From ef3d38240b398c7fa871d7774788d6613e63df7a Mon Sep 17 00:00:00 2001 From: tsmethurst Date: Mon, 21 Jun 2021 15:50:12 +0200 Subject: [PATCH] deletes, unboosts, docs updates --- PROGRESS.md | 2 +- README.md | 53 +++++++---- internal/api/client/status/status.go | 1 + internal/api/client/status/statusunboost.go | 60 ++++++++++++ internal/processing/fromclientapi.go | 42 ++++++++ internal/processing/fromcommon.go | 6 +- internal/processing/processor.go | 2 + internal/processing/status.go | 4 + .../processing/synchronous/status/status.go | 2 + .../processing/synchronous/status/unboost.go | 95 +++++++++++++++++++ .../synchronous/streaming/streamdelete.go | 51 ++++++++++ .../synchronous/streaming/streaming.go | 5 + internal/timeline/manager.go | 16 +--- internal/timeline/remove.go | 10 ++ internal/typeutils/internaltoas.go | 2 +- 15 files changed, 318 insertions(+), 33 deletions(-) create mode 100644 internal/api/client/status/statusunboost.go create mode 100644 internal/processing/synchronous/status/unboost.go create mode 100644 internal/processing/synchronous/streaming/streamdelete.go diff --git a/PROGRESS.md b/PROGRESS.md index c25887d..e6c2ca0 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -78,7 +78,7 @@ * [x] /api/v1/statuses/:id/favourite POST (Fave a status) * [x] /api/v1/statuses/:id/unfavourite POST (Unfave a status) * [x] /api/v1/statuses/:id/reblog POST (Reblog a status) - * [ ] /api/v1/statuses/:id/unreblog POST (Undo a reblog) + * [x] /api/v1/statuses/:id/unreblog POST (Undo a reblog) * [ ] /api/v1/statuses/:id/bookmark POST (Bookmark a status) * [ ] /api/v1/statuses/:id/unbookmark POST (Undo a bookmark) * [ ] /api/v1/statuses/:id/mute POST (Mute notifications on a status) diff --git a/README.md b/README.md index a5d21a6..9e3a01c 100644 --- a/README.md +++ b/README.md @@ -8,31 +8,46 @@ Federated social media software. GoToSocial is a Fediverse server project, written in Golang. It provides an alternative to existing projects such as [Mastodon](https://joinmastodon.org/), [Pleroma](https://pleroma.social/), [Friendica](https://friendica.net), [PixelFed](https://pixelfed.org/) etc. -One of the key differences between GoToSocial and those other projects is that GoToSocial doesn't include an integrated front-end (ie., a webapp). Instead, like the Matrix.org's [Synapse](https://github.com/matrix-org/synapse) project, it provides only a server implementation and a well-documented API. On this API, developers are free to build any front-end implementation or mobile application that they wish. +One of the key differences between GoToSocial and those other projects is that GoToSocial doesn't include an integrated client front-end (ie., a webapp). Instead, like the Matrix.org's [Synapse](https://github.com/matrix-org/synapse) project, it provides only a server implementation, some static web pages for profiles and posts, and a well-documented API. On this API, developers are free to build any front-end implementation or mobile application that they wish. Because the server implementation is as generic and flexible/configurable as possible, GoToSocial provides the basis for many different types of social media experience, whether Tumblr-like, Facebook-like, or Twitter-like. -## Goals +## Features Wishlist -The first goal of the project is to implement a feature set comparable to Mastodon: server logic, federation logic, and a client API that's a superset of the Mastodon API described [here](https://docs.joinmastodon.org/). +A grab-bag of things that are already included or will be included in the project if time allows: -Once the client API is implemented, it should allow existing Mastodon apps like [Tusky](https://tusky.app/) and [Whalebird](https://whalebird.social/en/desktop/contents) to work with GoToSocial. - -After that, custom features will be added that will necessitate expanding the API. - -## Wishlist - -Among other things: - -* Reputation-based 'slow' federation. -* Granular post settings. -* Local-only posting. -* Easily-configurable character limit. -* Groups and group posting. +* Various federation modes, including reputation-based 'slow' federation, 'normal' federation, and zero federation. +* Local-only posting, and granular post settings including 'rebloggable/boostable', 'likeable', 'replyable'. +* Character limit for posts that's easy for admins to configure (no messing around in the source code). +* Groups and group posting! +* Built-in, automatic LetsEncrypt support (no messing around with Nginx or Certbot). +* Good performance on lower-powered machines like Raspberry Pi, old laptops, tiny VPSes (the test VPS has 1gb of ram and 1 cpu core). ## Implementation Status -For an up-to-date view on progress made towards a v1.0.0 release, see [here](./PROGRESS.md). +Things are moving on the project! As of June 2021 you can now: + +* Build and deploy GoToSocial as a binary, with automatic LetsEncrypt certificate support built-in. +* Connect to the running instance via Tusky or Pinafore, using email address and password (stored encrypted). +* Post/delete posts. +* Reply/delete replies. +* Fave/unfave posts. +* Post images and gifs. +* Boost stuff/unboost stuff. +* Set your profile info (including header and avatar). +* Follow people/unfollow people. +* Accept follow requests from people. +* Post followers only/direct/public/unlocked. +* Customize posts with further flags: federated (y/n), replyable (y/n), likeable (y/n), boostable (y/n) -- not supported through Pinafore/Tusky yet. +* Get notifications for mentions/replies/likes/boosts. +* View local timeline. +* View and scroll home timeline (with ~10ms latency hell yeah). +* Stream new posts, notifications and deletes through a websockets connection via Pinafore. +* Federation support and interoperability with Mastodon and others. + +In other words, a deployed GoToSocial instance is already pretty useable! + +For a detailed view on progress made towards a v0.1.0 (beta) release, see [here](./PROGRESS.md). ## Contact @@ -44,7 +59,9 @@ Currently, this project is funded using Liberapay, to put bread on the table whi ### Sponsors -None yet! [Go For It](https://liberapay.com/dumpsterqueer/) +Only anonymous donations so far! + +[Donate](https://liberapay.com/dumpsterqueer/) ### Image Attribution diff --git a/internal/api/client/status/status.go b/internal/api/client/status/status.go index c5959ed..dec6beb 100644 --- a/internal/api/client/status/status.go +++ b/internal/api/client/status/status.go @@ -98,6 +98,7 @@ func (m *Module) Route(r router.Router) error { r.AttachHandler(http.MethodGet, FavouritedPath, m.StatusFavedByGETHandler) r.AttachHandler(http.MethodPost, ReblogPath, m.StatusBoostPOSTHandler) + r.AttachHandler(http.MethodPost, UnreblogPath, m.StatusUnboostPOSTHandler) r.AttachHandler(http.MethodGet, RebloggedPath, m.StatusBoostedByGETHandler) r.AttachHandler(http.MethodGet, ContextPath, m.StatusContextGETHandler) diff --git a/internal/api/client/status/statusunboost.go b/internal/api/client/status/statusunboost.go new file mode 100644 index 0000000..cf6b61f --- /dev/null +++ b/internal/api/client/status/statusunboost.go @@ -0,0 +1,60 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + 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 status + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// StatusUnboostPOSTHandler handles unboost requests against a given status ID +func (m *Module) StatusUnboostPOSTHandler(c *gin.Context) { + l := m.log.WithFields(logrus.Fields{ + "func": "StatusUnboostPOSTHandler", + "request_uri": c.Request.RequestURI, + "user_agent": c.Request.UserAgent(), + "origin_ip": c.ClientIP(), + }) + l.Debugf("entering function") + + authed, err := oauth.Authed(c, true, false, true, true) // we don't really need an app here but we want everything else + if err != nil { + l.Debug("not authed so can't unboost status") + c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"}) + return + } + + targetStatusID := c.Param(IDKey) + if targetStatusID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"}) + return + } + + mastoStatus, errWithCode := m.processor.StatusUnboost(authed, targetStatusID) + if errWithCode != nil { + l.Debugf("error processing status unboost: %s", errWithCode.Error()) + c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + return + } + + c.JSON(http.StatusOK, mastoStatus) +} diff --git a/internal/processing/fromclientapi.go b/internal/processing/fromclientapi.go index 8c4a169..180af04 100644 --- a/internal/processing/fromclientapi.go +++ b/internal/processing/fromclientapi.go @@ -138,6 +138,18 @@ func (p *processor) processFromClientAPI(clientMsg gtsmodel.FromClientAPI) error return errors.New("undo was not parseable as *gtsmodel.StatusFave") } return p.federateUnfave(fave, clientMsg.OriginAccount, clientMsg.TargetAccount) + case gtsmodel.ActivityStreamsAnnounce: + // UNDO ANNOUNCE/BOOST + boost, ok := clientMsg.GTSModel.(*gtsmodel.Status) + if !ok { + return errors.New("undo was not parseable as *gtsmodel.Status") + } + + if err := p.deleteStatusFromTimelines(boost); err != nil { + return err + } + + return p.federateUnannounce(boost, clientMsg.OriginAccount, clientMsg.TargetAccount) } case gtsmodel.ActivityStreamsDelete: // DELETE @@ -313,6 +325,36 @@ func (p *processor) federateUnfave(fave *gtsmodel.StatusFave, originAccount *gts return err } +func (p *processor) federateUnannounce(boost *gtsmodel.Status, originAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) error { + asAnnounce, err := p.tc.BoostToAS(boost, originAccount, targetAccount) + if err != nil { + return fmt.Errorf("federateUnannounce: error converting status to announce: %s", err) + } + + // create an Undo and set the appropriate actor on it + undo := streams.NewActivityStreamsUndo() + undo.SetActivityStreamsActor(asAnnounce.GetActivityStreamsActor()) + + // Set the boost as the 'object' property. + undoObject := streams.NewActivityStreamsObjectProperty() + undoObject.AppendActivityStreamsAnnounce(asAnnounce) + undo.SetActivityStreamsObject(undoObject) + + // set the to + undo.SetActivityStreamsTo(asAnnounce.GetActivityStreamsTo()) + + // set the cc + undo.SetActivityStreamsCc(asAnnounce.GetActivityStreamsCc()) + + outboxIRI, err := url.Parse(originAccount.OutboxURI) + if err != nil { + return fmt.Errorf("federateUnannounce: error parsing outboxURI %s: %s", originAccount.OutboxURI, err) + } + + _, err = p.federator.FederatingActor().Send(context.Background(), outboxIRI, undo) + return err +} + func (p *processor) federateAcceptFollowRequest(follow *gtsmodel.Follow, originAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) error { // if both accounts are local there's nothing to do here if originAccount.Domain == "" && targetAccount.Domain == "" { diff --git a/internal/processing/fromcommon.go b/internal/processing/fromcommon.go index e10f754..d719b7f 100644 --- a/internal/processing/fromcommon.go +++ b/internal/processing/fromcommon.go @@ -401,5 +401,9 @@ func (p *processor) timelineStatusForAccount(status *gtsmodel.Status, accountID } func (p *processor) deleteStatusFromTimelines(status *gtsmodel.Status) error { - return p.timelineManager.WipeStatusFromAllTimelines(status.ID) + if err := p.timelineManager.WipeStatusFromAllTimelines(status.ID); err != nil { + return err + } + + return p.streamingProcessor.StreamDelete(status.ID) } diff --git a/internal/processing/processor.go b/internal/processing/processor.go index d1b4431..618fd64 100644 --- a/internal/processing/processor.go +++ b/internal/processing/processor.go @@ -117,6 +117,8 @@ type Processor interface { StatusFave(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error) // StatusBoost processes the boost/reblog of a given status, returning the newly-created boost if all is well. StatusBoost(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, gtserror.WithCode) + // StatusUnboost processes the unboost/unreblog of a given status, returning the status if all is well. + StatusUnboost(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, gtserror.WithCode) // StatusBoostedBy returns a slice of accounts that have boosted the given status, filtered according to privacy settings. StatusBoostedBy(authed *oauth.Auth, targetStatusID string) ([]*apimodel.Account, gtserror.WithCode) // StatusFavedBy returns a slice of accounts that have liked the given status, filtered according to privacy settings. diff --git a/internal/processing/status.go b/internal/processing/status.go index 6848436..ab3843d 100644 --- a/internal/processing/status.go +++ b/internal/processing/status.go @@ -40,6 +40,10 @@ func (p *processor) StatusBoost(authed *oauth.Auth, targetStatusID string) (*api return p.statusProcessor.Boost(authed.Account, authed.Application, targetStatusID) } +func (p *processor) StatusUnboost(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, gtserror.WithCode) { + return p.statusProcessor.Unboost(authed.Account, authed.Application, targetStatusID) +} + func (p *processor) StatusBoostedBy(authed *oauth.Auth, targetStatusID string) ([]*apimodel.Account, gtserror.WithCode) { return p.statusProcessor.BoostedBy(authed.Account, targetStatusID) } diff --git a/internal/processing/synchronous/status/status.go b/internal/processing/synchronous/status/status.go index cfc48ff..d83c325 100644 --- a/internal/processing/synchronous/status/status.go +++ b/internal/processing/synchronous/status/status.go @@ -21,6 +21,8 @@ type Processor interface { Fave(account *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) // Boost processes the boost/reblog of a given status, returning the newly-created boost if all is well. Boost(account *gtsmodel.Account, application *gtsmodel.Application, targetStatusID string) (*apimodel.Status, gtserror.WithCode) + // Unboost processes the unboost/unreblog of a given status, returning the status if all is well. + Unboost(account *gtsmodel.Account, application *gtsmodel.Application, targetStatusID string) (*apimodel.Status, gtserror.WithCode) // BoostedBy returns a slice of accounts that have boosted the given status, filtered according to privacy settings. BoostedBy(account *gtsmodel.Account, targetStatusID string) ([]*apimodel.Account, gtserror.WithCode) // FavedBy returns a slice of accounts that have liked the given status, filtered according to privacy settings. diff --git a/internal/processing/synchronous/status/unboost.go b/internal/processing/synchronous/status/unboost.go new file mode 100644 index 0000000..4314e2f --- /dev/null +++ b/internal/processing/synchronous/status/unboost.go @@ -0,0 +1,95 @@ +package status + +import ( + "errors" + "fmt" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +func (p *processor) Unboost(account *gtsmodel.Account, application *gtsmodel.Application, targetStatusID string) (*apimodel.Status, gtserror.WithCode) { + l := p.log.WithField("func", "Unboost") + + l.Tracef("going to search for target status %s", targetStatusID) + targetStatus := >smodel.Status{} + if err := p.db.GetByID(targetStatusID, targetStatus); err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching status %s: %s", targetStatusID, err)) + } + + l.Tracef("going to search for target account %s", targetStatus.AccountID) + targetAccount := >smodel.Account{} + if err := p.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err)) + } + + l.Trace("going to see if status is visible") + visible, err := p.filter.StatusVisible(targetStatus, account) + if err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err)) + } + + if !visible { + return nil, gtserror.NewErrorNotFound(errors.New("status is not visible")) + } + + // check if we actually have a boost for this status + var toUnboost bool + + gtsBoost := >smodel.Status{} + where := []db.Where{ + { + Key: "boost_of_id", + Value: targetStatusID, + }, + { + Key: "account_id", + Value: account.ID, + }, + } + err = p.db.GetWhere(where, gtsBoost) + if err == nil { + // we have a boost + toUnboost = true + } + + if err != nil { + // something went wrong in the db finding the boost + if _, ok := err.(db.ErrNoEntries); !ok { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error fetching existing boost from database: %s", err)) + } + // we just don't have a boost + toUnboost = false + } + + if toUnboost { + // we had a boost, so take some action to get rid of it + if err := p.db.DeleteWhere(where, >smodel.Status{}); err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error unboosting status: %s", err)) + } + + // pin some stuff onto the boost while we have it out of the db + gtsBoost.GTSBoostedStatus = targetStatus + gtsBoost.GTSBoostedStatus.GTSAuthorAccount = targetAccount + gtsBoost.GTSBoostedAccount = targetAccount + gtsBoost.GTSAuthorAccount = account + + // send it back to the processor for async processing + p.fromClientAPI <- gtsmodel.FromClientAPI{ + APObjectType: gtsmodel.ActivityStreamsAnnounce, + APActivityType: gtsmodel.ActivityStreamsUndo, + GTSModel: gtsBoost, + OriginAccount: account, + TargetAccount: targetAccount, + } + } + + mastoStatus, err := p.tc.StatusToMasto(targetStatus, account) + if err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)) + } + + return mastoStatus, nil +} diff --git a/internal/processing/synchronous/streaming/streamdelete.go b/internal/processing/synchronous/streaming/streamdelete.go new file mode 100644 index 0000000..2282c29 --- /dev/null +++ b/internal/processing/synchronous/streaming/streamdelete.go @@ -0,0 +1,51 @@ +package streaming + +import ( + "fmt" + "strings" + + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +func (p *processor) StreamDelete(statusID string) error { + errs := []string{} + + // we want to range through ALL streams for ALL accounts here to make sure it's very clear to everyone that the status has been deleted + p.streamMap.Range(func(k interface{}, v interface{}) bool { + // the key of this map should be an accountID (string) + accountID, ok := k.(string) + if !ok { + errs = append(errs, "key in streamMap was not a string!") + return false + } + + // the value of the map should be a buncha streams + streamsForAccount, ok := v.(*gtsmodel.StreamsForAccount) + if !ok { + errs = append(errs, fmt.Sprintf("stream map error for account stream %s", accountID)) + } + + // lock the streams while we work on them + streamsForAccount.Lock() + defer streamsForAccount.Unlock() + for _, stream := range streamsForAccount.Streams { + // lock each individual stream as we work on it + stream.Lock() + defer stream.Unlock() + if stream.Connected { + stream.Messages <- >smodel.Message{ + Stream: []string{stream.Type}, + Event: "delete", + Payload: statusID, + } + } + } + return true + }) + + if len(errs) != 0 { + return fmt.Errorf("one or more errors streaming status delete: %s", strings.Join(errs, ";")) + } + + return nil +} diff --git a/internal/processing/synchronous/streaming/streaming.go b/internal/processing/synchronous/streaming/streaming.go index 9fd6285..de75b8f 100644 --- a/internal/processing/synchronous/streaming/streaming.go +++ b/internal/processing/synchronous/streaming/streaming.go @@ -18,9 +18,14 @@ import ( type Processor interface { // AuthorizeStreamingRequest returns an oauth2 token info in response to an access token query from the streaming API AuthorizeStreamingRequest(accessToken string) (*gtsmodel.Account, error) + // OpenStreamForAccount returns a new Stream for the given account, which will contain a channel for passing messages back to the caller. OpenStreamForAccount(account *gtsmodel.Account, streamType string) (*gtsmodel.Stream, gtserror.WithCode) + // StreamStatusToAccount streams the given status to any open, appropriate streams belonging to the given account. StreamStatusToAccount(s *apimodel.Status, account *gtsmodel.Account) error + // StreamNotificationToAccount streams the given notification to any open, appropriate streams belonging to the given account. StreamNotificationToAccount(n *apimodel.Notification, account *gtsmodel.Account) error + // StreamDelete streams the delete of the given statusID to *ALL* open streams. + StreamDelete(statusID string) error } type processor struct { diff --git a/internal/timeline/manager.go b/internal/timeline/manager.go index 2770f9e..923fd01 100644 --- a/internal/timeline/manager.go +++ b/internal/timeline/manager.go @@ -74,11 +74,9 @@ type Manager interface { GetOldestIndexedID(timelineAccountID string) (string, error) // PrepareXFromTop prepares limit n amount of posts, based on their indexed representations, from the top of the index. PrepareXFromTop(timelineAccountID string, limit int) error - // WipeStatusFromTimeline completely removes a status and from the index and prepared posts of the given account ID - // - // The returned int indicates how many entries were removed. - WipeStatusFromTimeline(timelineAccountID string, statusID string) (int, error) - // WipeStatusFromAllTimelines removes the status from the index and prepared posts of all timelines + // Remove removes one status from the timeline of the given timelineAccountID + Remove(statusID string, timelineAccountID string) (int, error) + // WipeStatusFromAllTimelines removes one status from the index and prepared posts of all timelines WipeStatusFromAllTimelines(statusID string) error } @@ -177,12 +175,6 @@ func (m *manager) PrepareXFromTop(timelineAccountID string, limit int) error { return t.PrepareFromTop(limit) } -func (m *manager) WipeStatusFromTimeline(timelineAccountID string, statusID string) (int, error) { - t := m.getOrCreateTimeline(timelineAccountID) - - return t.Remove(statusID) -} - func (m *manager) WipeStatusFromAllTimelines(statusID string) error { errors := []string{} m.accountTimelines.Range(func(k interface{}, i interface{}) bool { @@ -195,7 +187,7 @@ func (m *manager) WipeStatusFromAllTimelines(statusID string) error { errors = append(errors, err.Error()) } - return false + return true }) var err error diff --git a/internal/timeline/remove.go b/internal/timeline/remove.go index 2f340d3..bb7a1c8 100644 --- a/internal/timeline/remove.go +++ b/internal/timeline/remove.go @@ -3,9 +3,16 @@ package timeline import ( "container/list" "errors" + + "github.com/sirupsen/logrus" ) func (t *timeline) Remove(statusID string) (int, error) { + l := t.log.WithFields(logrus.Fields{ + "func": "Remove", + "accountTimeline": t.accountID, + "statusID": statusID, + }) t.Lock() defer t.Unlock() var removed int @@ -19,6 +26,7 @@ func (t *timeline) Remove(statusID string) (int, error) { return removed, errors.New("Remove: could not parse e as a postIndexEntry") } if entry.statusID == statusID { + l.Debug("found status in postIndex") removeIndexes = append(removeIndexes, e) } } @@ -37,6 +45,7 @@ func (t *timeline) Remove(statusID string) (int, error) { return removed, errors.New("Remove: could not parse e as a preparedPostsEntry") } if entry.statusID == statusID { + l.Debug("found status in preparedPosts") removePrepared = append(removePrepared, e) } } @@ -46,5 +55,6 @@ func (t *timeline) Remove(statusID string) (int, error) { removed = removed + 1 } + l.Debugf("removed %d entries", removed) return removed, nil } diff --git a/internal/typeutils/internaltoas.go b/internal/typeutils/internaltoas.go index 296d0a1..bc7eee6 100644 --- a/internal/typeutils/internaltoas.go +++ b/internal/typeutils/internaltoas.go @@ -649,7 +649,7 @@ func (c *converter) BoostToAS(boostWrapperStatus *gtsmodel.Status, boostingAccou if err := c.db.GetByID(boostWrapperStatus.BoostOfID, b); err != nil { return nil, fmt.Errorf("BoostToAS: error getting status with ID %s from the db: %s", boostWrapperStatus.BoostOfID, err) } - boostWrapperStatus = b + boostWrapperStatus.GTSBoostedStatus = b } // create the announce