mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2025-12-01 00:43:33 -06:00
[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:
parent
4ee436e98a
commit
fc02d3c6f7
73 changed files with 55005 additions and 141 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:"-"`
|
||||
}
|
||||
|
||||
/*
|
||||
|
|
|
|||
|
|
@ -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."`
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
184
internal/language/language.go
Normal file
184
internal/language/language.go
Normal 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
|
||||
}
|
||||
142
internal/language/language_test.go
Normal file
142
internal/language/language_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,7 +80,6 @@ func (suite *AdminStandardTestSuite) SetupSuite() {
|
|||
|
||||
func (suite *AdminStandardTestSuite) SetupTest() {
|
||||
suite.state.Caches.Init()
|
||||
|
||||
|
||||
testrig.InitTestConfig()
|
||||
testrig.InitTestLog()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue