diff options
author | 2023-11-17 11:35:28 +0100 | |
---|---|---|
committer | 2023-11-17 11:35:28 +0100 | |
commit | fc02d3c6f7db5a7794448f31fd9d6d81d3d224eb (patch) | |
tree | f792f799abadf784e493933af597d8f2292ab776 /internal | |
parent | [bugfix] process account delete side effects in serial, not in parallel (#2360) (diff) | |
download | gotosocial-fc02d3c6f7db5a7794448f31fd9d6d81d3d224eb.tar.xz |
[feature] Set/show instance language(s); show post language on frontend (#2362)
* update go text, include text/display
* [feature] Set instance langs, show post lang on frontend
* go fmt
* WebGet
* set language for whole article, don't use FA icon
* mention instance languages + other optional config vars
* little tweak
* put languages in config properly
* warn log language parse
* change some naming around
* tidy up validate a bit
* lint
* rename LanguageTmpl in template
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 |