[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
This commit is contained in:
tobi 2023-11-17 11:35:28 +01:00 committed by GitHub
commit fc02d3c6f7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
73 changed files with 55005 additions and 141 deletions

View file

@ -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,

View file

@ -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:"-"`
}
/*

View file

@ -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."`

View file

@ -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,

View file

@ -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"))

View file

@ -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()

View file

@ -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()

View file

@ -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))
errf(
"%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, "; "))
}
return nil
return errs.Combine()
}

View file

@ -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) {

View file

@ -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
}

View file

@ -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)
}
}
}

View file

@ -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
}

View file

@ -80,7 +80,6 @@ func (suite *AdminStandardTestSuite) SetupSuite() {
func (suite *AdminStandardTestSuite) SetupTest() {
suite.state.Caches.Init()
testrig.InitTestConfig()
testrig.InitTestLog()

View file

@ -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,

View file

@ -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())

View file

@ -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,
}

View file

@ -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"

View file

@ -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{

View file

@ -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"},

View file

@ -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