summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorLibravatar tobi <31960611+tsmethurst@users.noreply.github.com>2023-11-17 11:35:28 +0100
committerLibravatar GitHub <noreply@github.com>2023-11-17 11:35:28 +0100
commitfc02d3c6f7db5a7794448f31fd9d6d81d3d224eb (patch)
treef792f799abadf784e493933af597d8f2292ab776 /internal
parent[bugfix] process account delete side effects in serial, not in parallel (#2360) (diff)
downloadgotosocial-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.go30
-rw-r--r--internal/api/model/status.go9
-rw-r--r--internal/config/config.go16
-rw-r--r--internal/config/defaults.go2
-rw-r--r--internal/config/flags.go1
-rw-r--r--internal/config/gen/gen.go1
-rw-r--r--internal/config/helpers.gen.go26
-rw-r--r--internal/config/validate.go128
-rw-r--r--internal/config/validate_test.go8
-rw-r--r--internal/language/language.go184
-rw-r--r--internal/language/language_test.go142
-rw-r--r--internal/processing/account/statuses.go51
-rw-r--r--internal/processing/admin/admin_test.go1
-rw-r--r--internal/processing/status/get.go15
-rw-r--r--internal/typeutils/converter_test.go2
-rw-r--r--internal/typeutils/internaltofrontend.go28
-rw-r--r--internal/typeutils/internaltofrontend_test.go10
-rw-r--r--internal/web/about.go1
-rw-r--r--internal/web/profile.go10
-rw-r--r--internal/web/thread.go2
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