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?


ℹ️ 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.

", "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: \"The" 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 \