Merge branch 'main' into interaction_policy_updates

This commit is contained in:
tobi 2025-01-31 19:30:40 +01:00 committed by GitHub
commit 5e80c8a01d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 1443 additions and 586 deletions

View file

@ -69,6 +69,36 @@
"go.uber.org/automaxprocs/maxprocs"
)
// Maintenance starts and creates a GoToSocial server
// in maintenance mode (returns 503 for most requests).
var Maintenance action.GTSAction = func(ctx context.Context) error {
route, err := router.New(ctx)
if err != nil {
return fmt.Errorf("error creating maintenance router: %w", err)
}
// Route maintenance handlers.
maintenance := web.NewMaintenance()
maintenance.Route(route)
// Start the maintenance router.
if err := route.Start(); err != nil {
return fmt.Errorf("error starting maintenance router: %w", err)
}
// Catch shutdown signals from the OS.
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
sig := <-sigs // block until signal received
log.Infof(ctx, "received signal %s, shutting down", sig)
if err := route.Stop(); err != nil {
log.Errorf(ctx, "error stopping router: %v", err)
}
return nil
}
// Start creates and starts a gotosocial server
var Start action.GTSAction = func(ctx context.Context) error {
// Set GOMAXPROCS / GOMEMLIMIT
@ -148,6 +178,23 @@
log.Info(ctx, "done! exiting...")
}()
// Create maintenance router.
var err error
route, err = router.New(ctx)
if err != nil {
return fmt.Errorf("error creating maintenance router: %w", err)
}
// Route maintenance handlers.
maintenance := web.NewMaintenance()
maintenance.Route(route)
// Start the maintenance router to handle reqs
// while the instance is starting up / migrating.
if err := route.Start(); err != nil {
return fmt.Errorf("error starting maintenance router: %w", err)
}
// Initialize tracing (noop if not enabled).
if err := tracing.Initialize(); err != nil {
return fmt.Errorf("error initializing tracing: %w", err)
@ -359,9 +406,15 @@ func(context.Context, time.Time) {
HTTP router initialization
*/
// Close down the maintenance router.
if err := route.Stop(); err != nil {
return fmt.Errorf("error stopping maintenance router: %w", err)
}
// Instantiate the main router.
route, err = router.New(ctx)
if err != nil {
return fmt.Errorf("error creating router: %s", err)
return fmt.Errorf("error creating main router: %s", err)
}
// Start preparing middleware stack.

View file

@ -41,5 +41,19 @@ func serverCommands() *cobra.Command {
}
config.AddServerFlags(serverStartCmd)
serverCmd.AddCommand(serverStartCmd)
serverMaintenanceCmd := &cobra.Command{
Use: "maintenance",
Short: "start the gotosocial server in maintenance mode (returns 503 for almost all requests)",
PreRunE: func(cmd *cobra.Command, args []string) error {
return preRun(preRunArgs{cmd: cmd})
},
RunE: func(cmd *cobra.Command, args []string) error {
return run(cmd.Context(), server.Maintenance)
},
}
config.AddServerFlags(serverMaintenanceCmd)
serverCmd.AddCommand(serverMaintenanceCmd)
return serverCmd
}

View file

@ -8,7 +8,6 @@ We need to register a new application, which we can then use to request an OAuth
```bash
curl \
-X POST \
-H 'Content-Type:application/json' \
-d '{
"client_name": "your_app_name",
@ -89,7 +88,6 @@ You can do this with another `POST` request that looks like the following:
```bash
curl \
-X POST \
-H 'Content-Type: application/json' \
-d '{
"redirect_uri": "urn:ietf:wg:oauth:2.0:oob",

View file

@ -138,4 +138,15 @@ instance-subscriptions-process-from: "23:00"
# Examples: ["24h", "72h", "12h"]
# Default: "24h" (once per day).
instance-subscriptions-process-every: "24h"
# Bool. Set this to true to randomize stats served at
# the /api/v1|v2/instance and /nodeinfo/2.0 endpoints.
#
# This can be useful when you don't want bots to obtain
# reliable information about the amount of users and
# statuses on your instance.
#
# Options: [true, false]
# Default: false
instance-stats-randomize: false
```

View file

@ -8,7 +8,6 @@
```bash
curl \
-X POST \
-H 'Content-Type:application/json' \
-d '{
"client_name": "your_app_name",
@ -89,7 +88,6 @@ YOUR_AUTHORIZATION_TOKEN
```bash
curl \
-X POST \
-H 'Content-Type: application/json' \
-d '{
"redirect_uri": "urn:ietf:wg:oauth:2.0:oob",

View file

@ -425,6 +425,17 @@ instance-subscriptions-process-from: "23:00"
# Default: "24h" (once per day).
instance-subscriptions-process-every: "24h"
# Bool. Set this to true to randomize stats served at
# the /api/v1|v2/instance and /nodeinfo/2.0 endpoints.
#
# This can be useful when you don't want bots to obtain
# reliable information about the amount of users and
# statuses on your instance.
#
# Options: [true, false]
# Default: false
instance-stats-randomize: false
###########################
##### ACCOUNTS CONFIG #####
###########################

View file

@ -21,7 +21,9 @@
"net/http"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/gin-gonic/gin"
)
@ -58,6 +60,12 @@ func (m *Module) InstanceInformationGETHandlerV1(c *gin.Context) {
return
}
if config.GetInstanceStatsRandomize() {
// Replace actual stats with cached randomized ones.
instance.Stats["user_count"] = util.Ptr(int(instance.RandomStats.TotalUsers))
instance.Stats["status_count"] = util.Ptr(int(instance.RandomStats.Statuses))
}
apiutil.JSON(c, http.StatusOK, instance)
}
@ -93,5 +101,10 @@ func (m *Module) InstanceInformationGETHandlerV2(c *gin.Context) {
return
}
if config.GetInstanceStatsRandomize() {
// Replace actual stats with cached randomized ones.
instance.Usage.Users.ActiveMonth = int(instance.RandomStats.MonthlyActiveUsers)
}
apiutil.JSON(c, http.StatusOK, instance)
}

View file

@ -17,7 +17,10 @@
package model
import "mime/multipart"
import (
"mime/multipart"
"time"
)
// InstanceSettingsUpdateRequest models an instance update request.
//
@ -148,3 +151,11 @@ type InstanceConfigurationEmojis struct {
// example: 51200
EmojiSizeLimit int `json:"emoji_size_limit"`
}
// swagger:ignore
type RandomStats struct {
Statuses int64
TotalUsers int64
MonthlyActiveUsers int64
Generated time.Time
}

View file

@ -110,6 +110,13 @@ type InstanceV1 struct {
Terms string `json:"terms,omitempty"`
// Raw (unparsed) version of terms.
TermsRaw string `json:"terms_text,omitempty"`
// Random stats generated for the instance.
// Only used if `instance-stats-randomize` is true.
// Not serialized to the frontend.
//
// swagger:ignore
RandomStats `json:"-"`
}
// InstanceV1URLs models instance-relevant URLs for client application consumption.

View file

@ -74,6 +74,13 @@ type InstanceV2 struct {
Terms string `json:"terms,omitempty"`
// Raw (unparsed) version of terms.
TermsText string `json:"terms_text,omitempty"`
// Random stats generated for the instance.
// Only used if `instance-stats-randomize` is true.
// Not serialized to the frontend.
//
// swagger:ignore
RandomStats `json:"-"`
}
// Usage data for this instance.

View file

@ -104,13 +104,14 @@ func (e *Emoji) UncacheRemote(ctx context.Context, olderThan time.Time) (int, er
return total, gtserror.Newf("error getting remote emoji: %w", err)
}
// If no emojis / same group is returned, we reached the end.
// If no emojis / same group is
// returned, we reached the end.
if len(emojis) == 0 ||
olderThan.Equal(emojis[len(emojis)-1].CreatedAt) {
break
}
// Use last created-at as the next 'olderThan' value.
// Use last createdAt as next 'olderThan' value.
olderThan = emojis[len(emojis)-1].CreatedAt
for _, emoji := range emojis {

View file

@ -90,6 +90,7 @@ type Configuration struct {
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)."`
InstanceSubscriptionsProcessFrom string `name:"instance-subscriptions-process-from" usage:"Time of day from which to start running instance subscriptions processing jobs. Should be in the format 'hh:mm:ss', eg., '15:04:05'."`
InstanceSubscriptionsProcessEvery time.Duration `name:"instance-subscriptions-process-every" usage:"Period to elapse between instance subscriptions processing jobs, starting from instance-subscriptions-process-from."`
InstanceStatsRandomize bool `name:"instance-stats-randomize" usage:"Set to true to randomize the stats served at api/v1/instance and api/v2/instance endpoints. Home page stats remain unchanged."`
AccountsRegistrationOpen bool `name:"accounts-registration-open" usage:"Allow anyone to submit an account signup request. If false, server will be invite-only."`
AccountsReasonRequired bool `name:"accounts-reason-required" usage:"Do new account signups require a reason to be submitted on registration?"`

View file

@ -92,6 +92,7 @@ func (s *ConfigState) AddServerFlags(cmd *cobra.Command) {
cmd.Flags().StringSlice(InstanceLanguagesFlag(), cfg.InstanceLanguages.TagStrs(), fieldtag("InstanceLanguages", "usage"))
cmd.Flags().String(InstanceSubscriptionsProcessFromFlag(), cfg.InstanceSubscriptionsProcessFrom, fieldtag("InstanceSubscriptionsProcessFrom", "usage"))
cmd.Flags().Duration(InstanceSubscriptionsProcessEveryFlag(), cfg.InstanceSubscriptionsProcessEvery, fieldtag("InstanceSubscriptionsProcessEvery", "usage"))
cmd.Flags().Bool(InstanceStatsRandomizeFlag(), cfg.InstanceStatsRandomize, fieldtag("InstanceStatsRandomize", "usage"))
// Accounts
cmd.Flags().Bool(AccountsRegistrationOpenFlag(), cfg.AccountsRegistrationOpen, fieldtag("AccountsRegistrationOpen", "usage"))

View file

@ -1057,6 +1057,31 @@ func SetInstanceSubscriptionsProcessEvery(v time.Duration) {
global.SetInstanceSubscriptionsProcessEvery(v)
}
// GetInstanceStatsRandomize safely fetches the Configuration value for state's 'InstanceStatsRandomize' field
func (st *ConfigState) GetInstanceStatsRandomize() (v bool) {
st.mutex.RLock()
v = st.config.InstanceStatsRandomize
st.mutex.RUnlock()
return
}
// SetInstanceStatsRandomize safely sets the Configuration value for state's 'InstanceStatsRandomize' field
func (st *ConfigState) SetInstanceStatsRandomize(v bool) {
st.mutex.Lock()
defer st.mutex.Unlock()
st.config.InstanceStatsRandomize = v
st.reloadToViper()
}
// InstanceStatsRandomizeFlag returns the flag name for the 'InstanceStatsRandomize' field
func InstanceStatsRandomizeFlag() string { return "instance-stats-randomize" }
// GetInstanceStatsRandomize safely fetches the value for global configuration 'InstanceStatsRandomize' field
func GetInstanceStatsRandomize() bool { return global.GetInstanceStatsRandomize() }
// SetInstanceStatsRandomize safely sets the value for global configuration 'InstanceStatsRandomize' field
func SetInstanceStatsRandomize(v bool) { global.SetInstanceStatsRandomize(v) }
// GetAccountsRegistrationOpen safely fetches the Configuration value for state's 'AccountsRegistrationOpen' field
func (st *ConfigState) GetAccountsRegistrationOpen() (v bool) {
st.mutex.RLock()
@ -2699,7 +2724,7 @@ func (st *ConfigState) SetAdvancedRateLimitExceptionsParsed(v []netip.Prefix) {
}
// AdvancedRateLimitExceptionsParsedFlag returns the flag name for the 'AdvancedRateLimitExceptionsParsed' field
func AdvancedRateLimitExceptionsParsedFlag() string { return "" }
func AdvancedRateLimitExceptionsParsedFlag() string { return "advanced-rate-limit-exceptions-parsed" }
// GetAdvancedRateLimitExceptionsParsed safely fetches the value for global configuration 'AdvancedRateLimitExceptionsParsed' field
func GetAdvancedRateLimitExceptionsParsed() []netip.Prefix {

View file

@ -404,7 +404,8 @@ func (f *Federator) callForPubKey(
pubKeyID *url.URL,
) ([]byte, gtserror.WithCode) {
// Use a transport to dereference the remote.
trans, err := f.transportController.NewTransportForUsername(
trans, err := f.transport.NewTransportForUsername(
// We're on a hot path: don't retry if req fails.
gtscontext.SetFastFail(ctx),
requestedUsername,

View file

@ -639,7 +639,16 @@ func (d *Dereferencer) enrichAccount(
return nil, nil, gtserror.Newf("db error getting account after redirects: %w", err)
}
if alreadyAcc != nil {
switch {
case alreadyAcc == nil:
// nothing to do
case alreadyAcc.IsLocal():
// Request eventually redirected to a
// local account. Return it as-is here.
return alreadyAcc, nil, nil
default:
// We had this account stored
// under discovered final URI.
//
@ -718,12 +727,6 @@ func (d *Dereferencer) enrichAccount(
latestAcc.Username = cmp.Or(latestAcc.Username, accUsername)
}
if latestAcc.Domain == "" {
// Ensure we have a domain set by this point,
// otherwise it gets stored as a local user!
return nil, nil, gtserror.Newf("empty domain for %s", uri)
}
// Ensure the final parsed account URI matches
// the input URI we fetched (or received) it as.
if matches, err := util.URIMatches(
@ -740,10 +743,16 @@ func (d *Dereferencer) enrichAccount(
} else if !matches {
return nil, nil, gtserror.Newf(
"account uri %s does not match %s",
latestAcc.URI, uri.String(),
latestAcc.URI, uri,
)
}
// Ensure this isn't a local account,
// or a remote masquerading as such!
if latestAcc.IsLocal() {
return nil, nil, gtserror.Newf("cannot dereference local account %s", uri)
}
// Get current time.
now := time.Now()

View file

@ -18,11 +18,15 @@
package dereferencing_test
import (
"bytes"
"context"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"net/http"
"net/url"
"testing"
"time"
@ -33,6 +37,7 @@
"github.com/superseriousbusiness/gotosocial/internal/ap"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/federation/dereferencing"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/testrig"
)
@ -214,6 +219,111 @@ func (suite *AccountTestSuite) TestDereferenceLocalAccountWithUnknownUserURI() {
suite.Nil(fetchedAccount)
}
func (suite *AccountTestSuite) TestDereferenceLocalAccountByRedirect() {
ctx, cncl := context.WithCancel(context.Background())
defer cncl()
fetchingAccount := suite.testAccounts["local_account_1"]
targetAccount := suite.testAccounts["local_account_2"]
// Convert the target account to ActivityStreams model for dereference.
targetAccountable, err := suite.converter.AccountToAS(ctx, targetAccount)
suite.NoError(err)
suite.NotNil(targetAccountable)
// Serialize to "raw" JSON map for response.
rawJSON, err := ap.Serialize(targetAccountable)
suite.NoError(err)
// Finally serialize to actual bytes.
json, err := json.Marshal(rawJSON)
suite.NoError(err)
// Replace test HTTP client with one that always returns the target account AS model.
suite.client = testrig.NewMockHTTPClient(func(req *http.Request) (*http.Response, error) {
return &http.Response{
Status: http.StatusText(http.StatusOK),
StatusCode: http.StatusOK,
ContentLength: int64(len(json)),
Header: http.Header{"Content-Type": {"application/activity+json"}},
Body: io.NopCloser(bytes.NewReader(json)),
Request: &http.Request{URL: testrig.URLMustParse(targetAccount.URI)},
}, nil
}, "")
// Update dereferencer to use new test HTTP client.
suite.dereferencer = dereferencing.NewDereferencer(
&suite.state,
suite.converter,
testrig.NewTestTransportController(&suite.state, suite.client),
suite.visFilter,
suite.intFilter,
suite.media,
)
// Use any old input test URI, this doesn't actually matter what it is.
uri := testrig.URLMustParse("https://this-will-be-redirected.butts/")
// Try dereference the test URI, since it correctly redirects to us it should return our account.
account, accountable, err := suite.dereferencer.GetAccountByURI(ctx, fetchingAccount.Username, uri)
suite.NoError(err)
suite.Nil(accountable)
suite.NotNil(account)
suite.Equal(targetAccount.ID, account.ID)
}
func (suite *AccountTestSuite) TestDereferenceMasqueradingLocalAccount() {
ctx, cncl := context.WithCancel(context.Background())
defer cncl()
fetchingAccount := suite.testAccounts["local_account_1"]
targetAccount := suite.testAccounts["local_account_2"]
// Convert the target account to ActivityStreams model for dereference.
targetAccountable, err := suite.converter.AccountToAS(ctx, targetAccount)
suite.NoError(err)
suite.NotNil(targetAccountable)
// Serialize to "raw" JSON map for response.
rawJSON, err := ap.Serialize(targetAccountable)
suite.NoError(err)
// Finally serialize to actual bytes.
json, err := json.Marshal(rawJSON)
suite.NoError(err)
// Use any old input test URI, this doesn't actually matter what it is.
uri := testrig.URLMustParse("https://this-will-be-redirected.butts/")
// Replace test HTTP client with one that returns OUR account, but at their URI endpoint.
suite.client = testrig.NewMockHTTPClient(func(req *http.Request) (*http.Response, error) {
return &http.Response{
Status: http.StatusText(http.StatusOK),
StatusCode: http.StatusOK,
ContentLength: int64(len(json)),
Header: http.Header{"Content-Type": {"application/activity+json"}},
Body: io.NopCloser(bytes.NewReader(json)),
Request: &http.Request{URL: uri},
}, nil
}, "")
// Update dereferencer to use new test HTTP client.
suite.dereferencer = dereferencing.NewDereferencer(
&suite.state,
suite.converter,
testrig.NewTestTransportController(&suite.state, suite.client),
suite.visFilter,
suite.intFilter,
suite.media,
)
// Try dereference the test URI, since it correctly redirects to us it should return our account.
account, accountable, err := suite.dereferencer.GetAccountByURI(ctx, fetchingAccount.Username, uri)
suite.NotNil(err)
suite.Nil(account)
suite.Nil(accountable)
}
func (suite *AccountTestSuite) TestDereferenceRemoteAccountWithNonMatchingURI() {
fetchingAccount := suite.testAccounts["local_account_1"]

View file

@ -26,6 +26,7 @@
"github.com/superseriousbusiness/gotosocial/internal/filter/interaction"
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/media"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/storage"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
@ -34,10 +35,14 @@
type DereferencerStandardTestSuite struct {
suite.Suite
db db.DB
storage *storage.Driver
state state.State
client *testrig.MockHTTPClient
db db.DB
storage *storage.Driver
state state.State
client *testrig.MockHTTPClient
converter *typeutils.Converter
visFilter *visibility.Filter
intFilter *interaction.Filter
media *media.Manager
testRemoteStatuses map[string]vocab.ActivityStreamsNote
testRemotePeople map[string]vocab.ActivityStreamsPerson
@ -67,12 +72,15 @@ func (suite *DereferencerStandardTestSuite) SetupTest() {
suite.db = testrig.NewTestDB(&suite.state)
converter := typeutils.NewConverter(&suite.state)
suite.converter = typeutils.NewConverter(&suite.state)
suite.visFilter = visibility.NewFilter(&suite.state)
suite.intFilter = interaction.NewFilter(&suite.state)
suite.media = testrig.NewTestMediaManager(&suite.state)
testrig.StartTimelines(
&suite.state,
visibility.NewFilter(&suite.state),
converter,
suite.visFilter,
suite.converter,
)
suite.client = testrig.NewMockHTTPClient(nil, "../../../testrig/media")
@ -81,19 +89,16 @@ func (suite *DereferencerStandardTestSuite) SetupTest() {
suite.state.AdminActions = admin.New(suite.state.DB, &suite.state.Workers)
suite.state.Storage = suite.storage
visFilter := visibility.NewFilter(&suite.state)
intFilter := interaction.NewFilter(&suite.state)
media := testrig.NewTestMediaManager(&suite.state)
suite.dereferencer = dereferencing.NewDereferencer(
&suite.state,
converter,
suite.converter,
testrig.NewTestTransportController(
&suite.state,
suite.client,
),
visFilter,
intFilter,
media,
suite.visFilter,
suite.intFilter,
suite.media,
)
testrig.StandardDBSetup(suite.db, nil)
}

View file

@ -0,0 +1,87 @@
// 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 federatingdb
import (
"context"
"net/http"
"github.com/superseriousbusiness/activity/streams/vocab"
"github.com/superseriousbusiness/gotosocial/internal/ap"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/messages"
)
func (f *federatingDB) Block(ctx context.Context, blockable vocab.ActivityStreamsBlock) error {
log.DebugKV(ctx, "block", serialize{blockable})
// Extract relevant values from passed ctx.
activityContext := getActivityContext(ctx)
if activityContext.internal {
return nil // Already processed.
}
requesting := activityContext.requestingAcct
receiving := activityContext.receivingAcct
if receiving.IsMoving() {
// A Moving account
// can't do this.
return nil
}
// Convert received AS block type to internal model.
block, err := f.converter.ASBlockToBlock(ctx, blockable)
if err != nil {
err := gtserror.Newf("error converting from AS type: %w", err)
return gtserror.WrapWithCode(http.StatusBadRequest, err)
}
// Ensure block enacted by correct account.
if block.AccountID != requesting.ID {
return gtserror.NewfWithCode(http.StatusForbidden, "requester %s is not expected actor %s",
requesting.URI, block.Account.URI)
}
// Ensure block received by correct account.
if block.TargetAccountID != receiving.ID {
return gtserror.NewfWithCode(http.StatusForbidden, "receiver %s is not expected object %s",
receiving.URI, block.TargetAccount.URI)
}
// Generate new ID for block.
block.ID = id.NewULID()
// Insert the new validated block into the database.
if err := f.state.DB.PutBlock(ctx, block); err != nil {
return gtserror.Newf("error inserting %s into db: %w", block.URI, err)
}
// Push message to worker queue to handle block side-effects.
f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{
APObjectType: ap.ActivityBlock,
APActivityType: ap.ActivityCreate,
GTSModel: block,
Receiving: receiving,
Requesting: requesting,
})
return nil
}

View file

@ -20,9 +20,7 @@
import (
"context"
"errors"
"fmt"
"github.com/miekg/dns"
"github.com/superseriousbusiness/activity/streams/vocab"
"github.com/superseriousbusiness/gotosocial/internal/ap"
"github.com/superseriousbusiness/gotosocial/internal/db"
@ -49,115 +47,36 @@
func (f *federatingDB) Create(ctx context.Context, asType vocab.Type) error {
log.DebugKV(ctx, "create", serialize{asType})
// Cache entry for this activity type's ID for later
// checks in the Exist() function if we see it again.
f.activityIDs.Set(ap.GetJSONLDId(asType).String(), struct{}{})
// Extract relevant values from passed ctx.
activityContext := getActivityContext(ctx)
if activityContext.internal {
return nil // Already processed.
}
requestingAcct := activityContext.requestingAcct
receivingAcct := activityContext.receivingAcct
requesting := activityContext.requestingAcct
receiving := activityContext.receivingAcct
if requestingAcct.IsMoving() {
if requesting.IsMoving() {
// A Moving account
// can't do this.
return nil
}
// Cache entry for this create activity ID for later
// checks in the Exist() function if we see it again.
f.activityIDs.Set(ap.GetJSONLDId(asType).String(), struct{}{})
switch name := asType.GetTypeName(); name {
case ap.ActivityBlock:
// BLOCK SOMETHING
return f.activityBlock(ctx, asType, receivingAcct, requestingAcct)
case ap.ActivityCreate:
// CREATE SOMETHING
return f.activityCreate(ctx, asType, receivingAcct, requestingAcct)
case ap.ActivityFollow:
// FOLLOW SOMETHING
return f.activityFollow(ctx, asType, receivingAcct, requestingAcct)
case ap.ActivityLike:
// LIKE SOMETHING
return f.activityLike(ctx, asType, receivingAcct, requestingAcct)
case ap.ActivityFlag:
// FLAG / REPORT SOMETHING
return f.activityFlag(ctx, asType, receivingAcct, requestingAcct)
default:
log.Debugf(ctx, "unhandled object type: %s", name)
}
return nil
}
/*
BLOCK HANDLERS
*/
func (f *federatingDB) activityBlock(ctx context.Context, asType vocab.Type, receiving *gtsmodel.Account, requesting *gtsmodel.Account) error {
blockable, ok := asType.(vocab.ActivityStreamsBlock)
// Cast to the expected types we handle in this func.
creatable, ok := asType.(vocab.ActivityStreamsCreate)
if !ok {
return errors.New("activityBlock: could not convert type to block")
}
block, err := f.converter.ASBlockToBlock(ctx, blockable)
if err != nil {
return fmt.Errorf("activityBlock: could not convert Block to gts model block")
}
if block.AccountID != requesting.ID {
return fmt.Errorf(
"activityBlock: requestingAccount %s is not Block actor account %s",
requesting.URI, block.Account.URI,
)
}
if block.TargetAccountID != receiving.ID {
return fmt.Errorf(
"activityBlock: inbox account %s is not Block object account %s",
receiving.URI, block.TargetAccount.URI,
)
}
block.ID = id.NewULID()
if err := f.state.DB.PutBlock(ctx, block); err != nil {
return fmt.Errorf("activityBlock: database error inserting block: %s", err)
}
f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{
APObjectType: ap.ActivityBlock,
APActivityType: ap.ActivityCreate,
GTSModel: block,
Receiving: receiving,
Requesting: requesting,
})
return nil
}
/*
CREATE HANDLERS
*/
// activityCreate handles asType Create by checking
// the Object entries of the Create and calling other
// handlers as appropriate.
func (f *federatingDB) activityCreate(
ctx context.Context,
asType vocab.Type,
receivingAccount *gtsmodel.Account,
requestingAccount *gtsmodel.Account,
) error {
create, ok := asType.(vocab.ActivityStreamsCreate)
if !ok {
return gtserror.Newf("could not convert asType %T to ActivityStreamsCreate", asType)
log.Debugf(ctx, "unhandled object type: %s", asType.GetTypeName())
return nil
}
var errs gtserror.MultiError
// Extract objects from create activity.
objects := ap.ExtractObjects(create)
objects := ap.ExtractObjects(creatable)
// Extract PollOptionables (votes!) from objects slice.
optionables, objects := ap.ExtractPollOptionables(objects)
@ -166,8 +85,8 @@ func (f *federatingDB) activityCreate(
// Handle provided poll vote(s) creation, this can
// be for single or multiple votes in the same poll.
err := f.createPollOptionables(ctx,
receivingAccount,
requestingAccount,
receiving,
requesting,
optionables,
)
if err != nil {
@ -182,12 +101,12 @@ func (f *federatingDB) activityCreate(
for _, statusable := range statusables {
// Check if this is a forwarded object, i.e. did
// the account making the request also create this?
forwarded := !isSender(statusable, requestingAccount)
forwarded := !isSender(statusable, requesting)
// Handle create event for this statusable.
if err := f.createStatusable(ctx,
receivingAccount,
requestingAccount,
receiving,
requesting,
statusable,
forwarded,
); err != nil {
@ -340,8 +259,7 @@ func (f *federatingDB) createStatusable(
//
// It does this to try to ensure thread completion, but
// we have our own thread fetching mechanism anyway.
log.Debugf(ctx,
"status %s is not relevant to receiver (%v); dropping it",
log.Debugf(ctx, "status %s is not relevant to receiver (%v); dropping it",
ap.GetJSONLDId(statusable), err,
)
return nil
@ -351,8 +269,7 @@ func (f *federatingDB) createStatusable(
// gauge how much spam is being sent to them.
//
// TODO: add Prometheus metrics for this.
log.Infof(ctx,
"status %s looked like spam (%v); dropping it",
log.Infof(ctx, "status %s looked like spam (%v); dropping it",
ap.GetJSONLDId(statusable), err,
)
return nil
@ -398,210 +315,3 @@ func (f *federatingDB) createStatusable(
return nil
}
/*
FOLLOW HANDLERS
*/
func (f *federatingDB) activityFollow(ctx context.Context, asType vocab.Type, receivingAccount *gtsmodel.Account, requestingAccount *gtsmodel.Account) error {
follow, ok := asType.(vocab.ActivityStreamsFollow)
if !ok {
return errors.New("activityFollow: could not convert type to follow")
}
followRequest, err := f.converter.ASFollowToFollowRequest(ctx, follow)
if err != nil {
return fmt.Errorf("activityFollow: could not convert Follow to follow request: %s", err)
}
if followRequest.AccountID != requestingAccount.ID {
return fmt.Errorf(
"activityFollow: requestingAccount %s is not Follow actor account %s",
requestingAccount.URI, followRequest.Account.URI,
)
}
if followRequest.TargetAccountID != receivingAccount.ID {
return fmt.Errorf(
"activityFollow: inbox account %s is not Follow object account %s",
receivingAccount.URI, followRequest.TargetAccount.URI,
)
}
followRequest.ID = id.NewULID()
if err := f.state.DB.PutFollowRequest(ctx, followRequest); err != nil {
return fmt.Errorf("activityFollow: database error inserting follow request: %s", err)
}
f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{
APObjectType: ap.ActivityFollow,
APActivityType: ap.ActivityCreate,
GTSModel: followRequest,
Receiving: receivingAccount,
Requesting: requestingAccount,
})
return nil
}
/*
LIKE HANDLERS
*/
func (f *federatingDB) activityLike(
ctx context.Context,
asType vocab.Type,
receivingAcct *gtsmodel.Account,
requestingAcct *gtsmodel.Account,
) error {
like, ok := asType.(vocab.ActivityStreamsLike)
if !ok {
err := gtserror.Newf("could not convert asType %T to ActivityStreamsLike", asType)
return gtserror.SetMalformed(err)
}
fave, err := f.converter.ASLikeToFave(ctx, like)
if err != nil {
return gtserror.Newf("could not convert Like to fave: %w", err)
}
// Ensure requester not trying to
// Like on someone else's behalf.
if fave.AccountID != requestingAcct.ID {
text := fmt.Sprintf(
"requestingAcct %s is not Like actor account %s",
requestingAcct.URI, fave.Account.URI,
)
return gtserror.NewErrorForbidden(errors.New(text), text)
}
if !*fave.Status.Local {
// Only process likes of local statuses.
// TODO: process for remote statuses as well.
return nil
}
// Ensure valid Like target for requester.
policyResult, err := f.intFilter.StatusLikeable(ctx,
requestingAcct,
fave.Status,
)
if err != nil {
err := gtserror.Newf("error seeing if status %s is likeable: %w", fave.Status.ID, err)
return gtserror.NewErrorInternalError(err)
}
if policyResult.Forbidden() {
const errText = "requester does not have permission to Like this status"
err := gtserror.New(errText)
return gtserror.NewErrorForbidden(err, errText)
}
// Derive pendingApproval
// and preapproved status.
var (
pendingApproval bool
preApproved bool
)
switch {
case policyResult.WithApproval():
// Requester allowed to do
// this pending approval.
pendingApproval = true
case policyResult.MatchedOnCollection():
// Requester allowed to do this,
// but matched on collection.
// Preapprove Like and have the
// processor send out an Accept.
pendingApproval = true
preApproved = true
case policyResult.Permitted():
// Requester straight up
// permitted to do this,
// no need for Accept.
pendingApproval = false
}
// Set appropriate fields
// on fave and store it.
fave.ID = id.NewULID()
fave.PendingApproval = &pendingApproval
fave.PreApproved = preApproved
if err := f.state.DB.PutStatusFave(ctx, fave); err != nil {
if errors.Is(err, db.ErrAlreadyExists) {
// The fave already exists in the
// database, which means we've already
// handled side effects. We can just
// return nil here and be done with it.
return nil
}
return gtserror.Newf("db error inserting fave: %w", err)
}
f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{
APObjectType: ap.ActivityLike,
APActivityType: ap.ActivityCreate,
GTSModel: fave,
Receiving: receivingAcct,
Requesting: requestingAcct,
})
return nil
}
/*
FLAG HANDLERS
*/
func (f *federatingDB) activityFlag(ctx context.Context, asType vocab.Type, receivingAccount *gtsmodel.Account, requestingAccount *gtsmodel.Account) error {
flag, ok := asType.(vocab.ActivityStreamsFlag)
if !ok {
return errors.New("activityFlag: could not convert type to flag")
}
report, err := f.converter.ASFlagToReport(ctx, flag)
if err != nil {
return fmt.Errorf("activityFlag: could not convert Flag to report: %w", err)
}
// Requesting account must have at
// least two domains from the right
// in common with reporting account.
if dns.CompareDomainName(
requestingAccount.Domain,
report.Account.Domain,
) < 2 {
return fmt.Errorf(
"activityFlag: requesting account %s does not share a domain with Flag Actor account %s",
requestingAccount.URI, report.Account.URI,
)
}
if report.TargetAccountID != receivingAccount.ID {
return fmt.Errorf(
"activityFlag: inbox account %s is not Flag object account %s",
receivingAccount.URI, report.TargetAccount.URI,
)
}
report.ID = id.NewULID()
if err := f.state.DB.PutReport(ctx, report); err != nil {
return fmt.Errorf("activityFlag: database error inserting report: %w", err)
}
f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{
APObjectType: ap.ActivityFlag,
APActivityType: ap.ActivityCreate,
GTSModel: report,
Receiving: receivingAccount,
Requesting: requestingAccount,
})
return nil
}

View file

@ -25,6 +25,7 @@
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/activity/streams"
"github.com/superseriousbusiness/activity/streams/vocab"
"github.com/superseriousbusiness/gotosocial/internal/ap"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id"
@ -115,8 +116,10 @@ func (suite *CreateTestSuite) TestCreateFlag1() {
suite.FailNow(err.Error())
}
flag := t.(vocab.ActivityStreamsFlag)
ctx := createTestContext(reportedAccount, reportingAccount)
if err := suite.federatingDB.Create(ctx, t); err != nil {
if err := suite.federatingDB.Flag(ctx, flag); err != nil {
suite.FailNow(err.Error())
}

View file

@ -24,6 +24,7 @@
"codeberg.org/gruf/go-cache/v3/simple"
"github.com/superseriousbusiness/activity/pub"
"github.com/superseriousbusiness/activity/streams/vocab"
"github.com/superseriousbusiness/gotosocial/internal/ap"
"github.com/superseriousbusiness/gotosocial/internal/filter/interaction"
"github.com/superseriousbusiness/gotosocial/internal/filter/spam"
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
@ -34,18 +35,20 @@
// DB wraps the pub.Database interface with
// a couple of custom functions for GoToSocial.
type DB interface {
// Default functionality.
// Default
// functionality.
pub.Database
/*
Overridden functionality for calling from federatingProtocol.
*/
Undo(ctx context.Context, undo vocab.ActivityStreamsUndo) error
Accept(ctx context.Context, accept vocab.ActivityStreamsAccept) error
Reject(ctx context.Context, reject vocab.ActivityStreamsReject) error
Announce(ctx context.Context, announce vocab.ActivityStreamsAnnounce) error
Move(ctx context.Context, move vocab.ActivityStreamsMove) error
// Federating protocol overridden callback functionality.
Like(context.Context, vocab.ActivityStreamsLike) error
Block(context.Context, vocab.ActivityStreamsBlock) error
Follow(context.Context, vocab.ActivityStreamsFollow) error
Undo(context.Context, vocab.ActivityStreamsUndo) error
Accept(context.Context, vocab.ActivityStreamsAccept) error
Reject(context.Context, vocab.ActivityStreamsReject) error
Announce(context.Context, vocab.ActivityStreamsAnnounce) error
Move(context.Context, vocab.ActivityStreamsMove) error
Flag(context.Context, vocab.ActivityStreamsFlag) error
/*
Extra/convenience functionality.
@ -87,3 +90,9 @@ func New(
fdb.activityIDs.Init(0, 2048)
return &fdb
}
// storeActivityID stores an entry in the .activityIDs cache for this
// type's JSON-LD ID, for later checks in Exist() to mark it as seen.
func (f *federatingDB) storeActivityID(asType vocab.Type) {
f.activityIDs.Set(ap.GetJSONLDId(asType).String(), struct{}{})
}

View file

@ -0,0 +1,91 @@
// 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 federatingdb
import (
"context"
"net/http"
"github.com/miekg/dns"
"github.com/superseriousbusiness/activity/streams/vocab"
"github.com/superseriousbusiness/gotosocial/internal/ap"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/messages"
)
func (f *federatingDB) Flag(ctx context.Context, flaggable vocab.ActivityStreamsFlag) error {
log.DebugKV(ctx, "flag", serialize{flaggable})
// Mark activity as handled.
f.storeActivityID(flaggable)
// Extract relevant values from passed ctx.
activityContext := getActivityContext(ctx)
if activityContext.internal {
return nil // Already processed.
}
requesting := activityContext.requestingAcct
receiving := activityContext.receivingAcct
// Convert received AS flag type to internal report model.
report, err := f.converter.ASFlagToReport(ctx, flaggable)
if err != nil {
err := gtserror.Newf("error converting from AS type: %w", err)
return gtserror.WrapWithCode(http.StatusBadRequest, err)
}
// Requesting acc's domain must be at
// least a subdomain of the reporting
// account. i.e. if they're using a
// different account domain to host.
if dns.CompareDomainName(
requesting.Domain,
report.Account.Domain,
) < 2 {
return gtserror.NewfWithCode(http.StatusForbidden, "requester %s does not share a domain with Flag Actor account %s",
requesting.URI, report.Account.URI)
}
// Ensure report received by correct account.
if report.TargetAccountID != receiving.ID {
return gtserror.NewfWithCode(http.StatusForbidden, "receiver %s is not expected object %s",
receiving.URI, report.TargetAccount.URI)
}
// Generate new ID for report.
report.ID = id.NewULID()
// Insert the new validated reported into the database.
if err := f.state.DB.PutReport(ctx, report); err != nil {
return gtserror.Newf("error inserting %s into db: %w", report.URI, err)
}
// Push message to worker queue to handle report side-effects.
f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{
APObjectType: ap.ActivityFlag,
APActivityType: ap.ActivityCreate,
GTSModel: report,
Receiving: receiving,
Requesting: requesting,
})
return nil
}

View file

@ -0,0 +1,84 @@
// 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 federatingdb
import (
"context"
"net/http"
"github.com/superseriousbusiness/activity/streams/vocab"
"github.com/superseriousbusiness/gotosocial/internal/ap"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/messages"
)
func (f *federatingDB) Follow(ctx context.Context, followable vocab.ActivityStreamsFollow) error {
log.DebugKV(ctx, "follow", serialize{followable})
// Mark activity as handled.
f.storeActivityID(followable)
// Extract relevant values from passed ctx.
activityContext := getActivityContext(ctx)
if activityContext.internal {
return nil // Already processed.
}
requesting := activityContext.requestingAcct
receiving := activityContext.receivingAcct
// Convert received AS block type to internal follow request model.
followreq, err := f.converter.ASFollowToFollowRequest(ctx, followable)
if err != nil {
err := gtserror.Newf("error converting from AS type: %w", err)
return gtserror.WrapWithCode(http.StatusBadRequest, err)
}
// Ensure follow enacted by correct account.
if followreq.AccountID != requesting.ID {
return gtserror.NewfWithCode(http.StatusForbidden, "requester %s is not expected actor %s",
requesting.URI, followreq.Account.URI)
}
// Ensure follow received by correct account.
if followreq.TargetAccountID != receiving.ID {
return gtserror.NewfWithCode(http.StatusForbidden, "receiver %s is not expected object %s",
receiving.URI, followreq.TargetAccount.URI)
}
// Generate new ID for followreq.
followreq.ID = id.NewULID()
// Insert the new validate follow request into the database.
if err := f.state.DB.PutFollowRequest(ctx, followreq); err != nil {
return gtserror.Newf("error inserting %s into db: %w", followreq.URI, err)
}
// Push message to worker queue to handle followreq side-effects.
f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{
APObjectType: ap.ActivityFollow,
APActivityType: ap.ActivityCreate,
GTSModel: followreq,
Receiving: receiving,
Requesting: requesting,
})
return nil
}

View file

@ -0,0 +1,147 @@
// 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 federatingdb
import (
"context"
"errors"
"net/http"
"github.com/superseriousbusiness/activity/streams/vocab"
"github.com/superseriousbusiness/gotosocial/internal/ap"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/messages"
)
func (f *federatingDB) Like(ctx context.Context, likeable vocab.ActivityStreamsLike) error {
log.DebugKV(ctx, "like", serialize{likeable})
// Mark activity as handled.
f.storeActivityID(likeable)
// Extract relevant values from passed ctx.
activityContext := getActivityContext(ctx)
if activityContext.internal {
return nil // Already processed.
}
requesting := activityContext.requestingAcct
receiving := activityContext.receivingAcct
if receiving.IsMoving() {
// A Moving account
// can't do this.
return nil
}
// Convert received AS like type to internal fave model.
fave, err := f.converter.ASLikeToFave(ctx, likeable)
if err != nil {
err := gtserror.Newf("error converting from AS type: %w", err)
return gtserror.WrapWithCode(http.StatusBadRequest, err)
}
// Ensure fave enacted by correct account.
if fave.AccountID != requesting.ID {
return gtserror.NewfWithCode(http.StatusForbidden, "requester %s is not expected actor %s",
requesting.URI, fave.Account.URI)
}
// Ensure fave received by correct account.
if fave.TargetAccountID != receiving.ID {
return gtserror.NewfWithCode(http.StatusForbidden, "receiver %s is not expected object %s",
receiving.URI, fave.TargetAccount.URI)
}
if !*fave.Status.Local {
// Only process likes of local statuses.
// TODO: process for remote statuses as well.
return nil
}
// Ensure valid Like target for requester.
policyResult, err := f.intFilter.StatusLikeable(ctx,
requesting,
fave.Status,
)
if err != nil {
return gtserror.Newf("error seeing if status %s is likeable: %w", fave.Status.URI, err)
}
if policyResult.Forbidden() {
return gtserror.NewWithCode(http.StatusForbidden, "requester does not have permission to Like status")
}
// Derive pendingApproval
// and preapproved status.
var (
pendingApproval bool
preApproved bool
)
switch {
case policyResult.WithApproval():
// Requester allowed to do
// this pending approval.
pendingApproval = true
case policyResult.MatchedOnCollection():
// Requester allowed to do this,
// but matched on collection.
// Preapprove Like and have the
// processor send out an Accept.
pendingApproval = true
preApproved = true
case policyResult.Permitted():
// Requester straight up
// permitted to do this,
// no need for Accept.
pendingApproval = false
}
// Set appropriate fields
// on fave and store it.
fave.ID = id.NewULID()
fave.PendingApproval = &pendingApproval
fave.PreApproved = preApproved
if err := f.state.DB.PutStatusFave(ctx, fave); err != nil {
if errors.Is(err, db.ErrAlreadyExists) {
// The fave already exists in the
// database, which means we've already
// handled side effects. We can just
// return nil here and be done with it.
return nil
}
return gtserror.Newf("error inserting %s into db: %w", fave.URI, err)
}
f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{
APObjectType: ap.ActivityLike,
APActivityType: ap.ActivityCreate,
GTSModel: fave,
Receiving: receiving,
Requesting: requesting,
})
return nil
}

View file

@ -38,6 +38,9 @@
func (f *federatingDB) Move(ctx context.Context, move vocab.ActivityStreamsMove) error {
log.DebugKV(ctx, "move", serialize{move})
// Mark activity as handled.
f.storeActivityID(move)
activityContext := getActivityContext(ctx)
if activityContext.internal {
// Already processed.

View file

@ -43,20 +43,24 @@
func (f *federatingDB) Update(ctx context.Context, asType vocab.Type) error {
log.DebugKV(ctx, "update", serialize{asType})
// Mark activity as handled.
f.storeActivityID(asType)
// Extract relevant values from passed ctx.
activityContext := getActivityContext(ctx)
if activityContext.internal {
return nil // Already processed.
}
requestingAcct := activityContext.requestingAcct
receivingAcct := activityContext.receivingAcct
requesting := activityContext.requestingAcct
receiving := activityContext.receivingAcct
if accountable, ok := ap.ToAccountable(asType); ok {
return f.updateAccountable(ctx, receivingAcct, requestingAcct, accountable)
return f.updateAccountable(ctx, receiving, requesting, accountable)
}
if statusable, ok := ap.ToStatusable(asType); ok {
return f.updateStatusable(ctx, receivingAcct, requestingAcct, statusable)
return f.updateStatusable(ctx, receiving, requesting, statusable)
}
log.Debugf(ctx, "unhandled object type: %T", asType)

View file

@ -456,39 +456,8 @@ func (f *Federator) FederatingCallbacks(ctx context.Context) (
other []any,
err error,
) {
wrapped = pub.FederatingWrappedCallbacks{
// OnFollow determines what action to take for this
// particular callback if a Follow Activity is handled.
//
// For our implementation, we always want to do nothing
// because we have internal logic for handling follows.
OnFollow: pub.OnFollowDoNothing,
}
// Override some default behaviors to trigger our own side effects.
other = []any{
func(ctx context.Context, undo vocab.ActivityStreamsUndo) error {
return f.FederatingDB().Undo(ctx, undo)
},
func(ctx context.Context, accept vocab.ActivityStreamsAccept) error {
return f.FederatingDB().Accept(ctx, accept)
},
func(ctx context.Context, reject vocab.ActivityStreamsReject) error {
return f.FederatingDB().Reject(ctx, reject)
},
func(ctx context.Context, announce vocab.ActivityStreamsAnnounce) error {
return f.FederatingDB().Announce(ctx, announce)
},
}
// Define some of our own behaviors which are not
// overrides of the default pub.FederatingWrappedCallbacks.
other = append(other, []any{
func(ctx context.Context, move vocab.ActivityStreamsMove) error {
return f.FederatingDB().Move(ctx, move)
},
}...)
wrapped = f.wrapped
other = f.callback
return
}

View file

@ -36,14 +36,19 @@
} = (*Federator)(nil)
type Federator struct {
db db.DB
federatingDB federatingdb.DB
clock pub.Clock
converter *typeutils.Converter
transportController transport.Controller
mediaManager *media.Manager
actor pub.FederatingActor
db db.DB
federatingDB federatingdb.DB
clock pub.Clock
converter *typeutils.Converter
transport transport.Controller
mediaManager *media.Manager
actor pub.FederatingActor
dereferencing.Dereferencer
// store result of FederatingCallbacks() ahead
// of time since it's called in every PostInbox().
wrapped pub.FederatingWrappedCallbacks
callback []any
}
// NewFederator returns a new federator instance.
@ -58,12 +63,13 @@ func NewFederator(
) *Federator {
clock := &Clock{}
f := &Federator{
db: state.DB,
federatingDB: federatingDB,
clock: clock,
converter: converter,
transportController: transportController,
mediaManager: mediaManager,
db: state.DB,
federatingDB: federatingDB,
clock: clock,
converter: converter,
transport: transportController,
mediaManager: mediaManager,
Dereferencer: dereferencing.NewDereferencer(
state,
converter,
@ -72,6 +78,28 @@ func NewFederator(
intFilter,
mediaManager,
),
// prepared response to FederatingCallbacks()
wrapped: pub.FederatingWrappedCallbacks{
// OnFollow determines what action to take for this
// particular callback if a Follow Activity is handled.
//
// For our implementation, we always want to do nothing
// because we have internal logic for handling follows.
OnFollow: pub.OnFollowDoNothing,
},
callback: []any{
federatingDB.Like,
federatingDB.Block,
federatingDB.Follow,
federatingDB.Undo,
federatingDB.Accept,
federatingDB.Reject,
federatingDB.Announce,
federatingDB.Move,
federatingDB.Flag,
},
}
actor := newFederatingActor(f, f, federatingDB, clock)
f.actor = actor
@ -90,5 +118,5 @@ func (f *Federator) FederatingDB() federatingdb.DB {
// TransportController returns the underlying transport controller.
func (f *Federator) TransportController() transport.Controller {
return f.transportController
return f.transport
}

View file

@ -68,5 +68,5 @@ func (f *Federator) NewTransport(ctx context.Context, actorBoxIRI *url.URL, _ st
return nil, fmt.Errorf("id %s was neither an inbox path nor an outbox path", actorBoxIRI.String())
}
return f.transportController.NewTransportForUsername(ctx, username)
return f.transport.NewTransportForUsername(ctx, username)
}

View file

@ -18,7 +18,7 @@
package gtserror
import (
"errors"
"fmt"
"net/http"
"strings"
)
@ -53,37 +53,78 @@ type WithCode interface {
}
type withCode struct {
original error
safe error
code int
err error
safe string
code int
}
func (e withCode) Unwrap() error {
return e.original
func (e *withCode) Unwrap() error {
return e.err
}
func (e withCode) Error() string {
return e.original.Error()
func (e *withCode) Error() string {
return e.err.Error()
}
func (e withCode) Safe() string {
return e.safe.Error()
func (e *withCode) Safe() string {
return e.safe
}
func (e withCode) Code() int {
func (e *withCode) Code() int {
return e.code
}
// NewWithCode returns a new gtserror.WithCode that implements the error interface
// with given HTTP status code, providing status message of "${httpStatus}: ${msg}".
func NewWithCode(code int, msg string) WithCode {
return &withCode{
err: newAt(3, msg),
safe: http.StatusText(code) + ": " + msg,
code: code,
}
}
// NewfWithCode returns a new formatted gtserror.WithCode that implements the error interface
// with given HTTP status code, provided formatted status message of "${httpStatus}: ${msg}".
func NewfWithCode(code int, msgf string, args ...any) WithCode {
msg := fmt.Sprintf(msgf, args...)
return &withCode{
err: newAt(3, msg),
safe: http.StatusText(code) + ": " + msg,
code: code,
}
}
// NewWithCodeSafe returns a new gtserror.WithCode wrapping error with given HTTP status
// code, hiding error message externally, providing status message of "${httpStatus}: ${safe}".
func NewWithCodeSafe(code int, err error, safe string) WithCode {
return &withCode{
err: err,
safe: http.StatusText(code) + ": " + safe,
code: code,
}
}
// WrapWithCode returns a new gtserror.WithCode wrapping error with given HTTP
// status code, hiding error message externally, providing standard status message.
func WrapWithCode(code int, err error) WithCode {
return &withCode{
err: err,
safe: http.StatusText(code),
code: code,
}
}
// NewErrorBadRequest returns an ErrorWithCode 400 with the given original error and optional help text.
func NewErrorBadRequest(original error, helpText ...string) WithCode {
safe := http.StatusText(http.StatusBadRequest)
if helpText != nil {
safe = safe + ": " + strings.Join(helpText, ": ")
}
return withCode{
original: original,
safe: errors.New(safe),
code: http.StatusBadRequest,
return &withCode{
err: original,
safe: safe,
code: http.StatusBadRequest,
}
}
@ -93,10 +134,10 @@ func NewErrorUnauthorized(original error, helpText ...string) WithCode {
if helpText != nil {
safe = safe + ": " + strings.Join(helpText, ": ")
}
return withCode{
original: original,
safe: errors.New(safe),
code: http.StatusUnauthorized,
return &withCode{
err: original,
safe: safe,
code: http.StatusUnauthorized,
}
}
@ -106,10 +147,10 @@ func NewErrorForbidden(original error, helpText ...string) WithCode {
if helpText != nil {
safe = safe + ": " + strings.Join(helpText, ": ")
}
return withCode{
original: original,
safe: errors.New(safe),
code: http.StatusForbidden,
return &withCode{
err: original,
safe: safe,
code: http.StatusForbidden,
}
}
@ -119,10 +160,10 @@ func NewErrorNotFound(original error, helpText ...string) WithCode {
if helpText != nil {
safe = safe + ": " + strings.Join(helpText, ": ")
}
return withCode{
original: original,
safe: errors.New(safe),
code: http.StatusNotFound,
return &withCode{
err: original,
safe: safe,
code: http.StatusNotFound,
}
}
@ -132,10 +173,10 @@ func NewErrorInternalError(original error, helpText ...string) WithCode {
if helpText != nil {
safe = safe + ": " + strings.Join(helpText, ": ")
}
return withCode{
original: original,
safe: errors.New(safe),
code: http.StatusInternalServerError,
return &withCode{
err: original,
safe: safe,
code: http.StatusInternalServerError,
}
}
@ -145,10 +186,10 @@ func NewErrorConflict(original error, helpText ...string) WithCode {
if helpText != nil {
safe = safe + ": " + strings.Join(helpText, ": ")
}
return withCode{
original: original,
safe: errors.New(safe),
code: http.StatusConflict,
return &withCode{
err: original,
safe: safe,
code: http.StatusConflict,
}
}
@ -158,10 +199,10 @@ func NewErrorNotAcceptable(original error, helpText ...string) WithCode {
if helpText != nil {
safe = safe + ": " + strings.Join(helpText, ": ")
}
return withCode{
original: original,
safe: errors.New(safe),
code: http.StatusNotAcceptable,
return &withCode{
err: original,
safe: safe,
code: http.StatusNotAcceptable,
}
}
@ -171,10 +212,10 @@ func NewErrorUnprocessableEntity(original error, helpText ...string) WithCode {
if helpText != nil {
safe = safe + ": " + strings.Join(helpText, ": ")
}
return withCode{
original: original,
safe: errors.New(safe),
code: http.StatusUnprocessableEntity,
return &withCode{
err: original,
safe: safe,
code: http.StatusUnprocessableEntity,
}
}
@ -184,10 +225,10 @@ func NewErrorGone(original error, helpText ...string) WithCode {
if helpText != nil {
safe = safe + ": " + strings.Join(helpText, ": ")
}
return withCode{
original: original,
safe: errors.New(safe),
code: http.StatusGone,
return &withCode{
err: original,
safe: safe,
code: http.StatusGone,
}
}
@ -197,10 +238,10 @@ func NewErrorNotImplemented(original error, helpText ...string) WithCode {
if helpText != nil {
safe = safe + ": " + strings.Join(helpText, ": ")
}
return withCode{
original: original,
safe: errors.New(safe),
code: http.StatusNotImplemented,
return &withCode{
err: original,
safe: safe,
code: http.StatusNotImplemented,
}
}
@ -208,10 +249,10 @@ func NewErrorNotImplemented(original error, helpText ...string) WithCode {
// This error type should only be used when an http caller has already hung up their request.
// See: https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#nginx
func NewErrorClientClosedRequest(original error) WithCode {
return withCode{
original: original,
safe: errors.New(StatusTextClientClosedRequest),
code: StatusClientClosedRequest,
return &withCode{
err: original,
safe: StatusTextClientClosedRequest,
code: StatusClientClosedRequest,
}
}
@ -219,9 +260,9 @@ func NewErrorClientClosedRequest(original error) WithCode {
// This error type should only be used when the server has decided to hang up a client
// request after x amount of time, to avoid keeping extremely slow client requests open.
func NewErrorRequestTimeout(original error) WithCode {
return withCode{
original: original,
safe: errors.New(http.StatusText(http.StatusRequestTimeout)),
code: http.StatusRequestTimeout,
return &withCode{
err: original,
safe: http.StatusText(http.StatusRequestTimeout),
code: http.StatusRequestTimeout,
}
}

View file

@ -31,7 +31,8 @@
"github.com/superseriousbusiness/gotosocial/internal/log"
)
// Account represents either a local or a remote fediverse account, gotosocial or otherwise (mastodon, pleroma, etc).
// Account represents either a local or a remote fediverse
// account, gotosocial or otherwise (mastodon, pleroma, etc).
type Account struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created.
@ -83,9 +84,19 @@ type Account struct {
Stats *AccountStats `bun:"-"` // gtsmodel.AccountStats for this account.
}
// UsernameDomain returns account @username@domain (missing domain if local).
func (a *Account) UsernameDomain() string {
if a.IsLocal() {
return "@" + a.Username
}
return "@" + a.Username + "@" + a.Domain
}
// IsLocal returns whether account is a local user account.
func (a *Account) IsLocal() bool {
return a.Domain == "" || a.Domain == config.GetHost() || a.Domain == config.GetAccountDomain()
return a.Domain == "" ||
a.Domain == config.GetHost() ||
a.Domain == config.GetAccountDomain()
}
// IsRemote returns whether account is a remote user account.

View file

@ -19,7 +19,8 @@
import "time"
// Emoji represents a custom emoji that's been uploaded through the admin UI or downloaded from a remote instance.
// Emoji represents a custom emoji that's been uploaded
// through the admin UI or downloaded from a remote instance.
type Emoji struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created

View file

@ -65,16 +65,30 @@ func (p *Processor) NodeInfoRelGet(ctx context.Context) (*apimodel.WellKnownResp
// NodeInfoGet returns a node info struct in response to a node info request.
func (p *Processor) NodeInfoGet(ctx context.Context) (*apimodel.Nodeinfo, gtserror.WithCode) {
host := config.GetHost()
var (
userCount int
postCount int
err error
)
userCount, err := p.state.DB.CountInstanceUsers(ctx, host)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
if config.GetInstanceStatsRandomize() {
// Use randomized stats.
stats := p.converter.RandomStats()
userCount = int(stats.TotalUsers)
postCount = int(stats.Statuses)
} else {
// Count actual stats.
host := config.GetHost()
postCount, err := p.state.DB.CountInstanceStatuses(ctx, host)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
userCount, err = p.state.DB.CountInstanceUsers(ctx, host)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
postCount, err = p.state.DB.CountInstanceStatuses(ctx, host)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
}
return &apimodel.Nodeinfo{

View file

@ -48,6 +48,7 @@
type Router struct {
engine *gin.Engine
srv *http.Server
leSrv *http.Server
}
// New returns a new Router, which wraps
@ -185,15 +186,38 @@ func (r *Router) Start() error {
// Stop shuts down the router nicely.
func (r *Router) Stop() error {
log.Infof(nil, "shutting down http router with %s grace period", shutdownTimeout)
timeout, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
ctx := context.Background()
if err := r.srv.Shutdown(timeout); err != nil {
return fmt.Errorf("error shutting down http router: %s", err)
// Shut down "main" server.
if err := stopServer(ctx, r.srv, "http server"); err != nil {
return err
}
log.Info(nil, "http router closed connections and shut down gracefully")
// Shut down letsencrypt
// server if enabled.
if r.leSrv != nil {
if err := stopServer(ctx, r.leSrv, "letsencrypt http server"); err != nil {
return err
}
}
return nil
}
func stopServer(
ctx context.Context,
s *http.Server,
name string,
) error {
timeout, cancel := context.WithTimeout(ctx, shutdownTimeout)
defer cancel()
log.Infof(nil, "shutting down %s with %s grace period", name, shutdownTimeout)
if err := s.Shutdown(timeout); err != nil {
return fmt.Errorf("error shutting down %s: %w", name, err)
}
log.Infof(ctx, "%s closed connections and shut down gracefully", name)
return nil
}
@ -228,8 +252,8 @@ func (r *Router) customTLS(
// letsEncryptTLS modifies the router's underlying http
// server to use LetsEncrypt via an ACME Autocert manager.
//
// It also starts a listener on the configured LetsEncrypt
// port to validate LE requests.
// It also sets r.leSrv and starts a listener on the
// configured LetsEncrypt port to validate LE requests.
func (r *Router) letsEncryptTLS() (func() error, error) {
acm := &autocert.Manager{
Prompt: autocert.AcceptTOS,
@ -261,17 +285,18 @@ func (r *Router) letsEncryptTLS() (func() error, error) {
// Take our own copy of the HTTP server,
// and update it to serve LetsEncrypt
// requests via the autocert manager.
leSrv := (*r.srv) //nolint:govet
leSrv.Handler = acm.HTTPHandler(fallback)
leSrv.Addr = fmt.Sprintf("%s:%d",
r.leSrv = new(http.Server) //nolint:gosec
*r.leSrv = (*r.srv) //nolint:govet
r.leSrv.Handler = acm.HTTPHandler(fallback)
r.leSrv.Addr = fmt.Sprintf("%s:%d",
config.GetBindAddress(),
config.GetLetsEncryptPort(),
)
go func() {
// Start the LetsEncrypt autocert manager HTTP server.
log.Infof(nil, "letsencrypt listening on %s", leSrv.Addr)
if err := leSrv.ListenAndServe(); err != nil &&
log.Infof(nil, "letsencrypt listening on %s", r.leSrv.Addr)
if err := r.leSrv.ListenAndServe(); err != nil &&
err != http.ErrServerClosed {
log.Panicf(nil, "letsencrypt: listen: %v", err)
}

View file

@ -177,7 +177,7 @@ func (p *hashtagParser) Parse(
// Ignore initial '#'.
continue
case !isPlausiblyInHashtag(r) &&
case !isPermittedInHashtag(r) &&
!isHashtagBoundary(r):
// Weird non-boundary character
// in the hashtag. Don't trust it.

View file

@ -50,6 +50,8 @@
withInlineCode2Expected = "<p><code>Nobody tells you about the &lt;/code>&lt;del>SECRET CODE&lt;/del>&lt;code>, do they?</code></p>"
withHashtag = "# Title\n\nhere's a simple status that uses hashtag #Hashtag!"
withHashtagExpected = "<h1>Title</h1><p>here's a simple status that uses hashtag <a href=\"http://localhost:8080/tags/hashtag\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>Hashtag</span></a>!</p>"
withTamilHashtag = "here's a simple status that uses a hashtag in Tamil #தமிழ்"
withTamilHashtagExpected = "<p>here's a simple status that uses a hashtag in Tamil <a href=\"http://localhost:8080/tags/%E0%AE%A4%E0%AE%AE%E0%AE%BF%E0%AE%B4%E0%AF%8D\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>தமிழ்</span></a></p>"
mdWithHTML = "# Title\n\nHere's a simple text in markdown.\n\nHere's a <a href=\"https://example.org\">link</a>.\n\nHere's an image: <img src=\"https://gts.superseriousbusiness.org/assets/logo.png\" alt=\"The GoToSocial sloth logo.\" width=\"500\" height=\"600\">"
mdWithHTMLExpected = "<h1>Title</h1><p>Here's a simple text in markdown.</p><p>Here's a <a href=\"https://example.org\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">link</a>.</p><p>Here's an image:</p>"
mdWithCheekyHTML = "# Title\n\nHere's a simple text in markdown.\n\nHere's a cheeky little script: <script>alert(ahhhh)</script>"
@ -121,6 +123,12 @@ func (suite *MarkdownTestSuite) TestParseWithHashtag() {
suite.Equal(withHashtagExpected, formatted.HTML)
}
// Regressiom test for https://github.com/superseriousbusiness/gotosocial/issues/3618
func (suite *MarkdownTestSuite) TestParseWithTamilHashtag() {
formatted := suite.FromMarkdown(withTamilHashtag)
suite.Equal(withTamilHashtagExpected, formatted.HTML)
}
func (suite *MarkdownTestSuite) TestParseWithHTML() {
formatted := suite.FromMarkdown(mdWithHTML)
suite.Equal(mdWithHTMLExpected, formatted.HTML)

View file

@ -50,17 +50,16 @@ func NormalizeHashtag(text string) (string, bool) {
// Validate normalized result.
var (
notJustUnderscores = false
onlyPermittedChars = true
lengthOK = true
atLeastOneRequiredChar = false
onlyPermittedChars = true
lengthOK = true
)
for i, r := range normalized {
if r != '_' {
// This isn't an underscore,
// so the whole hashtag isn't
// just underscores.
notJustUnderscores = true
if !isPermittedIfNotEntireHashtag(r) {
// This isn't an underscore, mark, etc,
// so the hashtag contains at least one
atLeastOneRequiredChar = true
}
if i >= maximumHashtagLength {
@ -74,5 +73,5 @@ func NormalizeHashtag(text string) (string, bool) {
}
}
return normalized, (lengthOK && onlyPermittedChars && notJustUnderscores)
return normalized, lengthOK && onlyPermittedChars && atLeastOneRequiredChar
}

View file

@ -118,20 +118,20 @@ func (suite *PlainTestSuite) TestDeriveHashtagsOK() {
`
tags := suite.FromPlain(statusText).Tags
suite.Len(tags, 13)
suite.Equal("testing123", tags[0].Name)
suite.Equal("also", tags[1].Name)
suite.Equal("thisshouldwork", tags[2].Name)
suite.Equal("dupe", tags[3].Name)
suite.Equal("ThisShouldAlsoWork", tags[4].Name)
suite.Equal("this_should_not_be_split", tags[5].Name)
suite.Equal("111111", tags[6].Name)
suite.Equal("alimentación", tags[7].Name)
suite.Equal("saúde", tags[8].Name)
suite.Equal("lävistää", tags[9].Name)
suite.Equal("ö", tags[10].Name)
suite.Equal("", tags[11].Name)
suite.Equal("ThisOneIsThirteyCharactersLong", tags[12].Name)
if suite.Len(tags, 12) {
suite.Equal("testing123", tags[0].Name)
suite.Equal("also", tags[1].Name)
suite.Equal("thisshouldwork", tags[2].Name)
suite.Equal("dupe", tags[3].Name)
suite.Equal("ThisShouldAlsoWork", tags[4].Name)
suite.Equal("this_should_not_be_split", tags[5].Name)
suite.Equal("alimentación", tags[6].Name)
suite.Equal("saúde", tags[7].Name)
suite.Equal("lävistää", tags[8].Name)
suite.Equal("ö", tags[9].Name)
suite.Equal("", tags[10].Name)
suite.Equal("ThisOneIsThirteyCharactersLong", tags[11].Name)
}
statusText = `#올빼미 hej`
tags = suite.FromPlain(statusText).Tags
@ -170,8 +170,17 @@ func (suite *PlainTestSuite) TestDeriveMultiple() {
func (suite *PlainTestSuite) TestZalgoHashtag() {
statusText := `yo who else loves #praying to #z̸͉̅a̸͚͋l̵͈̊g̸̫͌ỏ̷̪?`
f := suite.FromPlain(statusText)
suite.Len(f.Tags, 1)
suite.Equal("praying", f.Tags[0].Name)
if suite.Len(f.Tags, 2) {
suite.Equal("praying", f.Tags[0].Name)
// NFC doesn't do much for Zalgo text, but it's difficult to strip marks without affecting non-Latin text.
suite.Equal("z̸͉̅a̸͚͋l̵͈̊g̸̫͌ỏ̷̪", f.Tags[1].Name)
}
}
func (suite *PlainTestSuite) TestNumbersAreNotHashtags() {
statusText := `yo who else thinks #19_98 is #1?`
f := suite.FromPlain(statusText)
suite.Len(f.Tags, 0)
}
func TestPlainTestSuite(t *testing.T) {

View file

@ -19,19 +19,14 @@
import "unicode"
func isPlausiblyInHashtag(r rune) bool {
// Marks are allowed during parsing
// prior to normalization, but not after,
// since they may be combined into letters
// during normalization.
return unicode.IsMark(r) ||
isPermittedInHashtag(r)
func isPermittedInHashtag(r rune) bool {
return unicode.IsLetter(r) || isPermittedIfNotEntireHashtag(r)
}
func isPermittedInHashtag(r rune) bool {
return unicode.IsLetter(r) ||
unicode.IsNumber(r) ||
r == '_'
// isPermittedIfNotEntireHashtag is true for characters that may be in a hashtag
// but are not allowed to be the only characters making up the hashtag.
func isPermittedIfNotEntireHashtag(r rune) bool {
return unicode.IsNumber(r) || unicode.IsMark(r) || r == '_'
}
// isHashtagBoundary returns true if rune r

View file

@ -18,10 +18,17 @@
package typeutils
import (
crand "crypto/rand"
"math/big"
"math/rand"
"sync"
"sync/atomic"
"time"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/filter/interaction"
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/state"
)
@ -31,6 +38,7 @@ type Converter struct {
randAvatars sync.Map
visFilter *visibility.Filter
intFilter *interaction.Filter
randStats atomic.Pointer[apimodel.RandomStats]
}
func NewConverter(state *state.State) *Converter {
@ -41,3 +49,53 @@ func NewConverter(state *state.State) *Converter {
intFilter: interaction.NewFilter(state),
}
}
// RandomStats returns or generates
// and returns random instance stats.
func (c *Converter) RandomStats() apimodel.RandomStats {
now := time.Now()
stats := c.randStats.Load()
if stats != nil && time.Since(stats.Generated) < time.Hour {
// Random stats are still
// fresh (less than 1hr old),
// so return them as-is.
return *stats
}
// Generate new random stats.
newStats := genRandStats()
newStats.Generated = now
c.randStats.Store(&newStats)
return newStats
}
func genRandStats() apimodel.RandomStats {
const (
statusesMax = 10000000
usersMax = 1000000
)
statusesB, err := crand.Int(crand.Reader, big.NewInt(statusesMax))
if err != nil {
// Only errs if something is buggered with the OS.
log.Panicf(nil, "error randomly generating statuses count: %v", err)
}
totalUsersB, err := crand.Int(crand.Reader, big.NewInt(usersMax))
if err != nil {
// Only errs if something is buggered with the OS.
log.Panicf(nil, "error randomly generating users count: %v", err)
}
// Monthly users should only ever
// be <= 100% of total users.
totalUsers := totalUsersB.Int64()
activeRatio := rand.Float64() //nolint
mau := int64(float64(totalUsers) * activeRatio)
return apimodel.RandomStats{
Statuses: statusesB.Int64(),
TotalUsers: totalUsers,
MonthlyActiveUsers: mau,
}
}

View file

@ -2021,7 +2021,19 @@ func (c *Converter) InteractionReqToASAccept(
objectIRI, err := url.Parse(req.InteractionURI)
if err != nil {
return nil, gtserror.Newf("invalid target uri: %w", err)
return nil, gtserror.Newf("invalid object uri: %w", err)
}
if req.Status == nil {
req.Status, err = c.state.DB.GetStatusByID(ctx, req.StatusID)
if err != nil {
return nil, gtserror.Newf("db error getting interaction req target status: %w", err)
}
}
targetIRI, err := url.Parse(req.Status.URI)
if err != nil {
return nil, gtserror.Newf("invalid interaction req target status uri: %w", err)
}
toIRI, err := url.Parse(req.InteractingAccount.URI)
@ -2040,6 +2052,10 @@ func (c *Converter) InteractionReqToASAccept(
// Object is the interaction URI.
ap.AppendObjectIRIs(accept, objectIRI)
// Target is the URI of the
// status being interacted with.
ap.AppendTargetIRIs(accept, targetIRI)
// Address to the owner
// of interaction URI.
ap.AppendTo(accept, toIRI)
@ -2101,7 +2117,19 @@ func (c *Converter) InteractionReqToASReject(
objectIRI, err := url.Parse(req.InteractionURI)
if err != nil {
return nil, gtserror.Newf("invalid target uri: %w", err)
return nil, gtserror.Newf("invalid object uri: %w", err)
}
if req.Status == nil {
req.Status, err = c.state.DB.GetStatusByID(ctx, req.StatusID)
if err != nil {
return nil, gtserror.Newf("db error getting interaction req target status: %w", err)
}
}
targetIRI, err := url.Parse(req.Status.URI)
if err != nil {
return nil, gtserror.Newf("invalid interaction req target status uri: %w", err)
}
toIRI, err := url.Parse(req.InteractingAccount.URI)
@ -2120,6 +2148,10 @@ func (c *Converter) InteractionReqToASReject(
// Object is the interaction URI.
ap.AppendObjectIRIs(reject, objectIRI)
// Target is the URI of the
// status being interacted with.
ap.AppendTargetIRIs(reject, targetIRI)
// Address to the owner
// of interaction URI.
ap.AppendTo(reject, toIRI)

View file

@ -1235,7 +1235,9 @@ func (suite *InternalToASTestSuite) TestInteractionReqToASAcceptAnnounce() {
req := &gtsmodel.InteractionRequest{
ID: "01J1AKMZ8JE5NW0ZSFTRC1JJNE",
CreatedAt: testrig.TimeMustParse("2022-06-09T13:12:00Z"),
TargetAccountID: acceptingAccount.ID,
StatusID: "01JJYCVKCXB9JTQD1XW2KB8MT3",
Status: &gtsmodel.Status{URI: "http://localhost:8080/users/the_mighty_zork/statuses/01JJYCVKCXB9JTQD1XW2KB8MT3"},
TargetAccountID: acceptingAccount.ID,
TargetAccount: acceptingAccount,
InteractingAccountID: interactingAccount.ID,
InteractingAccount: interactingAccount,
@ -1272,6 +1274,7 @@ func (suite *InternalToASTestSuite) TestInteractionReqToASAcceptAnnounce() {
],
"id": "http://localhost:8080/users/the_mighty_zork/accepts/01J1AKMZ8JE5NW0ZSFTRC1JJNE",
"object": "https://fossbros-anonymous.io/users/foss_satan/statuses/01J1AKRRHQ6MDDQHV0TP716T2K",
"target": "http://localhost:8080/users/the_mighty_zork/statuses/01JJYCVKCXB9JTQD1XW2KB8MT3",
"to": "http://fossbros-anonymous.io/users/foss_satan",
"type": "Accept"
}`, string(b))
@ -1284,6 +1287,8 @@ func (suite *InternalToASTestSuite) TestInteractionReqToASAcceptLike() {
req := &gtsmodel.InteractionRequest{
ID: "01J1AKMZ8JE5NW0ZSFTRC1JJNE",
CreatedAt: testrig.TimeMustParse("2022-06-09T13:12:00Z"),
StatusID: "01JJYCVKCXB9JTQD1XW2KB8MT3",
Status: &gtsmodel.Status{URI: "http://localhost:8080/users/the_mighty_zork/statuses/01JJYCVKCXB9JTQD1XW2KB8MT3"},
TargetAccountID: acceptingAccount.ID,
TargetAccount: acceptingAccount,
InteractingAccountID: interactingAccount.ID,
@ -1317,6 +1322,7 @@ func (suite *InternalToASTestSuite) TestInteractionReqToASAcceptLike() {
"actor": "http://localhost:8080/users/the_mighty_zork",
"id": "http://localhost:8080/users/the_mighty_zork/accepts/01J1AKMZ8JE5NW0ZSFTRC1JJNE",
"object": "https://fossbros-anonymous.io/users/foss_satan/statuses/01J1AKRRHQ6MDDQHV0TP716T2K",
"target": "http://localhost:8080/users/the_mighty_zork/statuses/01JJYCVKCXB9JTQD1XW2KB8MT3",
"to": "http://fossbros-anonymous.io/users/foss_satan",
"type": "Accept"
}`, string(b))

View file

@ -943,8 +943,9 @@ func (c *Converter) statusToAPIFilterResults(
// Both mutes and filters can expire.
now := time.Now()
// If the requesting account mutes the account that created this status, hide the status.
if mutes.Matches(s.AccountID, filterContext, now) {
// If requesting account mutes the author (taking boosts into account), hide the status.
if (s.BoostOfAccountID != "" && mutes.Matches(s.BoostOfAccountID, filterContext, now)) ||
mutes.Matches(s.AccountID, filterContext, now) {
return nil, statusfilter.ErrHideStatus
}
@ -1744,6 +1745,12 @@ func (c *Converter) InstanceToAPIV1Instance(ctx context.Context, i *gtsmodel.Ins
stats["domain_count"] = util.Ptr(domainCount)
instance.Stats = stats
if config.GetInstanceStatsRandomize() {
// Whack some random stats on the instance
// to be injected by API handlers.
instance.RandomStats = c.RandomStats()
}
// thumbnail
iAccount, err := c.state.DB.GetInstanceAccount(ctx, "")
if err != nil {
@ -1820,6 +1827,12 @@ func (c *Converter) InstanceToAPIV2Instance(ctx context.Context, i *gtsmodel.Ins
instance.Debug = util.Ptr(true)
}
if config.GetInstanceStatsRandomize() {
// Whack some random stats on the instance
// to be injected by API handlers.
instance.RandomStats = c.RandomStats()
}
// thumbnail
thumbnail := apimodel.InstanceV2Thumbnail{}
@ -2641,28 +2654,36 @@ func (c *Converter) FilterStatusToAPIFilterStatus(ctx context.Context, filterSta
func (c *Converter) convertEmojisToAPIEmojis(ctx context.Context, emojis []*gtsmodel.Emoji, emojiIDs []string) ([]apimodel.Emoji, error) {
var errs gtserror.MultiError
// GTS model attachments were not populated
if len(emojis) == 0 && len(emojiIDs) > 0 {
// GTS model attachments were not populated
var err error
// Fetch GTS models for emoji IDs
emojis, err = c.state.DB.GetEmojisByIDs(ctx, emojiIDs)
if err != nil {
errs.Appendf("error fetching emojis from database: %w", err)
return nil, gtserror.Newf("db error fetching emojis: %w", err)
}
}
// Preallocate expected frontend slice
// Preallocate expected frontend slice of emojis.
apiEmojis := make([]apimodel.Emoji, 0, len(emojis))
// Convert GTS models to frontend models
for _, emoji := range emojis {
// Skip adding emojis that are
// uncached, the empty URLs can
// cause issues with some clients.
if !*emoji.Cached {
continue
}
// Convert each to a frontend API model emoji.
apiEmoji, err := c.EmojiToAPIEmoji(ctx, emoji)
if err != nil {
errs.Appendf("error converting emoji %s to api emoji: %w", emoji.ID, err)
continue
}
// Append converted emoji to return slice.
apiEmojis = append(apiEmojis, apiEmoji)
}

View file

@ -1161,6 +1161,7 @@ func (suite *InternalToFrontendTestSuite) TestHashtagAnywhereFilteredBoostToFron
func (suite *InternalToFrontendTestSuite) TestMutedStatusToFrontend() {
testStatus := suite.testStatuses["admin_account_status_1"]
requestingAccount := suite.testAccounts["local_account_1"]
mutes := usermute.NewCompiledUserMuteList([]*gtsmodel.UserMute{
{
AccountID: requestingAccount.ID,
@ -1168,6 +1169,7 @@ func (suite *InternalToFrontendTestSuite) TestMutedStatusToFrontend() {
Notifications: util.Ptr(false),
},
})
_, err := suite.typeconverter.StatusToAPIStatus(
context.Background(),
testStatus,
@ -1186,6 +1188,7 @@ func (suite *InternalToFrontendTestSuite) TestMutedReplyStatusToFrontend() {
testStatus.InReplyToID = suite.testStatuses["local_account_2_status_1"].ID
testStatus.InReplyToAccountID = mutedAccount.ID
requestingAccount := suite.testAccounts["local_account_1"]
mutes := usermute.NewCompiledUserMuteList([]*gtsmodel.UserMute{
{
AccountID: requestingAccount.ID,
@ -1193,11 +1196,46 @@ func (suite *InternalToFrontendTestSuite) TestMutedReplyStatusToFrontend() {
Notifications: util.Ptr(false),
},
})
// Populate status so the converter has the account objects it needs for muting.
err := suite.db.PopulateStatus(context.Background(), testStatus)
if err != nil {
suite.FailNow(err.Error())
}
// Convert the status to API format, which should fail.
_, err = suite.typeconverter.StatusToAPIStatus(
context.Background(),
testStatus,
requestingAccount,
statusfilter.FilterContextHome,
nil,
mutes,
)
suite.ErrorIs(err, statusfilter.ErrHideStatus)
}
func (suite *InternalToFrontendTestSuite) TestMutedBoostStatusToFrontend() {
mutedAccount := suite.testAccounts["local_account_2"]
testStatus := suite.testStatuses["admin_account_status_1"]
testStatus.BoostOfID = suite.testStatuses["local_account_2_status_1"].ID
testStatus.BoostOfAccountID = mutedAccount.ID
requestingAccount := suite.testAccounts["local_account_1"]
mutes := usermute.NewCompiledUserMuteList([]*gtsmodel.UserMute{
{
AccountID: requestingAccount.ID,
TargetAccountID: mutedAccount.ID,
Notifications: util.Ptr(false),
},
})
// Populate status so the converter has the account objects it needs for muting.
err := suite.db.PopulateStatus(context.Background(), testStatus)
if err != nil {
suite.FailNow(err.Error())
}
// Convert the status to API format, which should fail.
_, err = suite.typeconverter.StatusToAPIStatus(
context.Background(),
@ -1240,7 +1278,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownAttachments
"muted": false,
"bookmarked": false,
"pinned": false,
"content": "\u003cp\u003ehi \u003cspan class=\"h-card\"\u003e\u003ca href=\"http://localhost:8080/@admin\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e@\u003cspan\u003eadmin\u003c/span\u003e\u003c/a\u003e\u003c/span\u003e here's some media for ya\u003c/p\u003e\u003chr\u003e\u003cp\u003e\u003ci lang=\"en\"\u003e Note from localhost:8080: 2 attachments in this status were not downloaded. Treat the following external links with care:\u003c/i\u003e\u003c/p\u003e\u003cul\u003e\u003cli\u003e\u003ca href=\"http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE7ZGJYTSYMXF927GF9353KR.svg\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e01HE7ZGJYTSYMXF927GF9353KR.svg\u003c/a\u003e [SVG line art of a sloth, public domain]\u003c/li\u003e\u003cli\u003e\u003ca href=\"http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE892Y8ZS68TQCNPX7J888P3.mp3\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e01HE892Y8ZS68TQCNPX7J888P3.mp3\u003c/a\u003e [Jolly salsa song, public domain.]\u003c/li\u003e\u003c/ul\u003e",
"content": "\u003cp\u003ehi \u003cspan class=\"h-card\"\u003e\u003ca href=\"http://localhost:8080/@admin\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e@\u003cspan\u003eadmin\u003c/span\u003e\u003c/a\u003e\u003c/span\u003e here's some media for ya\u003c/p\u003e\u003cdiv class=\"gts-system-message gts-placeholder-attachments\"\u003e\u003chr\u003e\u003cp\u003e\u003ci lang=\"en\"\u003e Note from localhost:8080: 2 attachments in this status were not downloaded. Treat the following external links with care:\u003c/i\u003e\u003c/p\u003e\u003cul\u003e\u003cli\u003e\u003ca href=\"http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE7ZGJYTSYMXF927GF9353KR.svg\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e01HE7ZGJYTSYMXF927GF9353KR.svg\u003c/a\u003e [SVG line art of a sloth, public domain]\u003c/li\u003e\u003cli\u003e\u003ca href=\"http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE892Y8ZS68TQCNPX7J888P3.mp3\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e01HE892Y8ZS68TQCNPX7J888P3.mp3\u003c/a\u003e [Jolly salsa song, public domain.]\u003c/li\u003e\u003c/ul\u003e\u003c/div\u003e",
"reblog": null,
"account": {
"id": "01FHMQX3GAABWSM0S2VZEC2SWC",
@ -1790,7 +1828,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToAPIStatusPendingApproval()
"muted": false,
"bookmarked": false,
"pinned": false,
"content": "<p>Hi <span class=\"h-card\"><a href=\"http://localhost:8080/@1happyturtle\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>1happyturtle</span></a></span>, can I reply?</p><hr><p><i lang=\"en\"> Note from localhost:8080: This reply is pending your approval. You can quickly accept it by liking, boosting or replying to it. You can also accept or reject it at the following link: <a href=\"http://localhost:8080/settings/user/interaction_requests/01J5QVXCCEATJYSXM9H6MZT4JR\" rel=\"noreferrer noopener nofollow\" target=\"_blank\">http://localhost:8080/settings/user/interaction_requests/01J5QVXCCEATJYSXM9H6MZT4JR</a>.</i></p>",
"content": "<p>Hi <span class=\"h-card\"><a href=\"http://localhost:8080/@1happyturtle\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>1happyturtle</span></a></span>, can I reply?</p><div class=\"gts-system-message gts-pending-reply\"><hr><p><i lang=\"en\"> Note from localhost:8080: This reply is pending your approval. You can quickly accept it by liking, boosting or replying to it. You can also accept or reject it at the following link: <a href=\"http://localhost:8080/settings/user/interaction_requests/01J5QVXCCEATJYSXM9H6MZT4JR\" rel=\"noreferrer noopener nofollow\" target=\"_blank\">http://localhost:8080/settings/user/interaction_requests/01J5QVXCCEATJYSXM9H6MZT4JR</a>.</i></p></div>",
"reblog": null,
"application": {
"name": "superseriousbusiness",

View file

@ -128,12 +128,14 @@ func misskeyReportInlineURLs(content string) []*url.URL {
//
// Example:
//
// <hr>
// <p><i lang="en"> Note from your.instance.com: 2 attachment(s) in this status were not downloaded. Treat the following external link(s) with care:</i></p>
// <ul>
// <li><a href="http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE7ZGJYTSYMXF927GF9353KR.svg" rel="nofollow noreferrer noopener" target="_blank">01HE7ZGJYTSYMXF927GF9353KR.svg</a> [SVG line art of a sloth, public domain]</li>
// <li><a href="http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE892Y8ZS68TQCNPX7J888P3.mp3" rel="nofollow noreferrer noopener" target="_blank">01HE892Y8ZS68TQCNPX7J888P3.mp3</a> [Jolly salsa song, public domain.]</li>
// </ul>
// <div class="gts-system-message gts-placeholder-attachments">
// <hr>
// <p><i lang="en"> Note from your.instance.com: 2 attachment(s) in this status were not downloaded. Treat the following external link(s) with care:</i></p>
// <ul>
// <li><a href="http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE7ZGJYTSYMXF927GF9353KR.svg" rel="nofollow noreferrer noopener" target="_blank">01HE7ZGJYTSYMXF927GF9353KR.svg</a> [SVG line art of a sloth, public domain]</li>
// <li><a href="http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE892Y8ZS68TQCNPX7J888P3.mp3" rel="nofollow noreferrer noopener" target="_blank">01HE892Y8ZS68TQCNPX7J888P3.mp3</a> [Jolly salsa song, public domain.]</li>
// </ul>
// </div>
func placeholderAttachments(arr []*apimodel.Attachment) (string, []*apimodel.Attachment) {
// Extract non-locally stored attachments into a
@ -187,7 +189,7 @@ func placeholderAttachments(arr []*apimodel.Attachment) (string, []*apimodel.Att
}
note.WriteString(`</ul>`)
return text.SanitizeToHTML(note.String()), arr
return systemMessage("gts-placeholder-attachments", note.String()), arr
}
func (c *Converter) pendingReplyNote(
@ -228,7 +230,27 @@ func (c *Converter) pendingReplyNote(
note.WriteString(`</a>.`)
note.WriteString(`</i></p>`)
return text.SanitizeToHTML(note.String()), nil
return systemMessage("gts-pending-reply", note.String()), nil
}
// systemMessage wraps a note with a div with semantic classes that aren't allowed through the sanitizer,
// but may be emitted to the client as an addition to the status's actual content.
// Clients may want to display these specially or suppress them in favor of their own UI.
//
// messageClass must be valid inside an HTML attribute and should be one or more classes starting with `gts-`.
func systemMessage(
messageClass string,
unsanitizedNoteHTML string,
) string {
var wrappedNote strings.Builder
wrappedNote.WriteString(`<div class="gts-system-message `)
wrappedNote.WriteString(messageClass)
wrappedNote.WriteString(`">`)
wrappedNote.WriteString(text.SanitizeToHTML(unsanitizedNoteHTML))
wrappedNote.WriteString(`</div>`)
return wrappedNote.String()
}
// ContentToContentLanguage tries to

View file

@ -21,10 +21,13 @@
"fmt"
"net/http"
"path"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/router"
)
type fileSystem struct {
@ -53,7 +56,11 @@ func (fs fileSystem) Open(path string) (http.File, error) {
// getAssetFileInfo tries to fetch the ETag for the given filePath from the module's
// assetsETagCache. If it can't be found there, it uses the provided http.FileSystem
// to generate a new ETag to go in the cache, which it then returns.
func (m *Module) getAssetETag(filePath string, fs http.FileSystem) (string, error) {
func getAssetETag(
wet withETagCache,
filePath string,
fs http.FileSystem,
) (string, error) {
file, err := fs.Open(filePath)
if err != nil {
return "", fmt.Errorf("error opening %s: %s", filePath, err)
@ -67,7 +74,8 @@ func (m *Module) getAssetETag(filePath string, fs http.FileSystem) (string, erro
fileLastModified := fileInfo.ModTime()
if cachedETag, ok := m.eTagCache.Get(filePath); ok && !fileLastModified.After(cachedETag.lastModified) {
cache := wet.ETagCache()
if cachedETag, ok := cache.Get(filePath); ok && !fileLastModified.After(cachedETag.lastModified) {
// only return our cached etag if the file wasn't
// modified since last time, otherwise generate a
// new one; eat fresh!
@ -80,7 +88,7 @@ func (m *Module) getAssetETag(filePath string, fs http.FileSystem) (string, erro
}
// put new entry in cache before we return
m.eTagCache.Set(filePath, eTagCacheEntry{
cache.Set(filePath, eTagCacheEntry{
eTag: eTag,
lastModified: fileLastModified,
})
@ -99,7 +107,10 @@ func (m *Module) getAssetETag(filePath string, fs http.FileSystem) (string, erro
//
// todo: move this middleware out of the 'web' package and into the 'middleware'
// package along with the other middlewares
func (m *Module) assetsCacheControlMiddleware(fs http.FileSystem) gin.HandlerFunc {
func assetsCacheControlMiddleware(
wet withETagCache,
fs http.FileSystem,
) gin.HandlerFunc {
return func(c *gin.Context) {
// Acquire context from gin request.
ctx := c.Request.Context()
@ -118,7 +129,7 @@ func (m *Module) assetsCacheControlMiddleware(fs http.FileSystem) gin.HandlerFun
assetFilePath := strings.TrimPrefix(path.Clean(upath), assetsPathPrefix)
// either fetch etag from ttlcache or generate it
eTag, err := m.getAssetETag(assetFilePath, fs)
eTag, err := getAssetETag(wet, assetFilePath, fs)
if err != nil {
log.Errorf(ctx, "error getting ETag for %s: %s", assetFilePath, err)
return
@ -137,3 +148,23 @@ func (m *Module) assetsCacheControlMiddleware(fs http.FileSystem) gin.HandlerFun
// else let the rest of the request be processed normally
}
}
// routeAssets attaches *just* the
// assets filesystem to the given router.
func routeAssets(
wet withETagCache,
r *router.Router,
mi ...gin.HandlerFunc,
) {
// Group all static files from assets dir at /assets,
// so that they can use the same cache control middleware.
webAssetsAbsFilePath, err := filepath.Abs(config.GetWebAssetBaseDir())
if err != nil {
log.Panicf(nil, "error getting absolute path of assets dir: %s", err)
}
fs := fileSystem{http.Dir(webAssetsAbsFilePath)}
assetsGroup := r.AttachGroup(assetsPathPrefix)
assetsGroup.Use(assetsCacheControlMiddleware(wet, fs))
assetsGroup.Use(mi...)
assetsGroup.StaticFS("/", fs)
}

View file

@ -29,6 +29,10 @@
"codeberg.org/gruf/go-cache/v3"
)
type withETagCache interface {
ETagCache() cache.Cache[string, eTagCacheEntry]
}
func newETagCache() cache.TTLCache[string, eTagCacheEntry] {
eTagCache := cache.NewTTL[string, eTagCacheEntry](0, 1000, 0)
eTagCache.SetTTL(time.Hour, false)

View file

@ -0,0 +1,70 @@
// 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 web
import (
"net/http"
"time"
"codeberg.org/gruf/go-cache/v3"
"github.com/gin-gonic/gin"
"github.com/superseriousbusiness/gotosocial/internal/api/health"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/router"
)
type MaintenanceModule struct {
eTagCache cache.Cache[string, eTagCacheEntry]
}
// NewMaintenance returns a module that routes only
// static assets, and returns a code 503 maintenance
// message template to all other requests.
func NewMaintenance() *MaintenanceModule {
return &MaintenanceModule{
eTagCache: newETagCache(),
}
}
// ETagCache implements withETagCache.
func (m *MaintenanceModule) ETagCache() cache.Cache[string, eTagCacheEntry] {
return m.eTagCache
}
func (m *MaintenanceModule) Route(r *router.Router, mi ...gin.HandlerFunc) {
// Route static assets.
routeAssets(m, r, mi...)
// Serve OK in response to live
// requests, but not ready requests.
liveHandler := func(c *gin.Context) {
c.Status(http.StatusOK)
}
r.AttachHandler(http.MethodGet, health.LivePath, liveHandler)
r.AttachHandler(http.MethodHead, health.LivePath, liveHandler)
// For everything else, serve maintenance template.
obj := map[string]string{"host": config.GetHost()}
r.AttachNoRouteHandler(func(c *gin.Context) {
retryAfter := time.Now().Add(120 * time.Second).UTC()
c.Writer.Header().Add("Retry-After", "120")
c.Writer.Header().Add("Retry-After", retryAfter.Format(http.TimeFormat))
c.Header("Cache-Control", "no-store")
c.HTML(http.StatusServiceUnavailable, "maintenance.tmpl", obj)
})
}

View file

@ -21,14 +21,11 @@
"context"
"net/http"
"net/url"
"path/filepath"
"codeberg.org/gruf/go-cache/v3"
"github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/middleware"
"github.com/superseriousbusiness/gotosocial/internal/processing"
"github.com/superseriousbusiness/gotosocial/internal/router"
@ -87,22 +84,22 @@ func New(db db.DB, processor *processing.Processor) *Module {
}
}
func (m *Module) Route(r *router.Router, mi ...gin.HandlerFunc) {
// Group all static files from assets dir at /assets,
// so that they can use the same cache control middleware.
webAssetsAbsFilePath, err := filepath.Abs(config.GetWebAssetBaseDir())
if err != nil {
log.Panicf(nil, "error getting absolute path of assets dir: %s", err)
}
fs := fileSystem{http.Dir(webAssetsAbsFilePath)}
assetsGroup := r.AttachGroup(assetsPathPrefix)
assetsGroup.Use(m.assetsCacheControlMiddleware(fs))
assetsGroup.Use(mi...)
assetsGroup.StaticFS("/", fs)
// ETagCache implements withETagCache.
func (m *Module) ETagCache() cache.Cache[string, eTagCacheEntry] {
return m.eTagCache
}
// handlers that serve profiles and statuses should use the SignatureCheck
// middleware, so that requests with content-type application/activity+json
// can still be served
// Route attaches the assets filesystem and profile,
// status, and other web handlers to the router.
func (m *Module) Route(r *router.Router, mi ...gin.HandlerFunc) {
// Route static assets.
routeAssets(m, r, mi...)
// Route all other endpoints + handlers.
//
// Handlers that serve profiles and statuses should use
// the SignatureCheck middleware, so that requests with
// content-type application/activity+json can be served
profileGroup := r.AttachGroup(profileGroupPath)
profileGroup.Use(mi...)
profileGroup.Use(middleware.SignatureCheck(m.isURIBlocked), middleware.CacheControl(middleware.CacheControlConfig{
@ -111,7 +108,7 @@ func (m *Module) Route(r *router.Router, mi ...gin.HandlerFunc) {
profileGroup.Handle(http.MethodGet, "", m.profileGETHandler) // use empty path here since it's the base of the group
profileGroup.Handle(http.MethodGet, statusPath, m.threadGETHandler)
// Attach individual web handlers which require no specific middlewares
// Individual web handlers requiring no specific middlewares.
r.AttachHandler(http.MethodGet, "/", m.indexHandler) // front-page
r.AttachHandler(http.MethodGet, settingsPathPrefix, m.SettingsPanelHandler)
r.AttachHandler(http.MethodGet, settingsPanelGlob, m.SettingsPanelHandler)
@ -128,7 +125,7 @@ func (m *Module) Route(r *router.Router, mi ...gin.HandlerFunc) {
r.AttachHandler(http.MethodGet, signupPath, m.signupGETHandler)
r.AttachHandler(http.MethodPost, signupPath, m.signupPOSTHandler)
// Attach redirects from old endpoints to current ones for backwards compatibility
// Redirects from old endpoints to for back compat.
r.AttachHandler(http.MethodGet, "/auth/edit", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, userPanelPath) })
r.AttachHandler(http.MethodGet, "/user", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, userPanelPath) })
r.AttachHandler(http.MethodGet, "/admin", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, adminPanelPath) })

View file

@ -81,18 +81,6 @@ func(subscription *gtsmodel.WebPushSubscription) bool {
return gtserror.Newf("error getting VAPID key pair: %w", err)
}
// Get contact email for this instance, if available.
domain := config.GetHost()
instance, err := r.state.DB.GetInstance(ctx, domain)
if err != nil {
return gtserror.Newf("error getting current instance: %w", err)
}
vapidSubjectEmail := instance.ContactEmail
if vapidSubjectEmail == "" {
// Instance contact email not configured. Use a dummy address.
vapidSubjectEmail = "admin@" + domain
}
// Get target account settings.
targetAccountSettings, err := r.state.DB.GetAccountSettings(ctx, notification.TargetAccountID)
if err != nil {
@ -111,7 +99,6 @@ func(subscription *gtsmodel.WebPushSubscription) bool {
if err := r.sendToSubscription(
ctx,
vapidKeyPair,
vapidSubjectEmail,
targetAccountSettings,
subscription,
notification,
@ -134,7 +121,6 @@ func(subscription *gtsmodel.WebPushSubscription) bool {
func (r *realSender) sendToSubscription(
ctx context.Context,
vapidKeyPair *gtsmodel.VAPIDKeyPair,
vapidSubjectEmail string,
targetAccountSettings *gtsmodel.AccountSettings,
subscription *gtsmodel.WebPushSubscription,
notification *gtsmodel.Notification,
@ -185,7 +171,7 @@ func (r *realSender) sendToSubscription(
},
&webpushgo.Options{
HTTPClient: r.httpClient,
Subscriber: vapidSubjectEmail,
Subscriber: "https://" + config.GetHost(),
VAPIDPublicKey: vapidKeyPair.Public,
VAPIDPrivateKey: vapidKeyPair.Private,
TTL: int(TTL.Seconds()),

View file

@ -118,6 +118,7 @@ EXPECT=$(cat << "EOF"
"nl",
"en-GB"
],
"instance-stats-randomize": true,
"instance-subscriptions-process-every": 86400000000000,
"instance-subscriptions-process-from": "23:00",
"landing-page-user": "admin",
@ -248,6 +249,7 @@ GTS_INSTANCE_FEDERATION_SPAM_FILTER=true \
GTS_INSTANCE_DELIVER_TO_SHARED_INBOXES=false \
GTS_INSTANCE_INJECT_MASTODON_VERSION=true \
GTS_INSTANCE_LANGUAGES="nl,en-gb" \
GTS_INSTANCE_STATS_RANDOMIZE=true \
GTS_ACCOUNTS_ALLOW_CUSTOM_CSS=true \
GTS_ACCOUNTS_CUSTOM_CSS_LENGTH=5000 \
GTS_ACCOUNTS_REGISTRATION_OPEN=true \

View file

@ -0,0 +1,76 @@
{{- /*
// 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/>.
*/ -}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<link rel="icon" href="/assets/logo.webp" type="image/webp">
<link rel="apple-touch-icon" href="/assets/logo.webp" type="image/webp">
<link rel="apple-touch-startup-image" href="/assets/logo.webp" type="image/webp">
<link rel="preload" href="/assets/dist/_colors.css" as="style">
<link rel="preload" href="/assets/dist/base.css" as="style">
<link rel="preload" href="/assets/dist/page.css" as="style">
<link rel="stylesheet" href="/assets/dist/_colors.css">
<link rel="stylesheet" href="/assets/dist/base.css">
<link rel="stylesheet" href="/assets/dist/page.css">
<title>{{- .host -}}</title>
</head>
<body>
<div class="page">
<header class="page-header">
<a aria-label="{{- .host -}}. Go to instance homepage" href="/" class="nounderline">
<picture>
<img
src="/assets/logo.webp"
alt="A cartoon sloth smiling happily."
title="A cartoon sloth smiling happily."
/>
</picture>
<h1>{{- .host -}}</h1>
</a>
</header>
<div class="page-content">
<p>This GoToSocial instance is currently down for maintenance, starting up, or running database migrations. Please wait.</p>
<p>If you are the admin of this instance, check your GoToSocial logs for more details, and make sure to <strong>not interrupt any running database migrations</strong>!</p>
</div>
<footer class="page-footer">
<nav>
<ul class="nodot">
<li id="version">
<a
href="https://github.com/superseriousbusiness/gotosocial"
class="nounderline"
rel="nofollow noreferrer noopener"
target="_blank"
>
<span aria-hidden="true">🦥</span>
Source - GoToSocial
<span aria-hidden="true">🦥</span>
</a>
</li>
</ul>
</nav>
</footer>
</div>
</body>
</html>