From 307d98e3862b6e867eea524b81d5428b03e6607c Mon Sep 17 00:00:00 2001 From: tobi <31960611+tsmethurst@users.noreply.github.com> Date: Tue, 10 Sep 2024 14:34:49 +0200 Subject: [PATCH] =?UTF-8?q?[feature]=20Process=20`Reject`=20of=20interacti?= =?UTF-8?q?on=20via=20fedi=20API,=20put=20rejected=20statuses=20in=20the?= =?UTF-8?q?=20"sin=20bin"=20=F0=9F=98=88=20(#3271)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [feature] Process `Reject` of interaction via fedi API, put rejected statuses in the "sin bin" * update test * move nil check back to `rejectStatusIRI` --- internal/cache/cache.go | 2 + internal/cache/db.go | 29 ++ internal/cache/size.go | 23 + internal/config/config.go | 1 + internal/config/defaults.go | 1 + internal/config/helpers.gen.go | 25 + internal/db/bundb/bundb.go | 5 + ...40904084406_fedi_api_reject_interaction.go | 67 +++ internal/db/bundb/sinbinstatus.go | 122 +++++ internal/db/db.go | 1 + internal/db/sinbinstatus.go | 41 ++ internal/federation/federatingdb/reject.go | 488 ++++++++++++++++-- .../federation/federatingdb/reject_test.go | 12 +- internal/gtsmodel/sinbinstatus.go | 45 ++ .../interactionrequests/reject_test.go | 10 + internal/processing/workers/fromclientapi.go | 62 ++- internal/processing/workers/fromfediapi.go | 151 +++++- internal/processing/workers/util.go | 99 ++-- internal/processing/workers/workers.go | 9 +- internal/typeutils/internal.go | 93 ++++ test/envparsing.sh | 1 + 21 files changed, 1172 insertions(+), 115 deletions(-) create mode 100644 internal/db/bundb/migrations/20240904084406_fedi_api_reject_interaction.go create mode 100644 internal/db/bundb/sinbinstatus.go create mode 100644 internal/db/sinbinstatus.go create mode 100644 internal/gtsmodel/sinbinstatus.go diff --git a/internal/cache/cache.go b/internal/cache/cache.go index f1c382d11..5554445b2 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -93,6 +93,7 @@ func (c *Caches) Init() { c.initPollVote() c.initPollVoteIDs() c.initReport() + c.initSinBinStatus() c.initStatus() c.initStatusBookmark() c.initStatusBookmarkIDs() @@ -170,6 +171,7 @@ func (c *Caches) Sweep(threshold float64) { c.DB.PollVote.Trim(threshold) c.DB.PollVoteIDs.Trim(threshold) c.DB.Report.Trim(threshold) + c.DB.SinBinStatus.Trim(threshold) c.DB.Status.Trim(threshold) c.DB.StatusBookmark.Trim(threshold) c.DB.StatusBookmarkIDs.Trim(threshold) diff --git a/internal/cache/db.go b/internal/cache/db.go index 5e86c92a2..7f54ee8c5 100644 --- a/internal/cache/db.go +++ b/internal/cache/db.go @@ -145,6 +145,9 @@ type DBCaches struct { // Report provides access to the gtsmodel Report database cache. Report StructCache[*gtsmodel.Report] + // SinBinStatus provides access to the gtsmodel SinBinStatus database cache. + SinBinStatus StructCache[*gtsmodel.SinBinStatus] + // Status provides access to the gtsmodel Status database cache. Status StructCache[*gtsmodel.Status] @@ -1170,6 +1173,32 @@ func (c *Caches) initReport() { }) } +func (c *Caches) initSinBinStatus() { + // Calculate maximum cache size. + cap := calculateResultCacheMax( + sizeofSinBinStatus(), // model in-mem size. + config.GetCacheSinBinStatusMemRatio(), + ) + + log.Infof(nil, "cache size = %d", cap) + + copyF := func(s1 *gtsmodel.SinBinStatus) *gtsmodel.SinBinStatus { + s2 := new(gtsmodel.SinBinStatus) + *s2 = *s1 + return s2 + } + + c.DB.SinBinStatus.Init(structr.CacheConfig[*gtsmodel.SinBinStatus]{ + Indices: []structr.IndexConfig{ + {Fields: "ID"}, + {Fields: "URI"}, + }, + MaxSize: cap, + IgnoreErr: ignoreErrors, + Copy: copyF, + }) +} + func (c *Caches) initStatus() { // Calculate maximum cache size. cap := calculateResultCacheMax( diff --git a/internal/cache/size.go b/internal/cache/size.go index 29ab77fbf..49c2f4318 100644 --- a/internal/cache/size.go +++ b/internal/cache/size.go @@ -593,6 +593,29 @@ func sizeofReport() uintptr { })) } +func sizeofSinBinStatus() uintptr { + return uintptr(size.Of(>smodel.SinBinStatus{ + ID: exampleID, + CreatedAt: exampleTime, + UpdatedAt: exampleTime, + URI: exampleURI, + URL: exampleURI, + Domain: exampleURI, + AccountURI: exampleURI, + InReplyToURI: exampleURI, + Content: exampleText, + AttachmentLinks: []string{exampleURI, exampleURI}, + MentionTargetURIs: []string{exampleURI}, + EmojiLinks: []string{exampleURI}, + PollOptions: []string{exampleTextSmall, exampleTextSmall, exampleTextSmall, exampleTextSmall}, + ContentWarning: exampleTextSmall, + Visibility: gtsmodel.VisibilityPublic, + Sensitive: util.Ptr(false), + Language: "en", + ActivityStreamsType: ap.ObjectNote, + })) +} + func sizeofStatus() uintptr { return uintptr(size.Of(>smodel.Status{ ID: exampleID, diff --git a/internal/config/config.go b/internal/config/config.go index d6b8f0a54..e24cb639b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -230,6 +230,7 @@ type CacheConfiguration struct { PollVoteMemRatio float64 `name:"poll-vote-mem-ratio"` PollVoteIDsMemRatio float64 `name:"poll-vote-ids-mem-ratio"` ReportMemRatio float64 `name:"report-mem-ratio"` + SinBinStatusMemRatio float64 `name:"sin-bin-status-mem-ratio"` StatusMemRatio float64 `name:"status-mem-ratio"` StatusBookmarkMemRatio float64 `name:"status-bookmark-mem-ratio"` StatusBookmarkIDsMemRatio float64 `name:"status-bookmark-ids-mem-ratio"` diff --git a/internal/config/defaults.go b/internal/config/defaults.go index e71711cb3..58e11a292 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -193,6 +193,7 @@ PollVoteMemRatio: 2, PollVoteIDsMemRatio: 2, ReportMemRatio: 1, + SinBinStatusMemRatio: 0.5, StatusMemRatio: 5, StatusBookmarkMemRatio: 0.5, StatusBookmarkIDsMemRatio: 2, diff --git a/internal/config/helpers.gen.go b/internal/config/helpers.gen.go index d19e4e241..75231d37b 100644 --- a/internal/config/helpers.gen.go +++ b/internal/config/helpers.gen.go @@ -3712,6 +3712,31 @@ func GetCacheReportMemRatio() float64 { return global.GetCacheReportMemRatio() } // SetCacheReportMemRatio safely sets the value for global configuration 'Cache.ReportMemRatio' field func SetCacheReportMemRatio(v float64) { global.SetCacheReportMemRatio(v) } +// GetCacheSinBinStatusMemRatio safely fetches the Configuration value for state's 'Cache.SinBinStatusMemRatio' field +func (st *ConfigState) GetCacheSinBinStatusMemRatio() (v float64) { + st.mutex.RLock() + v = st.config.Cache.SinBinStatusMemRatio + st.mutex.RUnlock() + return +} + +// SetCacheSinBinStatusMemRatio safely sets the Configuration value for state's 'Cache.SinBinStatusMemRatio' field +func (st *ConfigState) SetCacheSinBinStatusMemRatio(v float64) { + st.mutex.Lock() + defer st.mutex.Unlock() + st.config.Cache.SinBinStatusMemRatio = v + st.reloadToViper() +} + +// CacheSinBinStatusMemRatioFlag returns the flag name for the 'Cache.SinBinStatusMemRatio' field +func CacheSinBinStatusMemRatioFlag() string { return "cache-sin-bin-status-mem-ratio" } + +// GetCacheSinBinStatusMemRatio safely fetches the value for global configuration 'Cache.SinBinStatusMemRatio' field +func GetCacheSinBinStatusMemRatio() float64 { return global.GetCacheSinBinStatusMemRatio() } + +// SetCacheSinBinStatusMemRatio safely sets the value for global configuration 'Cache.SinBinStatusMemRatio' field +func SetCacheSinBinStatusMemRatio(v float64) { global.SetCacheSinBinStatusMemRatio(v) } + // GetCacheStatusMemRatio safely fetches the Configuration value for state's 'Cache.StatusMemRatio' field func (st *ConfigState) GetCacheStatusMemRatio() (v float64) { st.mutex.RLock() diff --git a/internal/db/bundb/bundb.go b/internal/db/bundb/bundb.go index 6ecd43cbc..45607ea15 100644 --- a/internal/db/bundb/bundb.go +++ b/internal/db/bundb/bundb.go @@ -76,6 +76,7 @@ type DBService struct { db.Rule db.Search db.Session + db.SinBinStatus db.Status db.StatusBookmark db.StatusFave @@ -271,6 +272,10 @@ func NewBunDBService(ctx context.Context, state *state.State) (db.DB, error) { Session: &sessionDB{ db: db, }, + SinBinStatus: &sinBinStatusDB{ + db: db, + state: state, + }, Status: &statusDB{ db: db, state: state, diff --git a/internal/db/bundb/migrations/20240904084406_fedi_api_reject_interaction.go b/internal/db/bundb/migrations/20240904084406_fedi_api_reject_interaction.go new file mode 100644 index 000000000..d97d35372 --- /dev/null +++ b/internal/db/bundb/migrations/20240904084406_fedi_api_reject_interaction.go @@ -0,0 +1,67 @@ +// 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 migrations + +import ( + "context" + + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/uptrace/bun" +) + +func init() { + up := func(ctx context.Context, db *bun.DB) error { + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + if _, err := tx. + NewCreateTable(). + Model(>smodel.SinBinStatus{}). + IfNotExists(). + Exec(ctx); err != nil { + return err + } + + for idx, col := range map[string]string{ + "sin_bin_statuses_account_uri_idx": "account_uri", + "sin_bin_statuses_domain_idx": "domain", + "sin_bin_statuses_in_reply_to_uri_idx": "in_reply_to_uri", + } { + if _, err := tx. + NewCreateIndex(). + Table("sin_bin_statuses"). + Index(idx). + Column(col). + IfNotExists(). + Exec(ctx); err != nil { + return err + } + } + + return nil + }) + } + + down := func(ctx context.Context, db *bun.DB) error { + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + return nil + }) + } + + if err := Migrations.Register(up, down); err != nil { + panic(err) + } +} diff --git a/internal/db/bundb/sinbinstatus.go b/internal/db/bundb/sinbinstatus.go new file mode 100644 index 000000000..5fc368022 --- /dev/null +++ b/internal/db/bundb/sinbinstatus.go @@ -0,0 +1,122 @@ +// 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 bundb + +import ( + "context" + "time" + + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/state" + "github.com/uptrace/bun" +) + +type sinBinStatusDB struct { + db *bun.DB + state *state.State +} + +func (s *sinBinStatusDB) GetSinBinStatusByID(ctx context.Context, id string) (*gtsmodel.SinBinStatus, error) { + return s.getSinBinStatus( + "ID", + func(sbStatus *gtsmodel.SinBinStatus) error { + return s.db. + NewSelect(). + Model(sbStatus). + Where("? = ?", bun.Ident("sin_bin_status.id"), id). + Scan(ctx) + }, + id, + ) +} + +func (s *sinBinStatusDB) GetSinBinStatusByURI(ctx context.Context, uri string) (*gtsmodel.SinBinStatus, error) { + return s.getSinBinStatus( + "URI", + func(sbStatus *gtsmodel.SinBinStatus) error { + return s.db. + NewSelect(). + Model(sbStatus). + Where("? = ?", bun.Ident("sin_bin_status.uri"), uri). + Scan(ctx) + }, + uri, + ) +} + +func (s *sinBinStatusDB) getSinBinStatus( + lookup string, + dbQuery func(*gtsmodel.SinBinStatus) error, + keyParts ...any, +) (*gtsmodel.SinBinStatus, error) { + // Fetch from database cache with loader callback. + return s.state.Caches.DB.SinBinStatus.LoadOne(lookup, func() (*gtsmodel.SinBinStatus, error) { + // Not cached! Perform database query. + sbStatus := new(gtsmodel.SinBinStatus) + if err := dbQuery(sbStatus); err != nil { + return nil, err + } + + return sbStatus, nil + }, keyParts...) +} + +func (s *sinBinStatusDB) PutSinBinStatus(ctx context.Context, sbStatus *gtsmodel.SinBinStatus) error { + return s.state.Caches.DB.SinBinStatus.Store(sbStatus, func() error { + _, err := s.db. + NewInsert(). + Model(sbStatus). + Exec(ctx) + return err + }) +} + +func (s *sinBinStatusDB) UpdateSinBinStatus( + ctx context.Context, + sbStatus *gtsmodel.SinBinStatus, + columns ...string, +) error { + sbStatus.UpdatedAt = time.Now() + if len(columns) > 0 { + // If we're updating by column, + // ensure "updated_at" is included. + columns = append(columns, "updated_at") + } + + return s.state.Caches.DB.SinBinStatus.Store(sbStatus, func() error { + _, err := s.db. + NewUpdate(). + Model(sbStatus). + Column(columns...). + Where("? = ?", bun.Ident("sin_bin_status.id"), sbStatus.ID). + Exec(ctx) + return err + }) +} + +func (s *sinBinStatusDB) DeleteSinBinStatusByID(ctx context.Context, id string) error { + // On return ensure status invalidated from cache. + defer s.state.Caches.DB.SinBinStatus.Invalidate("ID", id) + + _, err := s.db. + NewDelete(). + TableExpr("? AS ?", bun.Ident("sin_bin_statuses"), bun.Ident("sin_bin_status")). + Where("? = ?", bun.Ident("sin_bin_status.id"), id). + Exec(ctx) + return err +} diff --git a/internal/db/db.go b/internal/db/db.go index cd621871a..c42985912 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -48,6 +48,7 @@ type DB interface { Rule Search Session + SinBinStatus Status StatusBookmark StatusFave diff --git a/internal/db/sinbinstatus.go b/internal/db/sinbinstatus.go new file mode 100644 index 000000000..16abcf8bd --- /dev/null +++ b/internal/db/sinbinstatus.go @@ -0,0 +1,41 @@ +// 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 db + +import ( + "context" + + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +type SinBinStatus interface { + // GetSinBinStatusByID fetches the sin bin status from the database with matching id column. + GetSinBinStatusByID(ctx context.Context, id string) (*gtsmodel.SinBinStatus, error) + + // GetSinBinStatusByURI fetches the sin bin status from the database with matching uri column. + GetSinBinStatusByURI(ctx context.Context, uri string) (*gtsmodel.SinBinStatus, error) + + // PutSinBinStatus stores one sin bin status in the database. + PutSinBinStatus(ctx context.Context, sbStatus *gtsmodel.SinBinStatus) error + + // UpdateSinBinStatus updates one sin bin status in the database. + UpdateSinBinStatus(ctx context.Context, sbStatus *gtsmodel.SinBinStatus, columns ...string) error + + // DeleteSinBinStatusByID deletes one sin bin status from the database. + DeleteSinBinStatusByID(ctx context.Context, id string) error +} diff --git a/internal/federation/federatingdb/reject.go b/internal/federation/federatingdb/reject.go index 929559031..404e19c4c 100644 --- a/internal/federation/federatingdb/reject.go +++ b/internal/federation/federatingdb/reject.go @@ -20,12 +20,17 @@ import ( "context" "errors" - "fmt" + "time" "codeberg.org/gruf/go-logger/v2/level" "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/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/log" + "github.com/superseriousbusiness/gotosocial/internal/messages" "github.com/superseriousbusiness/gotosocial/internal/uris" ) @@ -48,63 +53,450 @@ func (f *federatingDB) Reject(ctx context.Context, reject vocab.ActivityStreamsR requestingAcct := activityContext.requestingAcct receivingAcct := activityContext.receivingAcct - for _, obj := range ap.ExtractObjects(reject) { + activityID := ap.GetJSONLDId(reject) + if activityID == nil { + // We need an ID. + const text = "Reject had no id property" + return gtserror.NewErrorBadRequest(errors.New(text), text) + } - if obj.IsIRI() { - // we have just the URI of whatever is being rejected, so we need to find out what it is - rejectedObjectIRI := obj.GetIRI() - if uris.IsFollowPath(rejectedObjectIRI) { - // REJECT FOLLOW - followReq, err := f.state.DB.GetFollowRequestByURI(ctx, rejectedObjectIRI.String()) - if err != nil { - return fmt.Errorf("Reject: couldn't get follow request with id %s from the database: %s", rejectedObjectIRI.String(), err) - } + for _, object := range ap.ExtractObjects(reject) { + if asType := object.GetType(); asType != nil { + // Check and handle any + // vocab.Type objects. + // nolint:gocritic + switch asType.GetTypeName() { - // Make sure the creator of the original follow - // is the same as whatever inbox this landed in. - if followReq.AccountID != receivingAcct.ID { - return errors.New("Reject: follow account and inbox account were not the same") - } - - // Make sure the target of the original follow - // is the same as the account making the request. - if followReq.TargetAccountID != requestingAcct.ID { - return errors.New("Reject: follow target account and requesting account were not the same") - } - - return f.state.DB.RejectFollowRequest(ctx, followReq.AccountID, followReq.TargetAccountID) - } - } - - if t := obj.GetType(); t != nil { - // we have the whole object so we can figure out what we're rejecting // REJECT FOLLOW - asFollow, ok := t.(vocab.ActivityStreamsFollow) - if !ok { - return errors.New("Reject: couldn't parse follow into vocab.ActivityStreamsFollow") + case ap.ActivityFollow: + if err := f.rejectFollowType( + ctx, + asType, + receivingAcct, + requestingAcct, + ); err != nil { + return err + } } - // convert the follow to something we can understand - gtsFollow, err := f.converter.ASFollowToFollow(ctx, asFollow) - if err != nil { - return fmt.Errorf("Reject: error converting asfollow to gtsfollow: %s", err) - } + } else if object.IsIRI() { + // Check and handle any + // IRI type objects. + switch objIRI := object.GetIRI(); { - // Make sure the creator of the original follow - // is the same as whatever inbox this landed in. - if gtsFollow.AccountID != receivingAcct.ID { - return errors.New("Reject: follow account and inbox account were not the same") - } + // REJECT FOLLOW + case uris.IsFollowPath(objIRI): + if err := f.rejectFollowIRI( + ctx, + objIRI.String(), + receivingAcct, + requestingAcct, + ); err != nil { + return err + } - // Make sure the target of the original follow - // is the same as the account making the request. - if gtsFollow.TargetAccountID != requestingAcct.ID { - return errors.New("Reject: follow target account and requesting account were not the same") - } + // REJECT STATUS (reply/boost) + case uris.IsStatusesPath(objIRI): + if err := f.rejectStatusIRI( + ctx, + activityID.String(), + objIRI.String(), + receivingAcct, + requestingAcct, + ); err != nil { + return err + } - return f.state.DB.RejectFollowRequest(ctx, gtsFollow.AccountID, gtsFollow.TargetAccountID) + // REJECT LIKE + case uris.IsLikePath(objIRI): + if err := f.rejectLikeIRI( + ctx, + activityID.String(), + objIRI.String(), + receivingAcct, + requestingAcct, + ); err != nil { + return err + } + } } } return nil } + +func (f *federatingDB) rejectFollowType( + ctx context.Context, + asType vocab.Type, + receivingAcct *gtsmodel.Account, + requestingAcct *gtsmodel.Account, +) error { + // Cast the vocab.Type object to known AS type. + asFollow := asType.(vocab.ActivityStreamsFollow) + + // Reconstruct the follow. + follow, err := f.converter.ASFollowToFollow(ctx, asFollow) + if err != nil { + err := gtserror.Newf("error converting Follow to *gtsmodel.Follow: %w", err) + return gtserror.NewErrorInternalError(err) + } + + // Lock on the Follow URI + // as we may be updating it. + unlock := f.state.FedLocks.Lock(follow.URI) + defer unlock() + + // Make sure the creator of the original follow + // is the same as whatever inbox this landed in. + if follow.AccountID != receivingAcct.ID { + const text = "Follow account and inbox account were not the same" + return gtserror.NewErrorUnprocessableEntity(errors.New(text), text) + } + + // Make sure the target of the original follow + // is the same as the account making the request. + if follow.TargetAccountID != requestingAcct.ID { + const text = "Follow target account and requesting account were not the same" + return gtserror.NewErrorForbidden(errors.New(text), text) + } + + // Reject the follow. + err = f.state.DB.RejectFollowRequest( + ctx, + follow.AccountID, + follow.TargetAccountID, + ) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error rejecting follow request: %w", err) + return gtserror.NewErrorInternalError(err) + } + + return nil +} + +func (f *federatingDB) rejectFollowIRI( + ctx context.Context, + objectIRI string, + receivingAcct *gtsmodel.Account, + requestingAcct *gtsmodel.Account, +) error { + // Lock on this potential Follow + // URI as we may be updating it. + unlock := f.state.FedLocks.Lock(objectIRI) + defer unlock() + + // Get the follow req from the db. + followReq, err := f.state.DB.GetFollowRequestByURI(ctx, objectIRI) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting follow request: %w", err) + return gtserror.NewErrorInternalError(err) + } + + if followReq == nil { + // We didn't have a follow request + // with this URI, so nothing to do. + // Just return. + // + // TODO: Handle Reject Follow to remove + // an already-accepted follow relationship. + return nil + } + + // Make sure the creator of the original follow + // is the same as whatever inbox this landed in. + if followReq.AccountID != receivingAcct.ID { + const text = "Follow account and inbox account were not the same" + return gtserror.NewErrorUnprocessableEntity(errors.New(text), text) + } + + // Make sure the target of the original follow + // is the same as the account making the request. + if followReq.TargetAccountID != requestingAcct.ID { + const text = "Follow target account and requesting account were not the same" + return gtserror.NewErrorForbidden(errors.New(text), text) + } + + // Reject the follow. + err = f.state.DB.RejectFollowRequest( + ctx, + followReq.AccountID, + followReq.TargetAccountID, + ) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error rejecting follow request: %w", err) + return gtserror.NewErrorInternalError(err) + } + + return nil +} + +func (f *federatingDB) rejectStatusIRI( + ctx context.Context, + activityID string, + objectIRI string, + receivingAcct *gtsmodel.Account, + requestingAcct *gtsmodel.Account, +) error { + // Lock on this potential status URI. + unlock := f.state.FedLocks.Lock(objectIRI) + defer unlock() + + // Get the status from the db. + status, err := f.state.DB.GetStatusByURI(ctx, objectIRI) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting status: %w", err) + return gtserror.NewErrorInternalError(err) + } + + if status == nil { + // We didn't have a status with + // this URI, so nothing to do. + // Just return. + return nil + } + + if !status.IsLocal() { + // We don't process Rejects of statuses + // that weren't created on our instance. + // Just return. + // + // TODO: Handle Reject to remove *remote* + // posts replying-to or boosting the + // Rejecting account. + return nil + } + + // Make sure the creator of the original status + // is the same as the inbox processing the Reject; + // this also ensures the status is local. + if status.AccountID != receivingAcct.ID { + const text = "status author account and inbox account were not the same" + return gtserror.NewErrorUnprocessableEntity(errors.New(text), text) + } + + // Check if we're dealing with a reply + // or an announce, and make sure the + // requester is permitted to Reject. + var apObjectType string + if status.InReplyToID != "" { + // Rejecting a Reply. + apObjectType = ap.ObjectNote + if status.InReplyToAccountID != requestingAcct.ID { + const text = "status reply to account and requesting account were not the same" + return gtserror.NewErrorForbidden(errors.New(text), text) + } + + // You can't mention an account and then Reject replies from that + // same account (harassment vector); don't process these Rejects. + if status.InReplyTo != nil && status.InReplyTo.MentionsAccount(status.AccountID) { + const text = "refusing to process Reject of a reply from a mentioned account" + return gtserror.NewErrorForbidden(errors.New(text), text) + } + + } else { + // Rejecting an Announce. + apObjectType = ap.ActivityAnnounce + if status.BoostOfAccountID != requestingAcct.ID { + const text = "status boost of account and requesting account were not the same" + return gtserror.NewErrorForbidden(errors.New(text), text) + } + } + + // Check if there's an interaction request in the db for this status. + req, err := f.state.DB.GetInteractionRequestByInteractionURI(ctx, status.URI) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting interaction request: %w", err) + return gtserror.NewErrorInternalError(err) + } + + switch { + case req == nil: + // No interaction request existed yet for this + // status, create a pre-rejected request now. + req = >smodel.InteractionRequest{ + ID: id.NewULID(), + TargetAccountID: requestingAcct.ID, + TargetAccount: requestingAcct, + InteractingAccountID: receivingAcct.ID, + InteractingAccount: receivingAcct, + InteractionURI: status.URI, + URI: activityID, + RejectedAt: time.Now(), + } + + if apObjectType == ap.ObjectNote { + // Reply. + req.InteractionType = gtsmodel.InteractionReply + req.StatusID = status.InReplyToID + req.Status = status.InReplyTo + req.Reply = status + } else { + // Announce. + req.InteractionType = gtsmodel.InteractionAnnounce + req.StatusID = status.BoostOfID + req.Status = status.BoostOf + req.Announce = status + } + + if err := f.state.DB.PutInteractionRequest(ctx, req); err != nil { + err := gtserror.Newf("db error inserting interaction request: %w", err) + return gtserror.NewErrorInternalError(err) + } + + case req.IsRejected(): + // Interaction has already been rejected. Just + // update to this Reject URI and then return early. + req.URI = activityID + if err := f.state.DB.UpdateInteractionRequest(ctx, req, "uri"); err != nil { + err := gtserror.Newf("db error updating interaction request: %w", err) + return gtserror.NewErrorInternalError(err) + } + return nil + + default: + // Mark existing interaction request as + // Rejected, even if previously Accepted. + req.AcceptedAt = time.Time{} + req.RejectedAt = time.Now() + req.URI = activityID + if err := f.state.DB.UpdateInteractionRequest(ctx, req, + "accepted_at", + "rejected_at", + "uri", + ); err != nil { + err := gtserror.Newf("db error updating interaction request: %w", err) + return gtserror.NewErrorInternalError(err) + } + } + + // Send the rejected request through to + // the fedi worker to process side effects. + f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{ + APObjectType: apObjectType, + APActivityType: ap.ActivityReject, + GTSModel: req, + Receiving: receivingAcct, + Requesting: requestingAcct, + }) + + return nil +} + +func (f *federatingDB) rejectLikeIRI( + ctx context.Context, + activityID string, + objectIRI string, + receivingAcct *gtsmodel.Account, + requestingAcct *gtsmodel.Account, +) error { + // Lock on this potential Like + // URI as we may be updating it. + unlock := f.state.FedLocks.Lock(objectIRI) + defer unlock() + + // Get the fave from the db. + fave, err := f.state.DB.GetStatusFaveByURI(ctx, objectIRI) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting fave: %w", err) + return gtserror.NewErrorInternalError(err) + } + + if fave == nil { + // We didn't have a fave with + // this URI, so nothing to do. + // Just return. + return nil + } + + if !fave.Account.IsLocal() { + // We don't process Rejects of Likes + // that weren't created on our instance. + // Just return. + // + // TODO: Handle Reject to remove *remote* + // likes targeting the Rejecting account. + return nil + } + + // Make sure the creator of the original Like + // is the same as the inbox processing the Reject; + // this also ensures the Like is local. + if fave.AccountID != receivingAcct.ID { + const text = "fave creator account and inbox account were not the same" + return gtserror.NewErrorUnprocessableEntity(errors.New(text), text) + } + + // Make sure the target of the Like is the + // same as the account doing the Reject. + if fave.TargetAccountID != requestingAcct.ID { + const text = "status fave target account and requesting account were not the same" + return gtserror.NewErrorForbidden(errors.New(text), text) + } + + // Check if there's an interaction request in the db for this like. + req, err := f.state.DB.GetInteractionRequestByInteractionURI(ctx, fave.URI) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting interaction request: %w", err) + return gtserror.NewErrorInternalError(err) + } + + switch { + case req == nil: + // No interaction request existed yet for this + // fave, create a pre-rejected request now. + req = >smodel.InteractionRequest{ + ID: id.NewULID(), + TargetAccountID: requestingAcct.ID, + TargetAccount: requestingAcct, + InteractingAccountID: receivingAcct.ID, + InteractingAccount: receivingAcct, + InteractionURI: fave.URI, + InteractionType: gtsmodel.InteractionLike, + Like: fave, + URI: activityID, + RejectedAt: time.Now(), + } + + if err := f.state.DB.PutInteractionRequest(ctx, req); err != nil { + err := gtserror.Newf("db error inserting interaction request: %w", err) + return gtserror.NewErrorInternalError(err) + } + + case req.IsRejected(): + // Interaction has already been rejected. Just + // update to this Reject URI and then return early. + req.URI = activityID + if err := f.state.DB.UpdateInteractionRequest(ctx, req, "uri"); err != nil { + err := gtserror.Newf("db error updating interaction request: %w", err) + return gtserror.NewErrorInternalError(err) + } + return nil + + default: + // Mark existing interaction request as + // Rejected, even if previously Accepted. + req.AcceptedAt = time.Time{} + req.RejectedAt = time.Now() + req.URI = activityID + if err := f.state.DB.UpdateInteractionRequest(ctx, req, + "accepted_at", + "rejected_at", + "uri", + ); err != nil { + err := gtserror.Newf("db error updating interaction request: %w", err) + return gtserror.NewErrorInternalError(err) + } + } + + // Send the rejected request through to + // the fedi worker to process side effects. + f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{ + APObjectType: ap.ActivityLike, + APActivityType: ap.ActivityReject, + GTSModel: req, + Receiving: receivingAcct, + Requesting: requestingAcct, + }) + + return nil +} diff --git a/internal/federation/federatingdb/reject_test.go b/internal/federation/federatingdb/reject_test.go index f51ffaf56..8efa71ca0 100644 --- a/internal/federation/federatingdb/reject_test.go +++ b/internal/federation/federatingdb/reject_test.go @@ -23,6 +23,7 @@ "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/activity/streams" + "github.com/superseriousbusiness/gotosocial/internal/ap" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/uris" @@ -61,10 +62,11 @@ func (suite *RejectTestSuite) TestRejectFollowRequest() { // create a Reject reject := streams.NewActivityStreamsReject() + // set an ID on it + ap.SetJSONLDId(reject, testrig.URLMustParse("https://example.org/some/reject/id")) + // set the rejecting actor on it - acceptActorProp := streams.NewActivityStreamsActorProperty() - acceptActorProp.AppendIRI(rejectingAccountURI) - reject.SetActivityStreamsActor(acceptActorProp) + ap.AppendActorIRIs(reject, rejectingAccountURI) // Set the recreated follow as the 'object' property. acceptObject := streams.NewActivityStreamsObjectProperty() @@ -72,9 +74,7 @@ func (suite *RejectTestSuite) TestRejectFollowRequest() { reject.SetActivityStreamsObject(acceptObject) // Set the To of the reject as the originator of the follow - acceptTo := streams.NewActivityStreamsToProperty() - acceptTo.AppendIRI(requestingAccountURI) - reject.SetActivityStreamsTo(acceptTo) + ap.AppendTo(reject, requestingAccountURI) // process the reject in the federating database err = suite.federatingDB.Reject(ctx, reject) diff --git a/internal/gtsmodel/sinbinstatus.go b/internal/gtsmodel/sinbinstatus.go new file mode 100644 index 000000000..d1dfcddd1 --- /dev/null +++ b/internal/gtsmodel/sinbinstatus.go @@ -0,0 +1,45 @@ +// 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 gtsmodel + +import "time" + +// SinBinStatus represents a status that's been rejected and/or reported + quarantined. +// +// Automatically rejected statuses are not put in the sin bin, only statuses that were +// stored on the instance and which someone (local or remote) has subsequently rejected. +type SinBinStatus 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"` // Creation time of this item. + UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // Last-updated time of this item. + URI string `bun:",unique,nullzero,notnull"` // ActivityPub URI/ID of this status. + URL string `bun:",nullzero"` // Web url for viewing this status. + Domain string `bun:",nullzero"` // Domain of the status, will be null if this is a local status, otherwise something like `example.org`. + AccountURI string `bun:",nullzero,notnull"` // ActivityPub uri of the author of this status. + InReplyToURI string `bun:",nullzero"` // ActivityPub uri of the status this status is a reply to. + Content string `bun:",nullzero"` // Content of this status. + AttachmentLinks []string `bun:",nullzero,array"` // Links to attachments of this status. + MentionTargetURIs []string `bun:",nullzero,array"` // URIs of mentioned accounts. + EmojiLinks []string `bun:",nullzero,array"` // Links to any emoji images used in this status. + PollOptions []string `bun:",nullzero,array"` // String values of any poll options used in this status. + ContentWarning string `bun:",nullzero"` // CW / subject string for this status. + Visibility Visibility `bun:",nullzero,notnull"` // Visibility level of this status. + Sensitive *bool `bun:",nullzero,notnull,default:false"` // Mark the status as sensitive. + Language string `bun:",nullzero"` // Language code for this status. + ActivityStreamsType string `bun:",nullzero,notnull"` // ActivityStreams type of this status. +} diff --git a/internal/processing/interactionrequests/reject_test.go b/internal/processing/interactionrequests/reject_test.go index f1f6aed72..6e4aac691 100644 --- a/internal/processing/interactionrequests/reject_test.go +++ b/internal/processing/interactionrequests/reject_test.go @@ -71,6 +71,16 @@ func (suite *RejectTestSuite) TestReject() { ) return status == nil && errors.Is(err, db.ErrNoEntries) }) + + // Wait for a copy of the status + // to be hurled into the sin bin. + testrig.WaitFor(func() bool { + sbStatus, err := state.DB.GetSinBinStatusByURI( + gtscontext.SetBarebones(ctx), + dbReq.InteractionURI, + ) + return err == nil && sbStatus != nil + }) } func TestRejectTestSuite(t *testing.T) { diff --git a/internal/processing/workers/fromclientapi.go b/internal/processing/workers/fromclientapi.go index c723a6001..c8bc8352f 100644 --- a/internal/processing/workers/fromclientapi.go +++ b/internal/processing/workers/fromclientapi.go @@ -911,11 +911,6 @@ func (p *clientAPI) UndoAnnounce(ctx context.Context, cMsg *messages.FromClientA } func (p *clientAPI) DeleteStatus(ctx context.Context, cMsg *messages.FromClientAPI) error { - // Don't delete attachments, just unattach them: - // this request comes from the client API and the - // poster may want to use attachments again later. - const deleteAttachments = false - status, ok := cMsg.GTSModel.(*gtsmodel.Status) if !ok { return gtserror.Newf("%T not parseable as *gtsmodel.Status", cMsg.GTSModel) @@ -942,8 +937,22 @@ func (p *clientAPI) DeleteStatus(ctx context.Context, cMsg *messages.FromClientA // (stops processing of remote origin data targeting this status). p.state.Workers.Federator.Queue.Delete("TargetURI", status.URI) - // First perform the actual status deletion. - if err := p.utils.wipeStatus(ctx, status, deleteAttachments); err != nil { + // Don't delete attachments, just unattach them: + // this request comes from the client API and the + // poster may want to use attachments again later. + const deleteAttachments = false + + // This is just a deletion, not a Reject, + // we don't need to take a copy of this status. + const copyToSinBin = false + + // Perform the actual status deletion. + if err := p.utils.wipeStatus( + ctx, + status, + deleteAttachments, + copyToSinBin, + ); err != nil { log.Errorf(ctx, "error wiping status: %v", err) } @@ -1275,9 +1284,23 @@ func (p *clientAPI) RejectReply(ctx context.Context, cMsg *messages.FromClientAP return gtserror.Newf("db error getting rejected reply: %w", err) } - // Totally wipe the status. - if err := p.utils.wipeStatus(ctx, status, true); err != nil { - return gtserror.Newf("error wiping status: %w", err) + // Delete attachments from this status. + // It's rejected so there's no possibility + // for the poster to delete + redraft it. + const deleteAttachments = true + + // Keep a copy of the status in + // the sin bin for future review. + const copyToSinBin = true + + // Perform the actual status deletion. + if err := p.utils.wipeStatus( + ctx, + status, + deleteAttachments, + copyToSinBin, + ); err != nil { + log.Errorf(ctx, "error wiping reply: %v", err) } return nil @@ -1306,9 +1329,22 @@ func (p *clientAPI) RejectAnnounce(ctx context.Context, cMsg *messages.FromClien return gtserror.Newf("db error getting rejected announce: %w", err) } - // Totally wipe the status. - if err := p.utils.wipeStatus(ctx, boost, true); err != nil { - return gtserror.Newf("error wiping status: %w", err) + // Boosts don't have attachments anyway + // so it doesn't matter what we set here. + const deleteAttachments = true + + // This is just a boost, don't + // keep a copy in the sin bin. + const copyToSinBin = true + + // Perform the actual status deletion. + if err := p.utils.wipeStatus( + ctx, + boost, + deleteAttachments, + copyToSinBin, + ); err != nil { + log.Errorf(ctx, "error wiping announce: %v", err) } return nil diff --git a/internal/processing/workers/fromfediapi.go b/internal/processing/workers/fromfediapi.go index 42e5e9db2..d8abaa865 100644 --- a/internal/processing/workers/fromfediapi.go +++ b/internal/processing/workers/fromfediapi.go @@ -27,6 +27,7 @@ "github.com/superseriousbusiness/gotosocial/internal/ap" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/federation/dereferencing" + "github.com/superseriousbusiness/gotosocial/internal/gtscontext" "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/uris" @@ -146,6 +147,23 @@ func (p *Processor) ProcessFromFediAPI(ctx context.Context, fMsg *messages.FromF return p.fediAPI.AcceptAnnounce(ctx, fMsg) } + // REJECT SOMETHING + case ap.ActivityReject: + switch fMsg.APObjectType { + + // REJECT LIKE + case ap.ActivityLike: + return p.fediAPI.RejectLike(ctx, fMsg) + + // REJECT NOTE/STATUS (ie., reject a reply) + case ap.ObjectNote: + return p.fediAPI.RejectReply(ctx, fMsg) + + // REJECT BOOST + case ap.ActivityAnnounce: + return p.fediAPI.RejectAnnounce(ctx, fMsg) + } + // DELETE SOMETHING case ap.ActivityDelete: switch fMsg.APObjectType { @@ -878,11 +896,6 @@ func (p *fediAPI) UpdateStatus(ctx context.Context, fMsg *messages.FromFediAPI) } func (p *fediAPI) DeleteStatus(ctx context.Context, fMsg *messages.FromFediAPI) error { - // Delete attachments from this status, since this request - // comes from the federating API, and there's no way the - // poster can do a delete + redraft for it on our instance. - const deleteAttachments = true - status, ok := fMsg.GTSModel.(*gtsmodel.Status) if !ok { return gtserror.Newf("%T not parseable as *gtsmodel.Status", fMsg.GTSModel) @@ -909,8 +922,22 @@ func (p *fediAPI) DeleteStatus(ctx context.Context, fMsg *messages.FromFediAPI) // (stops processing of remote origin data targeting this status). p.state.Workers.Federator.Queue.Delete("TargetURI", status.URI) - // First perform the actual status deletion. - if err := p.utils.wipeStatus(ctx, status, deleteAttachments); err != nil { + // Delete attachments from this status, since this request + // comes from the federating API, and there's no way the + // poster can do a delete + redraft for it on our instance. + const deleteAttachments = true + + // This is just a deletion, not a Reject, + // we don't need to take a copy of this status. + const copyToSinBin = false + + // Perform the actual status deletion. + if err := p.utils.wipeStatus( + ctx, + status, + deleteAttachments, + copyToSinBin, + ); err != nil { log.Errorf(ctx, "error wiping status: %v", err) } @@ -956,3 +983,113 @@ func (p *fediAPI) DeleteAccount(ctx context.Context, fMsg *messages.FromFediAPI) return nil } + +func (p *fediAPI) RejectLike(ctx context.Context, fMsg *messages.FromFediAPI) error { + req, ok := fMsg.GTSModel.(*gtsmodel.InteractionRequest) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.InteractionRequest", fMsg.GTSModel) + } + + // At this point the InteractionRequest should already + // be in the database, we just need to do side effects. + + // Send out the Reject. + if err := p.federate.RejectInteraction(ctx, req); err != nil { + log.Errorf(ctx, "error federating rejection of like: %v", err) + } + + // Get the rejected fave. + fave, err := p.state.DB.GetStatusFaveByURI( + gtscontext.SetBarebones(ctx), + req.InteractionURI, + ) + if err != nil { + return gtserror.Newf("db error getting rejected fave: %w", err) + } + + // Delete the fave. + if err := p.state.DB.DeleteStatusFaveByID(ctx, fave.ID); err != nil { + return gtserror.Newf("db error deleting fave: %w", err) + } + + return nil +} + +func (p *fediAPI) RejectReply(ctx context.Context, fMsg *messages.FromFediAPI) error { + req, ok := fMsg.GTSModel.(*gtsmodel.InteractionRequest) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.InteractionRequest", fMsg.GTSModel) + } + + // At this point the InteractionRequest should already + // be in the database, we just need to do side effects. + + // Get the rejected status. + status, err := p.state.DB.GetStatusByURI( + gtscontext.SetBarebones(ctx), + req.InteractionURI, + ) + if err != nil { + return gtserror.Newf("db error getting rejected reply: %w", err) + } + + // Delete attachments from this status. + // It's rejected so there's no possibility + // for the poster to delete + redraft it. + const deleteAttachments = true + + // Keep a copy of the status in + // the sin bin for future review. + const copyToSinBin = true + + // Perform the actual status deletion. + if err := p.utils.wipeStatus( + ctx, + status, + deleteAttachments, + copyToSinBin, + ); err != nil { + log.Errorf(ctx, "error wiping reply: %v", err) + } + + return nil +} + +func (p *fediAPI) RejectAnnounce(ctx context.Context, fMsg *messages.FromFediAPI) error { + req, ok := fMsg.GTSModel.(*gtsmodel.InteractionRequest) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.InteractionRequest", fMsg.GTSModel) + } + + // At this point the InteractionRequest should already + // be in the database, we just need to do side effects. + + // Get the rejected boost. + boost, err := p.state.DB.GetStatusByURI( + gtscontext.SetBarebones(ctx), + req.InteractionURI, + ) + if err != nil { + return gtserror.Newf("db error getting rejected announce: %w", err) + } + + // Boosts don't have attachments anyway + // so it doesn't matter what we set here. + const deleteAttachments = true + + // This is just a boost, don't + // keep a copy in the sin bin. + const copyToSinBin = true + + // Perform the actual status deletion. + if err := p.utils.wipeStatus( + ctx, + boost, + deleteAttachments, + copyToSinBin, + ); err != nil { + log.Errorf(ctx, "error wiping announce: %v", err) + } + + return nil +} diff --git a/internal/processing/workers/util.go b/internal/processing/workers/util.go index bb7faffbf..042f4827c 100644 --- a/internal/processing/workers/util.go +++ b/internal/processing/workers/util.go @@ -37,69 +37,90 @@ // util provides util functions used by both // the fromClientAPI and fromFediAPI functions. type utils struct { - state *state.State - media *media.Processor - account *account.Processor - surface *Surface + state *state.State + media *media.Processor + account *account.Processor + surface *Surface + converter *typeutils.Converter } -// wipeStatus encapsulates common logic -// used to totally delete a status + all -// its attachments, notifications, boosts, -// and timeline entries. +// wipeStatus encapsulates common logic used to +// totally delete a status + all its attachments, +// notifications, boosts, and timeline entries. +// +// If deleteAttachments is true, then any status +// attachments will also be deleted, else they +// will just be detached. +// +// If copyToSinBin is true, then a version of the +// status will be put in the `sin_bin_statuses` +// table prior to deletion. func (u *utils) wipeStatus( ctx context.Context, - statusToDelete *gtsmodel.Status, + status *gtsmodel.Status, deleteAttachments bool, + copyToSinBin bool, ) error { var errs gtserror.MultiError + if copyToSinBin { + // Copy this status to the sin bin before we delete it. + sbStatus, err := u.converter.StatusToSinBinStatus(ctx, status) + if err != nil { + errs.Appendf("error converting status to sinBinStatus: %w", err) + } else { + if err := u.state.DB.PutSinBinStatus(ctx, sbStatus); err != nil { + errs.Appendf("db error storing sinBinStatus: %w", err) + } + } + } + // Either delete all attachments for this status, - // or simply unattach + clean them separately later. + // or simply detach + clean them separately later. // - // Reason to unattach rather than delete is that - // the poster might want to reattach them to another - // status immediately (in case of delete + redraft) + // Reason to detach rather than delete is that + // the author might want to reattach them to another + // status immediately (in case of delete + redraft). if deleteAttachments { // todo:u.state.DB.DeleteAttachmentsForStatus - for _, id := range statusToDelete.AttachmentIDs { + for _, id := range status.AttachmentIDs { if err := u.media.Delete(ctx, id); err != nil { errs.Appendf("error deleting media: %w", err) } } } else { // todo:u.state.DB.UnattachAttachmentsForStatus - for _, id := range statusToDelete.AttachmentIDs { - if _, err := u.media.Unattach(ctx, statusToDelete.Account, id); err != nil { + for _, id := range status.AttachmentIDs { + if _, err := u.media.Unattach(ctx, status.Account, id); err != nil { errs.Appendf("error unattaching media: %w", err) } } } - // delete all mention entries generated by this status + // Delete all mentions generated by this status. // todo:u.state.DB.DeleteMentionsForStatus - for _, id := range statusToDelete.MentionIDs { + for _, id := range status.MentionIDs { if err := u.state.DB.DeleteMentionByID(ctx, id); err != nil { errs.Appendf("error deleting status mention: %w", err) } } - // delete all notification entries generated by this status - if err := u.state.DB.DeleteNotificationsForStatus(ctx, statusToDelete.ID); err != nil { + // Delete all notifications generated by this status. + if err := u.state.DB.DeleteNotificationsForStatus(ctx, status.ID); err != nil { errs.Appendf("error deleting status notifications: %w", err) } - // delete all bookmarks that point to this status - if err := u.state.DB.DeleteStatusBookmarksForStatus(ctx, statusToDelete.ID); err != nil { + // Delete all bookmarks of this status. + if err := u.state.DB.DeleteStatusBookmarksForStatus(ctx, status.ID); err != nil { errs.Appendf("error deleting status bookmarks: %w", err) } - // delete all faves of this status - if err := u.state.DB.DeleteStatusFavesForStatus(ctx, statusToDelete.ID); err != nil { + // Delete all faves of this status. + if err := u.state.DB.DeleteStatusFavesForStatus(ctx, status.ID); err != nil { errs.Appendf("error deleting status faves: %w", err) } - if pollID := statusToDelete.PollID; pollID != "" { + if pollID := status.PollID; pollID != "" { // Delete this poll by ID from the database. if err := u.state.DB.DeletePollByID(ctx, pollID); err != nil { errs.Appendf("error deleting status poll: %w", err) @@ -114,38 +135,42 @@ func (u *utils) wipeStatus( _ = u.state.Workers.Scheduler.Cancel(pollID) } - // delete all boosts for this status + remove them from timelines + // Get all boost of this status so that we can + // delete those boosts + remove them from timelines. boosts, err := u.state.DB.GetStatusBoosts( - // we MUST set a barebones context here, + // We MUST set a barebones context here, // as depending on where it came from the // original BoostOf may already be gone. gtscontext.SetBarebones(ctx), - statusToDelete.ID) + status.ID) if err != nil { errs.Appendf("error fetching status boosts: %w", err) } for _, boost := range boosts { - if err := u.surface.deleteStatusFromTimelines(ctx, boost.ID); err != nil { - errs.Appendf("error deleting boost from timelines: %w", err) - } + // Delete the boost itself. if err := u.state.DB.DeleteStatusByID(ctx, boost.ID); err != nil { errs.Appendf("error deleting boost: %w", err) } + + // Remove the boost from any and all timelines. + if err := u.surface.deleteStatusFromTimelines(ctx, boost.ID); err != nil { + errs.Appendf("error deleting boost from timelines: %w", err) + } } - // delete this status from any and all timelines - if err := u.surface.deleteStatusFromTimelines(ctx, statusToDelete.ID); err != nil { + // Delete the status itself from any and all timelines. + if err := u.surface.deleteStatusFromTimelines(ctx, status.ID); err != nil { errs.Appendf("error deleting status from timelines: %w", err) } - // delete this status from any conversations that it's part of - if err := u.state.DB.DeleteStatusFromConversations(ctx, statusToDelete.ID); err != nil { + // Delete this status from any conversations it's part of. + if err := u.state.DB.DeleteStatusFromConversations(ctx, status.ID); err != nil { errs.Appendf("error deleting status from conversations: %w", err) } - // finally, delete the status itself - if err := u.state.DB.DeleteStatusByID(ctx, statusToDelete.ID); err != nil { + // Finally delete the status itself. + if err := u.state.DB.DeleteStatusByID(ctx, status.ID); err != nil { errs.Appendf("error deleting status: %w", err) } diff --git a/internal/processing/workers/workers.go b/internal/processing/workers/workers.go index d4b525783..ad673481b 100644 --- a/internal/processing/workers/workers.go +++ b/internal/processing/workers/workers.go @@ -70,10 +70,11 @@ func New( // Init shared util funcs. utils := &utils{ - state: state, - media: media, - account: account, - surface: surface, + state: state, + media: media, + account: account, + surface: surface, + converter: converter, } return Processor{ diff --git a/internal/typeutils/internal.go b/internal/typeutils/internal.go index ed4ed4dd9..ccde6a38f 100644 --- a/internal/typeutils/internal.go +++ b/internal/typeutils/internal.go @@ -19,10 +19,15 @@ import ( "context" + "errors" + "net/url" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/id" + "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/uris" "github.com/superseriousbusiness/gotosocial/internal/util" ) @@ -175,3 +180,91 @@ func StatusFaveToInteractionRequest( Like: fave, }, nil } + +func (c *Converter) StatusToSinBinStatus( + ctx context.Context, + status *gtsmodel.Status, +) (*gtsmodel.SinBinStatus, error) { + // Populate status first so we have + // polls, mentions etc to copy over. + // + // ErrNoEntries is fine, we'll do our best. + err := c.state.DB.PopulateStatus(ctx, status) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return nil, gtserror.Newf("db error populating status: %w", err) + } + + // Get domain of this status, + // empty for our own domain. + var domain string + if status.Account != nil { + domain = status.Account.Domain + } else { + uri, err := url.Parse(status.URI) + if err != nil { + return nil, gtserror.Newf("error parsing status URI: %w", err) + } + + host := uri.Host + if host != config.GetAccountDomain() && + host != config.GetHost() { + domain = host + } + } + + // Extract just the image URLs from attachments. + attachLinks := make([]string, len(status.Attachments)) + for i, attach := range status.Attachments { + if attach.IsLocal() { + attachLinks[i] = attach.URL + } else { + attachLinks[i] = attach.RemoteURL + } + } + + // Extract just the target account URIs from mentions. + mentionTargetURIs := make([]string, 0, len(status.Mentions)) + for _, mention := range status.Mentions { + if err := c.state.DB.PopulateMention(ctx, mention); err != nil { + log.Errorf(ctx, "error populating mention: %v", err) + continue + } + + mentionTargetURIs = append(mentionTargetURIs, mention.TargetAccount.URI) + } + + // Extract just the image URLs from emojis. + emojiLinks := make([]string, len(status.Emojis)) + for i, emoji := range status.Emojis { + if emoji.IsLocal() { + emojiLinks[i] = emoji.ImageURL + } else { + emojiLinks[i] = emoji.ImageRemoteURL + } + } + + // Extract just the poll option strings. + var pollOptions []string + if status.Poll != nil { + pollOptions = status.Poll.Options + } + + return >smodel.SinBinStatus{ + ID: status.ID, // Reuse the status ID. + URI: status.URI, + URL: status.URL, + Domain: domain, + AccountURI: status.AccountURI, + InReplyToURI: status.InReplyToURI, + Content: status.Content, + AttachmentLinks: attachLinks, + MentionTargetURIs: mentionTargetURIs, + EmojiLinks: emojiLinks, + PollOptions: pollOptions, + ContentWarning: status.ContentWarning, + Visibility: status.Visibility, + Sensitive: status.Sensitive, + Language: status.Language, + ActivityStreamsType: status.ActivityStreamsType, + }, nil +} diff --git a/test/envparsing.sh b/test/envparsing.sh index 32842bee8..ab01578d6 100755 --- a/test/envparsing.sh +++ b/test/envparsing.sh @@ -59,6 +59,7 @@ EXPECT=$(cat << "EOF" "poll-vote-ids-mem-ratio": 2, "poll-vote-mem-ratio": 2, "report-mem-ratio": 1, + "sin-bin-status-mem-ratio": 0.5, "status-bookmark-ids-mem-ratio": 2, "status-bookmark-mem-ratio": 0.5, "status-fave-ids-mem-ratio": 3,