From 61141ac2324fd73221f8301ba3805c051bc99fe2 Mon Sep 17 00:00:00 2001
From: kim <89579420+NyaaaWhatsUpDoc@users.noreply.github.com>
Date: Tue, 28 Jan 2025 20:22:23 +0000
Subject: [PATCH 01/13] [chore] remove type switch in Create() and instead move
to FederatedCallbacks() (#3697)
* remove type switch in Create() and instead move to FederatedCallbacks()
* add missing (my bad!) federating wrapped callbacks behaviour
* add missing license header :innocent:
* fix create flag test to use correct function
---
internal/federation/authenticate.go | 3 +-
internal/federation/federatingdb/block.go | 87 +++++
internal/federation/federatingdb/create.go | 330 ++----------------
.../federation/federatingdb/create_test.go | 5 +-
internal/federation/federatingdb/db.go | 29 +-
internal/federation/federatingdb/flag.go | 91 +++++
internal/federation/federatingdb/follow.go | 84 +++++
internal/federation/federatingdb/like.go | 147 ++++++++
internal/federation/federatingdb/move.go | 3 +
internal/federation/federatingdb/update.go | 12 +-
internal/federation/federatingprotocol.go | 35 +-
internal/federation/federator.go | 56 ++-
internal/federation/transport.go | 2 +-
internal/gtserror/withcode.go | 159 +++++----
14 files changed, 610 insertions(+), 433 deletions(-)
create mode 100644 internal/federation/federatingdb/block.go
create mode 100644 internal/federation/federatingdb/flag.go
create mode 100644 internal/federation/federatingdb/follow.go
create mode 100644 internal/federation/federatingdb/like.go
diff --git a/internal/federation/authenticate.go b/internal/federation/authenticate.go
index e9263d43c..c06b8e72b 100644
--- a/internal/federation/authenticate.go
+++ b/internal/federation/authenticate.go
@@ -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,
diff --git a/internal/federation/federatingdb/block.go b/internal/federation/federatingdb/block.go
new file mode 100644
index 000000000..461a8748e
--- /dev/null
+++ b/internal/federation/federatingdb/block.go
@@ -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 .
+
+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
+}
diff --git a/internal/federation/federatingdb/create.go b/internal/federation/federatingdb/create.go
index 11030b16b..d9834b144 100644
--- a/internal/federation/federatingdb/create.go
+++ b/internal/federation/federatingdb/create.go
@@ -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
-}
diff --git a/internal/federation/federatingdb/create_test.go b/internal/federation/federatingdb/create_test.go
index fffee1432..51f1f3ed7 100644
--- a/internal/federation/federatingdb/create_test.go
+++ b/internal/federation/federatingdb/create_test.go
@@ -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())
}
diff --git a/internal/federation/federatingdb/db.go b/internal/federation/federatingdb/db.go
index 230098073..d76e5a42c 100644
--- a/internal/federation/federatingdb/db.go
+++ b/internal/federation/federatingdb/db.go
@@ -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{}{})
+}
diff --git a/internal/federation/federatingdb/flag.go b/internal/federation/federatingdb/flag.go
new file mode 100644
index 000000000..583a7c186
--- /dev/null
+++ b/internal/federation/federatingdb/flag.go
@@ -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 .
+
+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
+}
diff --git a/internal/federation/federatingdb/follow.go b/internal/federation/federatingdb/follow.go
new file mode 100644
index 000000000..33c5ad856
--- /dev/null
+++ b/internal/federation/federatingdb/follow.go
@@ -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 .
+
+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
+}
diff --git a/internal/federation/federatingdb/like.go b/internal/federation/federatingdb/like.go
new file mode 100644
index 000000000..37c7f988d
--- /dev/null
+++ b/internal/federation/federatingdb/like.go
@@ -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 .
+
+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
+}
diff --git a/internal/federation/federatingdb/move.go b/internal/federation/federatingdb/move.go
index 6ae299e30..cfdd0f651 100644
--- a/internal/federation/federatingdb/move.go
+++ b/internal/federation/federatingdb/move.go
@@ -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.
diff --git a/internal/federation/federatingdb/update.go b/internal/federation/federatingdb/update.go
index a2a9777d1..803c476d6 100644
--- a/internal/federation/federatingdb/update.go
+++ b/internal/federation/federatingdb/update.go
@@ -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)
diff --git a/internal/federation/federatingprotocol.go b/internal/federation/federatingprotocol.go
index a953701f8..2bf934161 100644
--- a/internal/federation/federatingprotocol.go
+++ b/internal/federation/federatingprotocol.go
@@ -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
}
diff --git a/internal/federation/federator.go b/internal/federation/federator.go
index b6f54906b..8f3efb302 100644
--- a/internal/federation/federator.go
+++ b/internal/federation/federator.go
@@ -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
}
diff --git a/internal/federation/transport.go b/internal/federation/transport.go
index bab89eafc..688bb793f 100644
--- a/internal/federation/transport.go
+++ b/internal/federation/transport.go
@@ -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)
}
diff --git a/internal/gtserror/withcode.go b/internal/gtserror/withcode.go
index 0878db7bc..e2059474d 100644
--- a/internal/gtserror/withcode.go
+++ b/internal/gtserror/withcode.go
@@ -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,
}
}
From d16e4fa34df222e08968b060d40aaf6b6ebeb912 Mon Sep 17 00:00:00 2001
From: tobi <31960611+tsmethurst@users.noreply.github.com>
Date: Wed, 29 Jan 2025 16:57:04 +0100
Subject: [PATCH 02/13] [feature] Use maintenance router to serve 503 while
server is starting/migrating (#3705)
* [feature] Use maintenance router to serve 503 while server is starting/migrating
* love you linter, kissies
---
cmd/gotosocial/action/server/server.go | 55 ++++++++++++++++++-
cmd/gotosocial/server.go | 14 +++++
internal/web/assets.go | 41 ++++++++++++--
internal/web/etag.go | 4 ++
internal/web/maintenance.go | 70 ++++++++++++++++++++++++
internal/web/web.go | 37 ++++++-------
web/template/maintenance.tmpl | 76 ++++++++++++++++++++++++++
7 files changed, 271 insertions(+), 26 deletions(-)
create mode 100644 internal/web/maintenance.go
create mode 100644 web/template/maintenance.tmpl
diff --git a/cmd/gotosocial/action/server/server.go b/cmd/gotosocial/action/server/server.go
index c08017e60..6f76fb804 100644
--- a/cmd/gotosocial/action/server/server.go
+++ b/cmd/gotosocial/action/server/server.go
@@ -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.
diff --git a/cmd/gotosocial/server.go b/cmd/gotosocial/server.go
index 80efc7486..da571ec3f 100644
--- a/cmd/gotosocial/server.go
+++ b/cmd/gotosocial/server.go
@@ -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
}
diff --git a/internal/web/assets.go b/internal/web/assets.go
index bc80bc398..ae185354d 100644
--- a/internal/web/assets.go
+++ b/internal/web/assets.go
@@ -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)
+}
diff --git a/internal/web/etag.go b/internal/web/etag.go
index a87d9fa3b..88779871c 100644
--- a/internal/web/etag.go
+++ b/internal/web/etag.go
@@ -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)
diff --git a/internal/web/maintenance.go b/internal/web/maintenance.go
new file mode 100644
index 000000000..f05fa83dd
--- /dev/null
+++ b/internal/web/maintenance.go
@@ -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 .
+
+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)
+ })
+}
diff --git a/internal/web/web.go b/internal/web/web.go
index ddf7d53ea..cfadc9283 100644
--- a/internal/web/web.go
+++ b/internal/web/web.go
@@ -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) })
diff --git a/web/template/maintenance.tmpl b/web/template/maintenance.tmpl
new file mode 100644
index 000000000..153130c53
--- /dev/null
+++ b/web/template/maintenance.tmpl
@@ -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 .
+*/ -}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{- .host -}}
+
+
+
+
+
+
This GoToSocial instance is currently down for maintenance, starting up, or running database migrations. Please wait.
+
If you are the admin of this instance, check your GoToSocial logs for more details, and make sure to not interrupt any running database migrations !
+
+
+
+
+
\ No newline at end of file
From 1ab960bf151d7b6440ee8611041447894abbc458 Mon Sep 17 00:00:00 2001
From: kim <89579420+NyaaaWhatsUpDoc@users.noreply.github.com>
Date: Thu, 30 Jan 2025 09:40:21 +0000
Subject: [PATCH 03/13] [bugfix] harden checks for remotes masquerading as
local, and return correct local account redirects early (#3706)
---
internal/federation/dereferencing/account.go | 25 ++--
.../federation/dereferencing/account_test.go | 110 ++++++++++++++++++
.../dereferencing/dereferencer_test.go | 33 +++---
3 files changed, 146 insertions(+), 22 deletions(-)
diff --git a/internal/federation/dereferencing/account.go b/internal/federation/dereferencing/account.go
index a47284c34..a9a816b4c 100644
--- a/internal/federation/dereferencing/account.go
+++ b/internal/federation/dereferencing/account.go
@@ -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()
diff --git a/internal/federation/dereferencing/account_test.go b/internal/federation/dereferencing/account_test.go
index 309771758..e6bd7eab7 100644
--- a/internal/federation/dereferencing/account_test.go
+++ b/internal/federation/dereferencing/account_test.go
@@ -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"]
diff --git a/internal/federation/dereferencing/dereferencer_test.go b/internal/federation/dereferencing/dereferencer_test.go
index 9878a1b50..f7627eca0 100644
--- a/internal/federation/dereferencing/dereferencer_test.go
+++ b/internal/federation/dereferencing/dereferencer_test.go
@@ -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)
}
From 91cef3495d40d2af06b448b5871b1a8101fd6515 Mon Sep 17 00:00:00 2001
From: kim <89579420+NyaaaWhatsUpDoc@users.noreply.github.com>
Date: Thu, 30 Jan 2025 12:52:03 +0000
Subject: [PATCH 04/13] [bugfix] Missing emoji urls (#3707)
* filter out emoji that are uncached when converting to frontend models
* some very small fixups
* remove TODO notice
---
internal/cleaner/emoji.go | 5 +++--
internal/gtsmodel/account.go | 15 +++++++++++++--
internal/gtsmodel/emoji.go | 3 ++-
internal/typeutils/internaltofrontend.go | 20 ++++++++++++++------
4 files changed, 32 insertions(+), 11 deletions(-)
diff --git a/internal/cleaner/emoji.go b/internal/cleaner/emoji.go
index 6cf194e40..05588ccb2 100644
--- a/internal/cleaner/emoji.go
+++ b/internal/cleaner/emoji.go
@@ -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 {
diff --git a/internal/gtsmodel/account.go b/internal/gtsmodel/account.go
index 79a35e561..bb07b8b16 100644
--- a/internal/gtsmodel/account.go
+++ b/internal/gtsmodel/account.go
@@ -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.
diff --git a/internal/gtsmodel/emoji.go b/internal/gtsmodel/emoji.go
index 7bf52586c..89cb551f1 100644
--- a/internal/gtsmodel/emoji.go
+++ b/internal/gtsmodel/emoji.go
@@ -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
diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go
index 6739d0540..d9c8748f5 100644
--- a/internal/typeutils/internaltofrontend.go
+++ b/internal/typeutils/internaltofrontend.go
@@ -2641,28 +2641,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)
}
From b3ecfe1e0a2a130d066afea733a17a9b5e58917e Mon Sep 17 00:00:00 2001
From: Marcus Jaschen
Date: Thu, 30 Jan 2025 13:56:08 +0100
Subject: [PATCH 05/13] [docs] remove `-X POST` from curl commands (#3665)
Daniel, author of curl, explains why `-X POST` is not
necessary and considered bad behavior:
https://daniel.haxx.se/blog/2015/09/11/unnecessary-use-of-curl-x/ for
---
docs/api/authentication.md | 2 --
docs/locales/zh/api/authentication.md | 2 --
2 files changed, 4 deletions(-)
diff --git a/docs/api/authentication.md b/docs/api/authentication.md
index 0b950543c..b9fccf98b 100644
--- a/docs/api/authentication.md
+++ b/docs/api/authentication.md
@@ -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",
diff --git a/docs/locales/zh/api/authentication.md b/docs/locales/zh/api/authentication.md
index 156c11f31..798c52b4c 100644
--- a/docs/locales/zh/api/authentication.md
+++ b/docs/locales/zh/api/authentication.md
@@ -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",
From 527587155a69ffb26a0c2210a4b31db9c6c27cdb Mon Sep 17 00:00:00 2001
From: kim <89579420+NyaaaWhatsUpDoc@users.noreply.github.com>
Date: Thu, 30 Jan 2025 15:05:15 +0000
Subject: [PATCH 06/13] check boosted account ID when performing usermute
checks (#3708)
---
internal/typeutils/internaltofrontend.go | 5 ++-
internal/typeutils/internaltofrontend_test.go | 38 +++++++++++++++++++
2 files changed, 41 insertions(+), 2 deletions(-)
diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go
index d9c8748f5..d5c7cb0b1 100644
--- a/internal/typeutils/internaltofrontend.go
+++ b/internal/typeutils/internaltofrontend.go
@@ -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 it.
+ if (s.BoostOfAccountID != "" && mutes.Matches(s.AccountID, filterContext, now)) ||
+ mutes.Matches(s.AccountID, filterContext, now) {
return nil, statusfilter.ErrHideStatus
}
diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go
index 1ca0840a5..df6edd553 100644
--- a/internal/typeutils/internaltofrontend_test.go
+++ b/internal/typeutils/internaltofrontend_test.go
@@ -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(),
From 493de5c0053b3bef7a0219144ff9bcca08f3a900 Mon Sep 17 00:00:00 2001
From: kim <89579420+NyaaaWhatsUpDoc@users.noreply.github.com>
Date: Thu, 30 Jan 2025 17:14:35 +0000
Subject: [PATCH 07/13] [bugfix] fix boost of account ID check (#3709)
---
internal/typeutils/internaltofrontend.go | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go
index d5c7cb0b1..71ff71f8b 100644
--- a/internal/typeutils/internaltofrontend.go
+++ b/internal/typeutils/internaltofrontend.go
@@ -943,8 +943,8 @@ func (c *Converter) statusToAPIFilterResults(
// Both mutes and filters can expire.
now := time.Now()
- // If requesting account mutes the author (taking boosts into account), hide it.
- if (s.BoostOfAccountID != "" && 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
}
From ab758cc2336e88d9cd967238310b433e75d500dc Mon Sep 17 00:00:00 2001
From: Vyr Cossont
Date: Fri, 31 Jan 2025 02:40:39 -0800
Subject: [PATCH 08/13] [feature] Add system message wrappers for pending
replies and placeholder attachments (#3713)
Fixes #3712
---
internal/typeutils/internaltofrontend_test.go | 4 +-
internal/typeutils/util.go | 38 +++++++++++++++----
2 files changed, 32 insertions(+), 10 deletions(-)
diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go
index df6edd553..27b6df139 100644
--- a/internal/typeutils/internaltofrontend_test.go
+++ b/internal/typeutils/internaltofrontend_test.go
@@ -1278,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",
@@ -1828,7 +1828,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToAPIStatusPendingApproval()
"muted": false,
"bookmarked": false,
"pinned": false,
- "content": "Hi @1happyturtle , can I reply?
ℹ️ 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: http://localhost:8080/settings/user/interaction_requests/01J5QVXCCEATJYSXM9H6MZT4JR .
",
+ "content": "Hi @1happyturtle , can I reply?
",
"reblog": null,
"application": {
"name": "superseriousbusiness",
diff --git a/internal/typeutils/util.go b/internal/typeutils/util.go
index 1747dbdcd..b4f2e41aa 100644
--- a/internal/typeutils/util.go
+++ b/internal/typeutils/util.go
@@ -128,12 +128,14 @@ func misskeyReportInlineURLs(content string) []*url.URL {
//
// Example:
//
-//
-// ℹ️ Note from your.instance.com: 2 attachment(s) in this status were not downloaded. Treat the following external link(s) with care:
-//
+//
+//
+//
ℹ️ Note from your.instance.com: 2 attachment(s) in this status were not downloaded. Treat the following external link(s) with care:
+//
+//
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(``)
- 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(`.`)
note.WriteString(``)
- 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(``)
+ wrappedNote.WriteString(text.SanitizeToHTML(unsanitizedNoteHTML))
+ wrappedNote.WriteString(`
`)
+
+ return wrappedNote.String()
}
// ContentToContentLanguage tries to
From b9e0689359f347edc47487a8043c9004ead0770a Mon Sep 17 00:00:00 2001
From: Vyr Cossont
Date: Fri, 31 Jan 2025 02:42:55 -0800
Subject: [PATCH 09/13] [bugfix] Extend parser to handle more non-Latin
hashtags (#3700)
* Allow marks after NFC normalization
Includes regression test for the Tamil example from #3618
* Disallow just numbers + marks + underscore as hashtag
---
internal/text/goldmark_parsers.go | 2 +-
internal/text/markdown_test.go | 8 ++++++
internal/text/normalize.go | 17 ++++++-------
internal/text/plain_test.go | 41 +++++++++++++++++++------------
internal/text/util.go | 17 +++++--------
5 files changed, 48 insertions(+), 37 deletions(-)
diff --git a/internal/text/goldmark_parsers.go b/internal/text/goldmark_parsers.go
index b7cf4f9e9..e2c87e057 100644
--- a/internal/text/goldmark_parsers.go
+++ b/internal/text/goldmark_parsers.go
@@ -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.
diff --git a/internal/text/markdown_test.go b/internal/text/markdown_test.go
index 98ed3a96b..153673415 100644
--- a/internal/text/markdown_test.go
+++ b/internal/text/markdown_test.go
@@ -50,6 +50,8 @@
withInlineCode2Expected = "Nobody tells you about the </code><del>SECRET CODE</del><code>, do they?
"
withHashtag = "# Title\n\nhere's a simple status that uses hashtag #Hashtag!"
withHashtagExpected = "Title here's a simple status that uses hashtag #Hashtag !
"
+ withTamilHashtag = "here's a simple status that uses a hashtag in Tamil #தமிழ்"
+ withTamilHashtagExpected = "here's a simple status that uses a hashtag in Tamil #தமிழ்
"
mdWithHTML = "# Title\n\nHere's a simple text in markdown.\n\nHere's a link .\n\nHere's an image: "
mdWithHTMLExpected = "Title Here's a simple text in markdown.
Here's a link .
Here's an image:
"
mdWithCheekyHTML = "# Title\n\nHere's a simple text in markdown.\n\nHere's a cheeky little 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)
diff --git a/internal/text/normalize.go b/internal/text/normalize.go
index d2e633d1e..ea266fb33 100644
--- a/internal/text/normalize.go
+++ b/internal/text/normalize.go
@@ -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
}
diff --git a/internal/text/plain_test.go b/internal/text/plain_test.go
index fac54a38e..ffa64ce44 100644
--- a/internal/text/plain_test.go
+++ b/internal/text/plain_test.go
@@ -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) {
diff --git a/internal/text/util.go b/internal/text/util.go
index af45cfaf0..47b2416dd 100644
--- a/internal/text/util.go
+++ b/internal/text/util.go
@@ -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
From 8a9422aa783e7d053a0d35eaac14914b7882b479 Mon Sep 17 00:00:00 2001
From: tobi <31960611+tsmethurst@users.noreply.github.com>
Date: Fri, 31 Jan 2025 13:45:39 +0100
Subject: [PATCH 10/13] [bugfix] shut down LE server nicely (#3714)
* [bugfix] shut down LE server nicely
---
internal/router/router.go | 51 +++++++++++++++++++++++++++++----------
1 file changed, 38 insertions(+), 13 deletions(-)
diff --git a/internal/router/router.go b/internal/router/router.go
index cf9033059..c2bf18b4f 100644
--- a/internal/router/router.go
+++ b/internal/router/router.go
@@ -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)
}
From fc895ade0262e27aff6a49bf33a460158a744beb Mon Sep 17 00:00:00 2001
From: Vyr Cossont
Date: Fri, 31 Jan 2025 06:57:32 -0800
Subject: [PATCH 11/13] [chore] Web Push: Use server URL for VAPID sub claim
(#3716)
webpush-go now supports sending `https://` as well as `mailto:` URLs for VAPID sub claims, so we can revert 464d920cfd9d6e362afdbe7ebdbf87f90fb50e0d now and save fetching the instance contact email or making up a dummy email if there wasn't one configured.
---
internal/webpush/realsender.go | 16 +---------------
1 file changed, 1 insertion(+), 15 deletions(-)
diff --git a/internal/webpush/realsender.go b/internal/webpush/realsender.go
index 4c4657957..4faf57fb2 100644
--- a/internal/webpush/realsender.go
+++ b/internal/webpush/realsender.go
@@ -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()),
From c47b9bd1d184ce415a9df9815536a161ab2869b5 Mon Sep 17 00:00:00 2001
From: tobi <31960611+tsmethurst@users.noreply.github.com>
Date: Fri, 31 Jan 2025 17:09:11 +0100
Subject: [PATCH 12/13] [chore] Add "object" uri to outgoing Accept + Reject
messages (#3717)
---
internal/typeutils/internaltoas.go | 36 +++++++++++++++++++++++--
internal/typeutils/internaltoas_test.go | 8 +++++-
2 files changed, 41 insertions(+), 3 deletions(-)
diff --git a/internal/typeutils/internaltoas.go b/internal/typeutils/internaltoas.go
index ce5187bde..ef492d91d 100644
--- a/internal/typeutils/internaltoas.go
+++ b/internal/typeutils/internaltoas.go
@@ -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)
diff --git a/internal/typeutils/internaltoas_test.go b/internal/typeutils/internaltoas_test.go
index 4d0d95641..ce949d577 100644
--- a/internal/typeutils/internaltoas_test.go
+++ b/internal/typeutils/internaltoas_test.go
@@ -1235,7 +1235,9 @@ func (suite *InternalToASTestSuite) TestInteractionReqToASAcceptAnnounce() {
req := >smodel.InteractionRequest{
ID: "01J1AKMZ8JE5NW0ZSFTRC1JJNE",
CreatedAt: testrig.TimeMustParse("2022-06-09T13:12:00Z"),
- TargetAccountID: acceptingAccount.ID,
+ StatusID: "01JJYCVKCXB9JTQD1XW2KB8MT3",
+ Status: >smodel.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 := >smodel.InteractionRequest{
ID: "01J1AKMZ8JE5NW0ZSFTRC1JJNE",
CreatedAt: testrig.TimeMustParse("2022-06-09T13:12:00Z"),
+ StatusID: "01JJYCVKCXB9JTQD1XW2KB8MT3",
+ Status: >smodel.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))
From a55bd6d2bd7b11aed653f4614836caed4103bec3 Mon Sep 17 00:00:00 2001
From: tobi <31960611+tsmethurst@users.noreply.github.com>
Date: Fri, 31 Jan 2025 19:27:18 +0100
Subject: [PATCH 13/13] [feature] Add `instance-stats-randomize` config option
(#3718)
* [feature] Add `instance-stats-randomize` config option
* don't use cache (overkill)
---
docs/configuration/instance.md | 11 ++++
example/config.yaml | 11 ++++
internal/api/client/instance/instanceget.go | 13 +++++
internal/api/model/instance.go | 13 ++++-
internal/api/model/instancev1.go | 7 +++
internal/api/model/instancev2.go | 7 +++
internal/config/config.go | 1 +
internal/config/flags.go | 1 +
internal/config/helpers.gen.go | 27 +++++++++-
internal/processing/fedi/wellknown.go | 30 ++++++++---
internal/typeutils/converter.go | 58 +++++++++++++++++++++
internal/typeutils/internaltofrontend.go | 12 +++++
test/envparsing.sh | 2 +
13 files changed, 183 insertions(+), 10 deletions(-)
diff --git a/docs/configuration/instance.md b/docs/configuration/instance.md
index cc793b7fe..fdaf324cf 100644
--- a/docs/configuration/instance.md
+++ b/docs/configuration/instance.md
@@ -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
```
diff --git a/example/config.yaml b/example/config.yaml
index 164eea7b2..10d7799c6 100644
--- a/example/config.yaml
+++ b/example/config.yaml
@@ -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 #####
###########################
diff --git a/internal/api/client/instance/instanceget.go b/internal/api/client/instance/instanceget.go
index 6690e7e98..d7a688b43 100644
--- a/internal/api/client/instance/instanceget.go
+++ b/internal/api/client/instance/instanceget.go
@@ -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)
}
diff --git a/internal/api/model/instance.go b/internal/api/model/instance.go
index d59424fa5..aaa01d837 100644
--- a/internal/api/model/instance.go
+++ b/internal/api/model/instance.go
@@ -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
+}
diff --git a/internal/api/model/instancev1.go b/internal/api/model/instancev1.go
index 6dedd04cc..57e32c80a 100644
--- a/internal/api/model/instancev1.go
+++ b/internal/api/model/instancev1.go
@@ -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.
diff --git a/internal/api/model/instancev2.go b/internal/api/model/instancev2.go
index b3d11dee2..96399ea06 100644
--- a/internal/api/model/instancev2.go
+++ b/internal/api/model/instancev2.go
@@ -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.
diff --git a/internal/config/config.go b/internal/config/config.go
index 33b4553a8..807d686d5 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -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?"`
diff --git a/internal/config/flags.go b/internal/config/flags.go
index 6f0957c36..b0b530d0b 100644
--- a/internal/config/flags.go
+++ b/internal/config/flags.go
@@ -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"))
diff --git a/internal/config/helpers.gen.go b/internal/config/helpers.gen.go
index 0f8ec02ce..469c46a7a 100644
--- a/internal/config/helpers.gen.go
+++ b/internal/config/helpers.gen.go
@@ -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 {
diff --git a/internal/processing/fedi/wellknown.go b/internal/processing/fedi/wellknown.go
index 4784b4bf7..ac92370c8 100644
--- a/internal/processing/fedi/wellknown.go
+++ b/internal/processing/fedi/wellknown.go
@@ -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{
diff --git a/internal/typeutils/converter.go b/internal/typeutils/converter.go
index 311839dc0..4fbe1dfd3 100644
--- a/internal/typeutils/converter.go
+++ b/internal/typeutils/converter.go
@@ -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,
+ }
+}
diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go
index 71ff71f8b..487e8434e 100644
--- a/internal/typeutils/internaltofrontend.go
+++ b/internal/typeutils/internaltofrontend.go
@@ -1745,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 {
@@ -1821,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{}
diff --git a/test/envparsing.sh b/test/envparsing.sh
index f9f3f25bc..565ecb1af 100755
--- a/test/envparsing.sh
+++ b/test/envparsing.sh
@@ -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 \