diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/api/client/instance/instancepatch_test.go | 30 | ||||
| -rw-r--r-- | internal/api/model/status.go | 9 | ||||
| -rw-r--r-- | internal/config/config.go | 16 | ||||
| -rw-r--r-- | internal/config/defaults.go | 2 | ||||
| -rw-r--r-- | internal/config/flags.go | 1 | ||||
| -rw-r--r-- | internal/config/gen/gen.go | 1 | ||||
| -rw-r--r-- | internal/config/helpers.gen.go | 26 | ||||
| -rw-r--r-- | internal/config/validate.go | 128 | ||||
| -rw-r--r-- | internal/config/validate_test.go | 8 | ||||
| -rw-r--r-- | internal/language/language.go | 184 | ||||
| -rw-r--r-- | internal/language/language_test.go | 142 | ||||
| -rw-r--r-- | internal/processing/account/statuses.go | 51 | ||||
| -rw-r--r-- | internal/processing/admin/admin_test.go | 1 | ||||
| -rw-r--r-- | internal/processing/status/get.go | 15 | ||||
| -rw-r--r-- | internal/typeutils/converter_test.go | 2 | ||||
| -rw-r--r-- | internal/typeutils/internaltofrontend.go | 28 | ||||
| -rw-r--r-- | internal/typeutils/internaltofrontend_test.go | 10 | ||||
| -rw-r--r-- | internal/web/about.go | 1 | ||||
| -rw-r--r-- | internal/web/profile.go | 10 | ||||
| -rw-r--r-- | internal/web/thread.go | 2 | 
20 files changed, 586 insertions, 81 deletions
diff --git a/internal/api/client/instance/instancepatch_test.go b/internal/api/client/instance/instancepatch_test.go index 2fc045855..1f8b691be 100644 --- a/internal/api/client/instance/instancepatch_test.go +++ b/internal/api/client/instance/instancepatch_test.go @@ -82,7 +82,10 @@ func (suite *InstancePatchTestSuite) TestInstancePatch1() {    "short_description": "\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e",    "email": "someone@example.org",    "version": "0.0.0-testrig", -  "languages": [], +  "languages": [ +    "nl", +    "en-gb" +  ],    "registrations": true,    "approval_required": true,    "invites_enabled": false, @@ -196,7 +199,10 @@ func (suite *InstancePatchTestSuite) TestInstancePatch2() {    "short_description": "\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e",    "email": "admin@example.org",    "version": "0.0.0-testrig", -  "languages": [], +  "languages": [ +    "nl", +    "en-gb" +  ],    "registrations": true,    "approval_required": true,    "invites_enabled": false, @@ -310,7 +316,10 @@ func (suite *InstancePatchTestSuite) TestInstancePatch3() {    "short_description": "\u003cp\u003eThis is some html, which is \u003cem\u003eallowed\u003c/em\u003e in short descriptions.\u003c/p\u003e",    "email": "admin@example.org",    "version": "0.0.0-testrig", -  "languages": [], +  "languages": [ +    "nl", +    "en-gb" +  ],    "registrations": true,    "approval_required": true,    "invites_enabled": false, @@ -475,7 +484,10 @@ func (suite *InstancePatchTestSuite) TestInstancePatch6() {    "short_description": "\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e",    "email": "",    "version": "0.0.0-testrig", -  "languages": [], +  "languages": [ +    "nl", +    "en-gb" +  ],    "registrations": true,    "approval_required": true,    "invites_enabled": false, @@ -611,7 +623,10 @@ func (suite *InstancePatchTestSuite) TestInstancePatch8() {    "short_description": "\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e",    "email": "admin@example.org",    "version": "0.0.0-testrig", -  "languages": [], +  "languages": [ +    "nl", +    "en-gb" +  ],    "registrations": true,    "approval_required": true,    "invites_enabled": false, @@ -762,7 +777,10 @@ func (suite *InstancePatchTestSuite) TestInstancePatch9() {    "short_description": "\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e",    "email": "admin@example.org",    "version": "0.0.0-testrig", -  "languages": [], +  "languages": [ +    "nl", +    "en-gb" +  ],    "registrations": true,    "approval_required": true,    "invites_enabled": false, diff --git a/internal/api/model/status.go b/internal/api/model/status.go index a6c7f43a4..1efae9cfc 100644 --- a/internal/api/model/status.go +++ b/internal/api/model/status.go @@ -17,6 +17,8 @@  package model +import "github.com/superseriousbusiness/gotosocial/internal/language" +  // Status models a status or post.  //  // swagger:model status @@ -98,6 +100,13 @@ type Status struct {  	// so the user may redraft from the source text without the client having to reverse-engineer  	// the original text from the HTML content.  	Text string `json:"text,omitempty"` + +	// Additional fields not exposed via JSON +	// (used only internally for templating etc). + +	// Template-ready language tag + string, based +	// on *status.Language. Nil for non-web statuses +	LanguageTag *language.Language `json:"-"`  }  /* diff --git a/internal/config/config.go b/internal/config/config.go index 7cb31d0a1..b7d2eff36 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -23,6 +23,7 @@ import (  	"codeberg.org/gruf/go-bytesize"  	"github.com/mitchellh/mapstructure" +	"github.com/superseriousbusiness/gotosocial/internal/language"  )  // cfgtype is the reflected type information of Configuration{}. @@ -76,13 +77,14 @@ type Configuration struct {  	WebTemplateBaseDir string `name:"web-template-base-dir" usage:"Basedir for html templating files for rendering pages and composing emails."`  	WebAssetBaseDir    string `name:"web-asset-base-dir" usage:"Directory to serve static assets from, accessible at example.org/assets/"` -	InstanceFederationMode         string `name:"instance-federation-mode" usage:"Set instance federation mode."` -	InstanceExposePeers            bool   `name:"instance-expose-peers" usage:"Allow unauthenticated users to query /api/v1/instance/peers?filter=open"` -	InstanceExposeSuspended        bool   `name:"instance-expose-suspended" usage:"Expose suspended instances via web UI, and allow unauthenticated users to query /api/v1/instance/peers?filter=suspended"` -	InstanceExposeSuspendedWeb     bool   `name:"instance-expose-suspended-web" usage:"Expose list of suspended instances as webpage on /about/suspended"` -	InstanceExposePublicTimeline   bool   `name:"instance-expose-public-timeline" usage:"Allow unauthenticated users to query /api/v1/timelines/public"` -	InstanceDeliverToSharedInboxes bool   `name:"instance-deliver-to-shared-inboxes" usage:"Deliver federated messages to shared inboxes, if they're available."` -	InstanceInjectMastodonVersion  bool   `name:"instance-inject-mastodon-version" usage:"This injects a Mastodon compatible version in /api/v1/instance to help Mastodon clients that use that version for feature detection"` +	InstanceFederationMode         string             `name:"instance-federation-mode" usage:"Set instance federation mode."` +	InstanceExposePeers            bool               `name:"instance-expose-peers" usage:"Allow unauthenticated users to query /api/v1/instance/peers?filter=open"` +	InstanceExposeSuspended        bool               `name:"instance-expose-suspended" usage:"Expose suspended instances via web UI, and allow unauthenticated users to query /api/v1/instance/peers?filter=suspended"` +	InstanceExposeSuspendedWeb     bool               `name:"instance-expose-suspended-web" usage:"Expose list of suspended instances as webpage on /about/suspended"` +	InstanceExposePublicTimeline   bool               `name:"instance-expose-public-timeline" usage:"Allow unauthenticated users to query /api/v1/timelines/public"` +	InstanceDeliverToSharedInboxes bool               `name:"instance-deliver-to-shared-inboxes" usage:"Deliver federated messages to shared inboxes, if they're available."` +	InstanceInjectMastodonVersion  bool               `name:"instance-inject-mastodon-version" usage:"This injects a Mastodon compatible version in /api/v1/instance to help Mastodon clients that use that version for feature detection"` +	InstanceLanguages              language.Languages `name:"instance-languages" usage:"BCP47 language tags for the instance. Used to indicate the preferred languages of instance residents (in order from most-preferred to least-preferred)."`  	AccountsRegistrationOpen bool `name:"accounts-registration-open" usage:"Allow anyone to submit an account signup request. If false, server will be invite-only."`  	AccountsApprovalRequired bool `name:"accounts-approval-required" usage:"Do account signups require approval by an admin or moderator before user can log in? If false, new registrations will be automatically approved."` diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 2a7c6f9db..3da489501 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -22,6 +22,7 @@ import (  	"codeberg.org/gruf/go-bytesize"  	"github.com/coreos/go-oidc/v3/oidc" +	"github.com/superseriousbusiness/gotosocial/internal/language"  )  // Defaults contains a populated Configuration with reasonable defaults. Note that @@ -62,6 +63,7 @@ var Defaults = Configuration{  	InstanceExposeSuspended:        false,  	InstanceExposeSuspendedWeb:     false,  	InstanceDeliverToSharedInboxes: true, +	InstanceLanguages:              make(language.Languages, 0),  	AccountsRegistrationOpen: true,  	AccountsApprovalRequired: true, diff --git a/internal/config/flags.go b/internal/config/flags.go index b29d0fe04..2dc583abc 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -88,6 +88,7 @@ func (s *ConfigState) AddServerFlags(cmd *cobra.Command) {  		cmd.Flags().Bool(InstanceExposeSuspendedFlag(), cfg.InstanceExposeSuspended, fieldtag("InstanceExposeSuspended", "usage"))  		cmd.Flags().Bool(InstanceExposeSuspendedWebFlag(), cfg.InstanceExposeSuspendedWeb, fieldtag("InstanceExposeSuspendedWeb", "usage"))  		cmd.Flags().Bool(InstanceDeliverToSharedInboxesFlag(), cfg.InstanceDeliverToSharedInboxes, fieldtag("InstanceDeliverToSharedInboxes", "usage")) +		// cmd.Flags().StringSlice(InstanceLanguagesFlag(), cfg.InstanceLanguages.TagStrs(), fieldtag("InstanceLanguages", "usage"))  		// Accounts  		cmd.Flags().Bool(AccountsRegistrationOpenFlag(), cfg.AccountsRegistrationOpen, fieldtag("AccountsRegistrationOpen", "usage")) diff --git a/internal/config/gen/gen.go b/internal/config/gen/gen.go index d9fb30904..96e036d98 100644 --- a/internal/config/gen/gen.go +++ b/internal/config/gen/gen.go @@ -67,6 +67,7 @@ func main() {  	fmt.Fprint(output, "import (\n")  	fmt.Fprint(output, "\t\"time\"\n\n")  	fmt.Fprint(output, "\t\"codeberg.org/gruf/go-bytesize\"\n") +	fmt.Fprint(output, "\t\"github.com/superseriousbusiness/gotosocial/internal/langs\"\n")  	fmt.Fprint(output, ")\n\n")  	generateFields(output, nil, reflect.TypeOf(config.Configuration{}))  	_ = output.Close() diff --git a/internal/config/helpers.gen.go b/internal/config/helpers.gen.go index 6e9b71812..a008092d3 100644 --- a/internal/config/helpers.gen.go +++ b/internal/config/helpers.gen.go @@ -22,6 +22,7 @@ import (  	"time"  	"codeberg.org/gruf/go-bytesize" +	"github.com/superseriousbusiness/gotosocial/internal/language"  )  // GetLogLevel safely fetches the Configuration value for state's 'LogLevel' field @@ -924,6 +925,31 @@ func GetInstanceInjectMastodonVersion() bool { return global.GetInstanceInjectMa  // SetInstanceInjectMastodonVersion safely sets the value for global configuration 'InstanceInjectMastodonVersion' field  func SetInstanceInjectMastodonVersion(v bool) { global.SetInstanceInjectMastodonVersion(v) } +// GetInstanceLanguages safely fetches the Configuration value for state's 'InstanceLanguages' field +func (st *ConfigState) GetInstanceLanguages() (v language.Languages) { +	st.mutex.RLock() +	v = st.config.InstanceLanguages +	st.mutex.RUnlock() +	return +} + +// SetInstanceLanguages safely sets the Configuration value for state's 'InstanceLanguages' field +func (st *ConfigState) SetInstanceLanguages(v language.Languages) { +	st.mutex.Lock() +	defer st.mutex.Unlock() +	st.config.InstanceLanguages = v +	st.reloadToViper() +} + +// InstanceLanguagesFlag returns the flag name for the 'InstanceLanguages' field +func InstanceLanguagesFlag() string { return "instance-languages" } + +// GetInstanceLanguages safely fetches the value for global configuration 'InstanceLanguages' field +func GetInstanceLanguages() language.Languages { return global.GetInstanceLanguages() } + +// SetInstanceLanguages safely sets the value for global configuration 'InstanceLanguages' field +func SetInstanceLanguages(v language.Languages) { global.SetInstanceLanguages(v) } +  // GetAccountsRegistrationOpen safely fetches the Configuration value for state's 'AccountsRegistrationOpen' field  func (st *ConfigState) GetAccountsRegistrationOpen() (v bool) {  	st.mutex.RLock() diff --git a/internal/config/validate.go b/internal/config/validate.go index 45cdc4eee..d79d83b9d 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -18,85 +18,131 @@  package config  import ( -	"errors"  	"fmt" -	"strings"  	"github.com/miekg/dns" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/language"  	"github.com/superseriousbusiness/gotosocial/internal/log"  ) -// Validate validates global config settings which don't have defaults, to make sure they are set sensibly. +// Validate validates global config settings.  func Validate() error { -	errs := []error{} +	// Gather all validation errors in +	// easily readable format for admins. +	var ( +		errs gtserror.MultiError +		errf = func(format string, a ...any) { +			errs = append(errs, fmt.Errorf(format, a...)) +		} +	) -	// host +	// `host`  	host := GetHost()  	if host == "" { -		errs = append(errs, fmt.Errorf("%s must be set", HostFlag())) +		errf("%s must be set", HostFlag())  	} -	// accountDomain; only check if host was set, otherwise there's no point +	// If `account-domain` and `host` +	// are set, `host` must be a valid +	// subdomain of `account-domain`.  	if host != "" { -		switch ad := GetAccountDomain(); ad { -		case "": +		ad := GetAccountDomain() +		if ad == "" { +			// `account-domain` not set, fall +			// back by setting it to `host`.  			SetAccountDomain(GetHost()) -		default: -			if !dns.IsSubDomain(ad, host) { -				errs = append(errs, fmt.Errorf("%s was %s and %s was %s, but %s is not a valid subdomain of %s", HostFlag(), host, AccountDomainFlag(), ad, host, ad)) -			} +		} else if !dns.IsSubDomain(ad, host) { +			errf( +				"%s %s is not a valid subdomain of %s %s", +				AccountDomainFlag(), ad, HostFlag(), host, +			)  		}  	} -	// protocol +	// Ensure `protocol` sensibly set.  	switch proto := GetProtocol(); proto {  	case "https": -		// no problem -		break +		// No problem. +  	case "http": -		log.Warnf(nil, "%s was set to 'http'; this should *only* be used for debugging and tests!", ProtocolFlag()) +		log.Warnf( +			nil, +			"%s was set to 'http'; this should *only* be used for debugging and tests!", +			ProtocolFlag(), +		) +  	case "": -		errs = append(errs, fmt.Errorf("%s must be set", ProtocolFlag())) +		errf("%s must be set", ProtocolFlag()) +  	default: -		errs = append(errs, fmt.Errorf("%s must be set to either http or https, provided value was %s", ProtocolFlag(), proto)) +		errf( +			"%s must be set to either http or https, provided value was %s", +			ProtocolFlag(), proto, +		)  	} -	// federation mode -	switch federationMode := GetInstanceFederationMode(); federationMode { +	// `federation-mode` should be +	// "blocklist" or "allowlist". +	switch fediMode := GetInstanceFederationMode(); fediMode {  	case InstanceFederationModeBlocklist, InstanceFederationModeAllowlist: -		// no problem -		break +		// No problem. +  	case "": -		errs = append(errs, fmt.Errorf("%s must be set", InstanceFederationModeFlag())) +		errf("%s must be set", InstanceFederationModeFlag()) +  	default: -		errs = append(errs, fmt.Errorf("%s must be set to either blocklist or allowlist, provided value was %s", InstanceFederationModeFlag(), federationMode)) +		errf( +			"%s must be set to either blocklist or allowlist, provided value was %s", +			InstanceFederationModeFlag(), fediMode, +		)  	} +	// Parse `instance-languages`, and +	// set enriched version into config. +	parsedLangs, err := language.InitLangs(GetInstanceLanguages().TagStrs()) +	if err != nil { +		errf( +			"%s could not be parsed as an array of valid BCP47 language tags: %v", +			InstanceLanguagesFlag(), err, +		) +	} else { +		// Parsed successfully, put enriched +		// versions in config immediately. +		SetInstanceLanguages(parsedLangs) +	} + +	// `web-assets-base-dir`.  	webAssetsBaseDir := GetWebAssetBaseDir()  	if webAssetsBaseDir == "" { -		errs = append(errs, fmt.Errorf("%s must be set", WebAssetBaseDirFlag())) +		errf("%s must be set", WebAssetBaseDirFlag())  	} -	tlsChain := GetTLSCertificateChain() -	tlsKey := GetTLSCertificateKey() -	tlsChainFlag := TLSCertificateChainFlag() -	tlsKeyFlag := TLSCertificateKeyFlag() +	// Custom / LE TLS settings. +	// +	// Only one of custom certs or LE can be set, +	// and if using custom certs then all relevant +	// values must be provided. +	var ( +		tlsChain     = GetTLSCertificateChain() +		tlsKey       = GetTLSCertificateKey() +		tlsChainFlag = TLSCertificateChainFlag() +		tlsKeyFlag   = TLSCertificateKeyFlag() +	)  	if GetLetsEncryptEnabled() && (tlsChain != "" || tlsKey != "") { -		errs = append(errs, fmt.Errorf("%s cannot be enabled when %s and/or %s are also set", LetsEncryptEnabledFlag(), tlsChainFlag, tlsKeyFlag)) +		errf( +			"%s cannot be true when %s and/or %s are also set", +			LetsEncryptEnabledFlag(), tlsChainFlag, tlsKeyFlag, +		)  	}  	if (tlsChain != "" && tlsKey == "") || (tlsChain == "" && tlsKey != "") { -		errs = append(errs, fmt.Errorf("%s and %s need to both be set or unset", tlsChainFlag, tlsKeyFlag)) -	} - -	if len(errs) > 0 { -		errStrings := []string{} -		for _, err := range errs { -			errStrings = append(errStrings, err.Error()) -		} -		return errors.New(strings.Join(errStrings, "; ")) +		errf( +			"%s and %s need to both be set or unset", +			tlsChainFlag, tlsKeyFlag, +		)  	} -	return nil +	return errs.Combine()  } diff --git a/internal/config/validate_test.go b/internal/config/validate_test.go index 72c268c16..1ae3d6c78 100644 --- a/internal/config/validate_test.go +++ b/internal/config/validate_test.go @@ -80,7 +80,7 @@ func (suite *ConfigValidateTestSuite) TestValidateAccountDomainNotSubdomain1() {  	config.SetAccountDomain("example.com")  	err := config.Validate() -	suite.EqualError(err, "host was gts.example.org and account-domain was example.com, but gts.example.org is not a valid subdomain of example.com") +	suite.EqualError(err, "account-domain example.com is not a valid subdomain of host gts.example.org")  }  func (suite *ConfigValidateTestSuite) TestValidateAccountDomainNotSubdomain2() { @@ -90,7 +90,7 @@ func (suite *ConfigValidateTestSuite) TestValidateAccountDomainNotSubdomain2() {  	config.SetAccountDomain("gts.example.org")  	err := config.Validate() -	suite.EqualError(err, "host was example.org and account-domain was gts.example.org, but example.org is not a valid subdomain of gts.example.org") +	suite.EqualError(err, "account-domain gts.example.org is not a valid subdomain of host example.org")  }  func (suite *ConfigValidateTestSuite) TestValidateConfigNoProtocol() { @@ -118,7 +118,7 @@ func (suite *ConfigValidateTestSuite) TestValidateConfigNoProtocolOrHost() {  	config.SetProtocol("")  	err := config.Validate() -	suite.EqualError(err, "host must be set; protocol must be set") +	suite.EqualError(err, "host must be set\nprotocol must be set")  }  func (suite *ConfigValidateTestSuite) TestValidateConfigBadProtocol() { @@ -137,7 +137,7 @@ func (suite *ConfigValidateTestSuite) TestValidateConfigBadProtocolNoHost() {  	config.SetProtocol("foo")  	err := config.Validate() -	suite.EqualError(err, "host must be set; protocol must be set to either http or https, provided value was foo") +	suite.EqualError(err, "host must be set\nprotocol must be set to either http or https, provided value was foo")  }  func TestConfigValidateTestSuite(t *testing.T) { diff --git a/internal/language/language.go b/internal/language/language.go new file mode 100644 index 000000000..d91e3a4db --- /dev/null +++ b/internal/language/language.go @@ -0,0 +1,184 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package language + +import ( +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"golang.org/x/text/language" +	"golang.org/x/text/language/display" +) + +var namer display.Namer + +// InitLangs parses languages from the +// given slice of tags, and sets the `namer` +// display.Namer for the instance. +// +// This function should only be called once, +// since setting the namer is not thread safe. +func InitLangs(tagStrs []string) (Languages, error) { +	var ( +		languages = make(Languages, len(tagStrs)) +		tags      = make([]language.Tag, len(tagStrs)) +	) + +	// Reset namer. +	namer = nil + +	// Parse all tags first. +	for i, tagStr := range tagStrs { +		tag, err := language.Parse(tagStr) +		if err != nil { +			return nil, gtserror.Newf( +				"error parsing %s as BCP47 language tag: %w", +				tagStr, err, +			) +		} +		tags[i] = tag +	} + +	// Check if we can set a namer. +	if len(tags) != 0 { +		namer = display.Languages(tags[0]) +	} + +	// Fall namer back to English. +	if namer == nil { +		namer = display.Languages(language.English) +	} + +	// Parse nice language models from tags +	// (this will use the namer we just set). +	for i, tag := range tags { +		languages[i] = ParseTag(tag) +	} + +	return languages, nil +} + +// Language models a BCP47 language tag +// along with helper strings for the tag. +type Language struct { +	// BCP47 language tag +	Tag language.Tag +	// Normalized string +	// of BCP47 tag. +	TagStr string +	// Human-readable +	// language name(s). +	DisplayStr string +} + +// MarshalText implements encoding.TextMarshaler{}. +func (l *Language) MarshalText() ([]byte, error) { +	return []byte(l.TagStr), nil +} + +// UnmarshalText implements encoding.TextUnmarshaler{}. +func (l *Language) UnmarshalText(text []byte) error { +	lang, err := Parse(string(text)) +	if err != nil { +		return err +	} + +	*l = *lang +	return nil +} + +type Languages []*Language + +func (l Languages) Tags() []language.Tag { +	tags := make([]language.Tag, len(l)) +	for i, lang := range l { +		tags[i] = lang.Tag +	} + +	return tags +} + +func (l Languages) TagStrs() []string { +	tagStrs := make([]string, len(l)) +	for i, lang := range l { +		tagStrs[i] = lang.TagStr +	} + +	return tagStrs +} + +func (l Languages) DisplayStrs() []string { +	displayStrs := make([]string, len(l)) +	for i, lang := range l { +		displayStrs[i] = lang.DisplayStr +	} + +	return displayStrs +} + +// ParseTag parses and nicely formats the input language BCP47 tag, +// returning a Language with ready-to-use display and tag strings. +func ParseTag(tag language.Tag) *Language { +	l := new(Language) +	l.Tag = tag +	l.TagStr = tag.String() + +	var ( +		// Our name for the language. +		name string +		// Language's name for itself. +		selfName = display.Self.Name(tag) +	) + +	// Try to use namer +	// (if initialized). +	if namer != nil { +		name = namer.Name(tag) +	} + +	switch { +	case name == "": +		// We don't have a name for +		// this language, just use +		// its own name for itself. +		l.DisplayStr = selfName + +	case name == selfName: +		// Avoid repeating ourselves: +		// showing "English (English)" +		// is not useful. +		l.DisplayStr = name + +	default: +		// Include our name for the +		// language, and its own +		// name for itself. +		l.DisplayStr = name + " " + "(" + selfName + ")" +	} + +	return l +} + +// Parse parses and nicely formats the input language BCP47 tag, +// returning a Language with ready-to-use display and tag strings. +func Parse(lang string) (*Language, error) { +	tag, err := language.Parse(lang) +	if err != nil { +		return nil, err +	} + +	return ParseTag(tag), nil +} diff --git a/internal/language/language_test.go b/internal/language/language_test.go new file mode 100644 index 000000000..024448ab4 --- /dev/null +++ b/internal/language/language_test.go @@ -0,0 +1,142 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program.  If not, see <http://www.gnu.org/licenses/>. + +package language_test + +import ( +	"slices" +	"testing" + +	"github.com/superseriousbusiness/gotosocial/internal/language" +	golanguage "golang.org/x/text/language" +) + +func TestInstanceLangs(t *testing.T) { +	for i, test := range []struct { +		InstanceLangs       []string +		expectedLangs       []golanguage.Tag +		expectedLangStrs    []string +		expectedErr         error +		parseDisplayLang    string +		expectedDisplayLang string +	}{ +		{ +			InstanceLangs: []string{"en-us", "fr"}, +			expectedLangs: []golanguage.Tag{ +				golanguage.AmericanEnglish, +				golanguage.French, +			}, +			expectedLangStrs: []string{ +				"American English", +				"French (français)", +			}, +			parseDisplayLang:    "de", +			expectedDisplayLang: "German (Deutsch)", +		}, +		{ +			InstanceLangs: []string{"fr", "en-us"}, +			expectedLangs: []golanguage.Tag{ +				golanguage.French, +				golanguage.AmericanEnglish, +			}, +			expectedLangStrs: []string{ +				"français", +				"anglais américain (American English)", +			}, +			parseDisplayLang:    "de", +			expectedDisplayLang: "allemand (Deutsch)", +		}, +		{ +			InstanceLangs:       []string{}, +			expectedLangs:       []golanguage.Tag{}, +			expectedLangStrs:    []string{}, +			parseDisplayLang:    "de", +			expectedDisplayLang: "German (Deutsch)", +		}, +		{ +			InstanceLangs: []string{"zh"}, +			expectedLangs: []golanguage.Tag{ +				golanguage.Chinese, +			}, +			expectedLangStrs: []string{ +				"中文", +			}, +			parseDisplayLang:    "de", +			expectedDisplayLang: "德语 (Deutsch)", +		}, +		{ +			InstanceLangs: []string{"ar", "en"}, +			expectedLangs: []golanguage.Tag{ +				golanguage.Arabic, +				golanguage.English, +			}, +			expectedLangStrs: []string{ +				"العربية", +				"الإنجليزية (English)", +			}, +			parseDisplayLang:    "fi", +			expectedDisplayLang: "الفنلندية (suomi)", +		}, +		{ +			InstanceLangs: []string{"en-us"}, +			expectedLangs: []golanguage.Tag{ +				golanguage.AmericanEnglish, +			}, +			expectedLangStrs: []string{ +				"American English", +			}, +			parseDisplayLang:    "en-us", +			expectedDisplayLang: "American English", +		}, +		{ +			InstanceLangs: []string{"en-us"}, +			expectedLangs: []golanguage.Tag{ +				golanguage.AmericanEnglish, +			}, +			expectedLangStrs: []string{ +				"American English", +			}, +			parseDisplayLang:    "en-gb", +			expectedDisplayLang: "British English", +		}, +	} { +		languages, err := language.InitLangs(test.InstanceLangs) +		if err != test.expectedErr { +			t.Errorf("test %d expected error %v, got %v", i, test.expectedErr, err) +		} + +		parsedTags := languages.Tags() +		if !slices.Equal(test.expectedLangs, parsedTags) { +			t.Errorf("test %d expected language tags %v, got %v", i, test.expectedLangs, parsedTags) +		} + +		parsedLangStrs := languages.DisplayStrs() +		if !slices.Equal(test.expectedLangStrs, parsedLangStrs) { +			t.Errorf("test %d expected language strings %v, got %v", i, test.expectedLangStrs, parsedLangStrs) +		} + +		parsedLang, err := language.Parse(test.parseDisplayLang) +		if err != nil { +			t.Errorf("unexpected error %v", err) +			return +		} + +		if test.expectedDisplayLang != parsedLang.DisplayStr { +			t.Errorf("test %d expected to parse language %v, got %v", i, test.expectedDisplayLang, parsedLang.DisplayStr) +		} +	} +} diff --git a/internal/processing/account/statuses.go b/internal/processing/account/statuses.go index 1bdd3906b..0985bb4ef 100644 --- a/internal/processing/account/statuses.go +++ b/internal/processing/account/statuses.go @@ -133,7 +133,11 @@ func (p *Processor) StatusesGet(  // WebStatusesGet fetches a number of statuses (in descending order)  // from the given account. It selects only statuses which are suitable  // for showing on the public web profile of an account. -func (p *Processor) WebStatusesGet(ctx context.Context, targetAccountID string, maxID string) (*apimodel.PageableResponse, gtserror.WithCode) { +func (p *Processor) WebStatusesGet( +	ctx context.Context, +	targetAccountID string, +	maxID string, +) (*apimodel.PageableResponse, gtserror.WithCode) {  	account, err := p.state.DB.GetAccountByID(ctx, targetAccountID)  	if err != nil {  		if errors.Is(err, db.ErrNoEntries) { @@ -167,10 +171,10 @@ func (p *Processor) WebStatusesGet(ctx context.Context, targetAccountID string,  	)  	for _, s := range statuses { -		// Convert fetched statuses to API statuses. -		item, err := p.converter.StatusToAPIStatus(ctx, s, nil) +		// Convert fetched statuses to web view statuses. +		item, err := p.converter.StatusToWebStatus(ctx, s, nil)  		if err != nil { -			log.Errorf(ctx, "error convering to api status: %v", err) +			log.Errorf(ctx, "error convering to web status: %v", err)  			continue  		}  		items = append(items, item) @@ -183,8 +187,39 @@ func (p *Processor) WebStatusesGet(ctx context.Context, targetAccountID string,  	})  } -// PinnedStatusesGet is a shortcut for getting just an account's pinned statuses. -// Under the hood, it just calls StatusesGet using mostly default parameters. -func (p *Processor) PinnedStatusesGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string) (*apimodel.PageableResponse, gtserror.WithCode) { -	return p.StatusesGet(ctx, requestingAccount, targetAccountID, 0, false, false, "", "", true, false, false) +// WebStatusesGetPinned returns web versions of pinned statuses. +func (p *Processor) WebStatusesGetPinned( +	ctx context.Context, +	targetAccountID string, +) ([]*apimodel.Status, gtserror.WithCode) { +	statuses, err := p.state.DB.GetAccountPinnedStatuses(ctx, targetAccountID) +	if err != nil && !errors.Is(err, db.ErrNoEntries) { +		return nil, gtserror.NewErrorInternalError(err) +	} + +	webStatuses := make([]*apimodel.Status, 0, len(statuses)) +	for _, status := range statuses { +		if status.Visibility != gtsmodel.VisibilityPublic { +			// Skip non-public +			// pinned status. +			continue +		} + +		webStatus, err := p.converter.StatusToWebStatus(ctx, status, nil) +		if err != nil { +			log.Errorf(ctx, "error convering to web status: %v", err) +			continue +		} + +		// Normally when viewed via the API, 'pinned' is +		// only true if the *viewing account* has pinned +		// the status being viewed. For web statuses, +		// however, we still want to be able to indicate +		// a pinned status, so bodge this in here. +		webStatus.Pinned = true + +		webStatuses = append(webStatuses, webStatus) +	} + +	return webStatuses, nil  } diff --git a/internal/processing/admin/admin_test.go b/internal/processing/admin/admin_test.go index 367924664..01a3a88ff 100644 --- a/internal/processing/admin/admin_test.go +++ b/internal/processing/admin/admin_test.go @@ -80,7 +80,6 @@ func (suite *AdminStandardTestSuite) SetupSuite() {  func (suite *AdminStandardTestSuite) SetupTest() {  	suite.state.Caches.Init() -	  	testrig.InitTestConfig()  	testrig.InitTestLog() diff --git a/internal/processing/status/get.go b/internal/processing/status/get.go index 170dd0e53..ae6918e3f 100644 --- a/internal/processing/status/get.go +++ b/internal/processing/status/get.go @@ -36,6 +36,21 @@ func (p *Processor) Get(ctx context.Context, requestingAccount *gtsmodel.Account  	return p.c.GetAPIStatus(ctx, requestingAccount, targetStatus)  } +// WebGet gets the given status for web use, taking account of privacy settings. +func (p *Processor) WebGet(ctx context.Context, targetStatusID string) (*apimodel.Status, gtserror.WithCode) { +	targetStatus, errWithCode := p.c.GetVisibleTargetStatus(ctx, nil, targetStatusID) +	if errWithCode != nil { +		return nil, errWithCode +	} + +	webStatus, err := p.converter.StatusToWebStatus(ctx, targetStatus, nil) +	if err != nil { +		err = gtserror.Newf("error converting status: %w", err) +		return nil, gtserror.NewErrorInternalError(err) +	} +	return webStatus, nil +} +  func (p *Processor) contextGet(  	ctx context.Context,  	requestingAccount *gtsmodel.Account, diff --git a/internal/typeutils/converter_test.go b/internal/typeutils/converter_test.go index 122b98d4c..0195a6889 100644 --- a/internal/typeutils/converter_test.go +++ b/internal/typeutils/converter_test.go @@ -526,7 +526,7 @@ func (suite *TypeUtilsTestSuite) GetProcessor() *processing.Processor {  	mediaManager := testrig.NewTestMediaManager(&suite.state)  	federator := testrig.NewTestFederator(&suite.state, transportController, mediaManager)  	emailSender := testrig.NewEmailSender("../../web/template/", nil) -	 +  	processor := testrig.NewTestProcessor(&suite.state, federator, emailSender, mediaManager)  	testrig.StartWorkers(&suite.state, processor.Workers()) diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index 1a2c321f5..8138ee7b4 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -30,6 +30,7 @@ import (  	"github.com/superseriousbusiness/gotosocial/internal/db"  	"github.com/superseriousbusiness/gotosocial/internal/gtserror"  	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/language"  	"github.com/superseriousbusiness/gotosocial/internal/log"  	"github.com/superseriousbusiness/gotosocial/internal/media"  	"github.com/superseriousbusiness/gotosocial/internal/uris" @@ -656,7 +657,28 @@ func (c *Converter) StatusToWebStatus(  	s *gtsmodel.Status,  	requestingAccount *gtsmodel.Account,  ) (*apimodel.Status, error) { -	return c.statusToFrontend(ctx, s, requestingAccount) +	webStatus, err := c.statusToFrontend(ctx, s, requestingAccount) +	if err != nil { +		return nil, err +	} + +	// Add additional information for template. +	// Assume empty langs, hope for not empty language. +	webStatus.LanguageTag = new(language.Language) +	if lang := webStatus.Language; lang != nil { +		langTag, err := language.Parse(*lang) +		if err != nil { +			log.Warnf( +				ctx, +				"error parsing %s as language tag: %v", +				*lang, err, +			) +		} else { +			webStatus.LanguageTag = langTag +		} +	} + +	return webStatus, nil  }  // statusToFrontend is a package internal function for @@ -873,7 +895,7 @@ func (c *Converter) InstanceToAPIV1Instance(ctx context.Context, i *gtsmodel.Ins  		ShortDescription: i.ShortDescription,  		Email:            i.ContactEmail,  		Version:          config.GetSoftwareVersion(), -		Languages:        []string{}, // todo: not supported yet +		Languages:        config.GetInstanceLanguages().TagStrs(),  		Registrations:    config.GetAccountsRegistrationOpen(),  		ApprovalRequired: config.GetAccountsApprovalRequired(),  		InvitesEnabled:   false, // todo: not supported yet @@ -982,7 +1004,7 @@ func (c *Converter) InstanceToAPIV2Instance(ctx context.Context, i *gtsmodel.Ins  		SourceURL:     instanceSourceURL,  		Description:   i.Description,  		Usage:         apimodel.InstanceV2Usage{}, // todo: not implemented -		Languages:     []string{},                 // todo: not implemented +		Languages:     config.GetInstanceLanguages().TagStrs(),  		Rules:         c.InstanceRulesToAPIRules(i.Rules),  		Terms:         i.Terms,  	} diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go index d386c180e..ee8216e8f 100644 --- a/internal/typeutils/internaltofrontend_test.go +++ b/internal/typeutils/internaltofrontend_test.go @@ -712,7 +712,10 @@ func (suite *InternalToFrontendTestSuite) TestInstanceV1ToFrontend() {    "short_description": "\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e",    "email": "admin@example.org",    "version": "0.0.0-testrig", -  "languages": [], +  "languages": [ +    "nl", +    "en-gb" +  ],    "registrations": true,    "approval_required": true,    "invites_enabled": false, @@ -826,7 +829,10 @@ func (suite *InternalToFrontendTestSuite) TestInstanceV2ToFrontend() {    "thumbnail": {      "url": "http://localhost:8080/assets/logo.png"    }, -  "languages": [], +  "languages": [ +    "nl", +    "en-gb" +  ],    "configuration": {      "urls": {        "streaming": "wss://localhost:8080" diff --git a/internal/web/about.go b/internal/web/about.go index ebb1ceefa..89bb13f0d 100644 --- a/internal/web/about.go +++ b/internal/web/about.go @@ -39,6 +39,7 @@ func (m *Module) aboutGETHandler(c *gin.Context) {  	c.HTML(http.StatusOK, "about.tmpl", gin.H{  		"instance":         instance, +		"languages":        config.GetInstanceLanguages().DisplayStrs(),  		"ogMeta":           ogBase(instance),  		"blocklistExposed": config.GetInstanceExposeSuspendedWeb(),  		"stylesheets": []string{ diff --git a/internal/web/profile.go b/internal/web/profile.go index c16965adc..b2c3bb944 100644 --- a/internal/web/profile.go +++ b/internal/web/profile.go @@ -121,21 +121,17 @@ func (m *Module) profileGETHandler(c *gin.Context) {  	var (  		maxStatusID    = apiutil.ParseMaxID(c.Query(apiutil.MaxIDKey), "")  		paging         = maxStatusID != "" -		pinnedStatuses *apimodel.PageableResponse +		pinnedStatuses []*apimodel.Status  	)  	if !paging {  		// Client opened bare profile (from the top)  		// so load + display pinned statuses. -		pinnedStatuses, errWithCode = m.processor.Account().PinnedStatusesGet(ctx, authed.Account, targetAccount.ID) +		pinnedStatuses, errWithCode = m.processor.Account().WebStatusesGetPinned(ctx, targetAccount.ID)  		if errWithCode != nil {  			apiutil.WebErrorHandler(c, errWithCode, instanceGet)  			return  		} -	} else { -		// Don't load pinned statuses at -		// the top of profile while paging. -		pinnedStatuses = new(apimodel.PageableResponse)  	}  	// Get statuses from maxStatusID onwards (or from top if empty string). @@ -162,7 +158,7 @@ func (m *Module) profileGETHandler(c *gin.Context) {  		"robotsMeta":       robotsMeta,  		"statuses":         statusResp.Items,  		"statuses_next":    statusResp.NextLink, -		"pinned_statuses":  pinnedStatuses.Items, +		"pinned_statuses":  pinnedStatuses,  		"show_back_to_top": paging,  		"stylesheets":      stylesheets,  		"javascript":       []string{distPathPrefix + "/frontend.js"}, diff --git a/internal/web/thread.go b/internal/web/thread.go index 13dd5877d..523cf7579 100644 --- a/internal/web/thread.go +++ b/internal/web/thread.go @@ -111,7 +111,7 @@ func (m *Module) threadGETHandler(c *gin.Context) {  	}  	// Get the status itself from the processor using provided ID and authorization (if any). -	status, errWithCode := m.processor.Status().Get(ctx, authed.Account, targetStatusID) +	status, errWithCode := m.processor.Status().WebGet(ctx, targetStatusID)  	if errWithCode != nil {  		apiutil.WebErrorHandler(c, errWithCode, instanceGet)  		return  | 
