From 24cec4e7aab33b6c44ba6d1ecf16895f254351b8 Mon Sep 17 00:00:00 2001 From: tobi <31960611+tsmethurst@users.noreply.github.com> Date: Wed, 1 Mar 2023 18:52:44 +0100 Subject: [PATCH] [feature] Federate pinned posts (aka `featuredCollection`) in and out (#1560) * start fiddling * the ol' fiddle + update * start working on fetching statuses * poopy doopy doo where r u uwu * further adventures in featuring statuses * finishing up * fmt * simply status unpin loop * move empty featured check back to caller function * remove unnecessary log.WithContext calls * remove unnecessary IsIRI() checks * add explanatory comment about status URIs * change log level to error * better test names --- docs/api/swagger.yaml | 70 +++++- docs/federation/federating_with_gotosocial.md | 64 ++++- internal/ap/activitystreams.go | 29 +-- internal/api/activitypub/emoji/emojiget.go | 2 +- internal/api/activitypub/users/common.go | 30 ++- internal/api/activitypub/users/featured.go | 97 ++++++++ internal/api/activitypub/users/followers.go | 5 +- internal/api/activitypub/users/following.go | 5 +- internal/api/activitypub/users/outboxget.go | 5 +- internal/api/activitypub/users/repliesget.go | 2 +- internal/api/activitypub/users/statusget.go | 2 +- internal/api/activitypub/users/user.go | 3 + .../api/client/statuses/statuspin_test.go | 2 +- internal/db/bundb/admin.go | 4 +- internal/db/bundb/status.go | 9 +- internal/db/status.go | 4 +- internal/federation/dereferencing/account.go | 160 +++++++++++- internal/federation/dereferencing/status.go | 17 +- internal/processing/fedi/collections.go | 230 +++++++----------- internal/processing/fedi/common.go | 60 +++++ internal/processing/fedi/emoji.go | 3 +- internal/processing/fedi/status.go | 98 ++------ internal/processing/status/pin.go | 4 +- internal/typeutils/astointernal.go | 11 +- internal/typeutils/converter.go | 3 + internal/typeutils/internaltoas.go | 28 +++ internal/typeutils/internaltoas_test.go | 92 +++++++ internal/uris/uri.go | 20 +- internal/web/thread.go | 2 +- 29 files changed, 783 insertions(+), 278 deletions(-) create mode 100644 internal/api/activitypub/users/featured.go create mode 100644 internal/processing/fedi/common.go diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index c9ab00b8..601c3a2b 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -2324,9 +2324,11 @@ definitions: swaggerCollection: properties: '@context': - description: ActivityStreams context. + description: |- + ActivityStreams JSON-LD context. + A string or an array of strings, or more + complex nested items. example: https://www.w3.org/ns/activitystreams - type: string x-go-name: Context first: $ref: '#/definitions/swaggerCollectionPage' @@ -2342,7 +2344,7 @@ definitions: example: Collection type: string x-go-name: Type - title: SwaggerCollection represents an activitypub collection. + title: SwaggerCollection represents an ActivityPub Collection. type: object x-go-name: SwaggerCollection x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/activitypub/users @@ -2381,6 +2383,41 @@ definitions: type: object x-go-name: SwaggerCollectionPage x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/activitypub/users + swaggerFeaturedCollection: + properties: + '@context': + description: |- + ActivityStreams JSON-LD context. + A string or an array of strings, or more + complex nested items. + example: https://www.w3.org/ns/activitystreams + x-go-name: Context + TotalItems: + description: Number of items in this collection. + example: 2 + format: int64 + type: integer + id: + description: ActivityStreams ID. + example: https://example.org/users/some_user/collections/featured + type: string + x-go-name: ID + items: + description: List of status URIs. + example: '[''https://example.org/users/some_user/statuses/01GSZ0F7Q8SJKNRF777GJD271R'', ''https://example.org/users/some_user/statuses/01GSZ0G012CBQ7TEKX689S3QRE'']' + items: + type: string + type: array + x-go-name: Items + type: + description: ActivityStreams type. + example: OrderedCollection + type: string + x-go-name: Type + title: SwaggerFeaturedCollection represents an ActivityPub OrderedCollection. + type: object + x-go-name: SwaggerFeaturedCollection + x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/activitypub/users tag: properties: name: @@ -5629,6 +5666,33 @@ paths: summary: Returns a compliant nodeinfo response to node info queries. tags: - nodeinfo + /users/{username}/collections/featured: + get: + description: |- + The response will contain an ordered collection of Note URIs in the `items` property. + + It is up to the caller to dereference the provided Note URIs (or not, if they already have them cached). + + HTTP signature is required on the request. + operationId: s2sFeaturedCollectionGet + produces: + - application/activity+json + responses: + "200": + description: "" + schema: + $ref: '#/definitions/swaggerFeaturedCollection' + "400": + description: bad request + "401": + description: unauthorized + "403": + description: forbidden + "404": + description: not found + summary: Get the featured collection (pinned posts) for a user. + tags: + - s2s/federation /users/{username}/outbox: get: description: |- diff --git a/docs/federation/federating_with_gotosocial.md b/docs/federation/federating_with_gotosocial.md index 4e5c6a4a..9357e230 100644 --- a/docs/federation/federating_with_gotosocial.md +++ b/docs/federation/federating_with_gotosocial.md @@ -1,4 +1,5 @@ # Federating with GoToSocial + Information on the various (ActivityPub) elements needed to federate with GoToSocial. ## HTTP Signatures @@ -71,12 +72,11 @@ Remote servers federating with GoToSocial should extract the public key from the This behavior was introduced as a way of avoiding having remote servers make unsigned `GET` requests to the full Actor endpoint. However, this may change in future as it is not compliant and causes issues. Tracked in [this issue](https://github.com/superseriousbusiness/gotosocial/issues/1186). - ## Access Control GoToSocial uses access control restrictions to protect users and resources from unwanted interactions with remote accounts and instances. -As shown in the [HTTP Signatures](#http_signatures.md) section, GoToSocial requires all incoming `GET` and `POST` requests from remote servers to be signed. Unsigned requests will be denied with http code `401 Unauthorized`. +As shown in the [HTTP Signatures](#http-signatures) section, GoToSocial requires all incoming `GET` and `POST` requests from remote servers to be signed. Unsigned requests will be denied with http code `401 Unauthorized`. Access control restrictions are implemented by checking the `keyId` of the signature (who owns the public/private key pair making the request). @@ -85,6 +85,7 @@ First, the host value of the `keyId` uri is checked against the GoToSocial insta Next, GoToSocial will check for the existence of a block (in either direction) between the owner of the public key making the http request, and the owner of the resource that the request is targeting. If the GoToSocial user blocks the remote account making the request, then the request will be aborted with http code `403 Forbidden`. ## Request Throttling & Rate Limiting + GoToSocial applies http request throttling and rate limiting to the ActivityPub API endpoints (inboxes, user endpoints, emojis, etc). This ensures that remote servers cannot flood a GoToSocial instance with spurious requests. Instead, remote servers making GET or POST requests to the ActivityPub API endpoints should respect 429 and 503 http codes, and take account of the `retry-after` http response header. @@ -140,6 +141,7 @@ The `orderedItems` array will contain up to 30 entries. To get more entries beyo Note that in the returned `orderedItems`, all activity types will be `Create`. On each activity, the `object` field will be the AP URI of an original public status created by the Actor who owns the Outbox (ie., a `Note` with `https://www.w3.org/ns/activitystreams#Public` in the `to` field, which is not a reply to another status). Callers can use the returned AP URIs to dereference the content of the notes. ## Conversation Threads + Due to the nature of decentralization and federation, it is practically impossible for any one server on the fediverse to be aware of every post in a given conversation thread. With that said, it is possible to do 'best effort' dereferencing of threads, whereby remote replies are fetched from one server onto another, to try to more fully flesh out a conversation. @@ -208,4 +210,60 @@ GoToSocial will not assume that the `to` field will be set on an incoming `Flag` A valid incoming `Flag` Activity will be made available as a report to the admin(s) of the GoToSocial instance that received the report, so that they can take any necessary moderation action against the reported user. -The reported user themself will not see the report, or be notified that they have been reported, unless the GtS admin chooses to share this information with them via some other channel. +The reported user themself will not see the report, or be notified that they have been reported, unless the GtS admin chooses to share this information with them via some other channel. + +## Featured (aka pinned) Posts + +GoToSocial allows users to feature (or 'pin') posts on their profile. + +In ActivityPub terms, GoToSocial serves these pinned posts as an [OrderedCollection](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-orderedcollection) at the endpoint indicated in an Actor's [featured](https://docs.joinmastodon.org/spec/activitypub/#featured) field. The value of this field will be set to something like `https://example.org/users/some_user/collections/featured`. + +By making a signed GET request to this endpoint, remote instances can dereference the featured posts collection, which will return an `OrderedCollection` with a list of post URIs in the `orderedItems` field. + +Example of a featured collection of a user who has pinned multiple `Note`s: + +```json +{ + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://example.org/users/some_user/collections/featured", + "orderedItems": [ + "https://example.org/users/some_user/statuses/01GS7VTYH0S77NNXTP6W4G9EAG", + "https://example.org/users/some_user/statuses/01GSFY2SZK9TPCJFQ1WCCPGDRT", + "https://example.org/users/some_user/statuses/01GSCXY70MZCBFMH5EKJW9ENC8" + ], + "totalItems": 3, + "type": "OrderedCollection" +} +``` + +Example of a user who has pinned one `Note` (`orderedItems` is just a URL string now!): + +```json +{ + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://example.org/users/some_user/collections/featured", + "orderedItems": "https://example.org/users/some_user/statuses/01GS7VTYH0S77NNXTP6W4G9EAG", + "totalItems": 1, + "type": "OrderedCollection" +} +``` + +Example with no pinned `Note`s: + +```json +{ + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://example.org/users/some_user/collections/featured", + "orderedItems": [], + "totalItems": 0, + "type": "OrderedCollection" +} +``` + +Unlike Mastodon and some other implementations, GoToSocial does *not* serve full `Note` representations as `orderedItems` values. Instead, it provides just the URI of each `Note`, which the remote server can then dereference (or not, if they already have the `Note` cached locally). + +Some of the URIs served as part of the collection may point to followers-only posts which the requesting `Actor` won't necessarily have permission to view. Remote servers should make sure to do their own filtering (as with any other post type) to ensure that these posts are only shown to users who are permitted to view them. + +Another difference between GoToSocial and other server implementations is that GoToSocial does not send updates to remote servers when a post is pinned or unpinned by a user. Mastodon does this by sending [Add](https://www.w3.org/TR/activitypub/#add-activity-inbox) and [Remove](https://www.w3.org/TR/activitypub/#remove-activity-inbox) Activity types where the `object` is the post being pinned or unpinned, and the `target` is the sending `Actor`'s `featured` collection. While this conceptually makes sense, it is not in line with what the ActivityPub protocol recommends, since the `target` of the Activity "is not owned by the receiving server, and thus they can't update it". + +Instead, to build a view of a GoToSocial user's pinned posts, it is recommended that remote instances simply poll a GoToSocial Actor's `featured` collection every so often, and add/remove posts in their cached representation as appropriate. diff --git a/internal/ap/activitystreams.go b/internal/ap/activitystreams.go index d46690f4..294a56fe 100644 --- a/internal/ap/activitystreams.go +++ b/internal/ap/activitystreams.go @@ -55,18 +55,19 @@ const ( ActorPerson = "Person" // ActivityStreamsPerson https://www.w3.org/TR/activitystreams-vocabulary/#dfn-person ActorService = "Service" // ActivityStreamsService https://www.w3.org/TR/activitystreams-vocabulary/#dfn-service - ObjectArticle = "Article" // ActivityStreamsArticle https://www.w3.org/TR/activitystreams-vocabulary/#dfn-article - ObjectAudio = "Audio" // ActivityStreamsAudio https://www.w3.org/TR/activitystreams-vocabulary/#dfn-audio - ObjectDocument = "Document" // ActivityStreamsDocument https://www.w3.org/TR/activitystreams-vocabulary/#dfn-document - ObjectEvent = "Event" // ActivityStreamsEvent https://www.w3.org/TR/activitystreams-vocabulary/#dfn-event - ObjectImage = "Image" // ActivityStreamsImage https://www.w3.org/TR/activitystreams-vocabulary/#dfn-image - ObjectNote = "Note" // ActivityStreamsNote https://www.w3.org/TR/activitystreams-vocabulary/#dfn-note - ObjectPage = "Page" // ActivityStreamsPage https://www.w3.org/TR/activitystreams-vocabulary/#dfn-page - ObjectPlace = "Place" // ActivityStreamsPlace https://www.w3.org/TR/activitystreams-vocabulary/#dfn-place - ObjectProfile = "Profile" // ActivityStreamsProfile https://www.w3.org/TR/activitystreams-vocabulary/#dfn-profile - ObjectRelationship = "Relationship" // ActivityStreamsRelationship https://www.w3.org/TR/activitystreams-vocabulary/#dfn-relationship - ObjectTombstone = "Tombstone" // ActivityStreamsTombstone https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tombstone - ObjectVideo = "Video" // ActivityStreamsVideo https://www.w3.org/TR/activitystreams-vocabulary/#dfn-video - ObjectCollection = "Collection" // ActivityStreamsCollection https://www.w3.org/TR/activitystreams-vocabulary/#dfn-collection - ObjectCollectionPage = "CollectionPage" // ActivityStreamsCollectionPage https://www.w3.org/TR/activitystreams-vocabulary/#dfn-collectionpage + ObjectArticle = "Article" // ActivityStreamsArticle https://www.w3.org/TR/activitystreams-vocabulary/#dfn-article + ObjectAudio = "Audio" // ActivityStreamsAudio https://www.w3.org/TR/activitystreams-vocabulary/#dfn-audio + ObjectDocument = "Document" // ActivityStreamsDocument https://www.w3.org/TR/activitystreams-vocabulary/#dfn-document + ObjectEvent = "Event" // ActivityStreamsEvent https://www.w3.org/TR/activitystreams-vocabulary/#dfn-event + ObjectImage = "Image" // ActivityStreamsImage https://www.w3.org/TR/activitystreams-vocabulary/#dfn-image + ObjectNote = "Note" // ActivityStreamsNote https://www.w3.org/TR/activitystreams-vocabulary/#dfn-note + ObjectPage = "Page" // ActivityStreamsPage https://www.w3.org/TR/activitystreams-vocabulary/#dfn-page + ObjectPlace = "Place" // ActivityStreamsPlace https://www.w3.org/TR/activitystreams-vocabulary/#dfn-place + ObjectProfile = "Profile" // ActivityStreamsProfile https://www.w3.org/TR/activitystreams-vocabulary/#dfn-profile + ObjectRelationship = "Relationship" // ActivityStreamsRelationship https://www.w3.org/TR/activitystreams-vocabulary/#dfn-relationship + ObjectTombstone = "Tombstone" // ActivityStreamsTombstone https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tombstone + ObjectVideo = "Video" // ActivityStreamsVideo https://www.w3.org/TR/activitystreams-vocabulary/#dfn-video + ObjectCollection = "Collection" // ActivityStreamsCollection https://www.w3.org/TR/activitystreams-vocabulary/#dfn-collection + ObjectCollectionPage = "CollectionPage" // ActivityStreamsCollectionPage https://www.w3.org/TR/activitystreams-vocabulary/#dfn-collectionpage + ObjectOrderedCollection = "OrderedCollection" // ActivityStreamsOrderedCollection https://www.w3.org/TR/activitystreams-vocabulary/#dfn-orderedcollection ) diff --git a/internal/api/activitypub/emoji/emojiget.go b/internal/api/activitypub/emoji/emojiget.go index e66a854c..6f9bd6c8 100644 --- a/internal/api/activitypub/emoji/emojiget.go +++ b/internal/api/activitypub/emoji/emojiget.go @@ -43,7 +43,7 @@ func (m *Module) EmojiGetHandler(c *gin.Context) { return } - resp, errWithCode := m.processor.Fedi().EmojiGet(apiutil.TransferSignatureContext(c), requestedEmojiID, c.Request.URL) + resp, errWithCode := m.processor.Fedi().EmojiGet(apiutil.TransferSignatureContext(c), requestedEmojiID) if errWithCode != nil { apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) return diff --git a/internal/api/activitypub/users/common.go b/internal/api/activitypub/users/common.go index f0e4891d..93d9ba77 100644 --- a/internal/api/activitypub/users/common.go +++ b/internal/api/activitypub/users/common.go @@ -18,12 +18,14 @@ package users -// SwaggerCollection represents an activitypub collection. +// SwaggerCollection represents an ActivityPub Collection. // swagger:model swaggerCollection type SwaggerCollection struct { - // ActivityStreams context. + // ActivityStreams JSON-LD context. + // A string or an array of strings, or more + // complex nested items. // example: https://www.w3.org/ns/activitystreams - Context string `json:"@context"` + Context interface{} `json:"@context"` // ActivityStreams ID. // example: https://example.org/users/some_user/statuses/106717595988259568/replies ID string `json:"id"` @@ -55,3 +57,25 @@ type SwaggerCollectionPage struct { // example: ["https://example.org/users/some_other_user/statuses/086417595981111564", "https://another.example.com/users/another_user/statuses/01FCN8XDV3YG7B4R42QA6YQZ9R"] Items []string `json:"items"` } + +// SwaggerFeaturedCollection represents an ActivityPub OrderedCollection. +// swagger:model swaggerFeaturedCollection +type SwaggerFeaturedCollection struct { + // ActivityStreams JSON-LD context. + // A string or an array of strings, or more + // complex nested items. + // example: https://www.w3.org/ns/activitystreams + Context interface{} `json:"@context"` + // ActivityStreams ID. + // example: https://example.org/users/some_user/collections/featured + ID string `json:"id"` + // ActivityStreams type. + // example: OrderedCollection + Type string `json:"type"` + // List of status URIs. + // example: ['https://example.org/users/some_user/statuses/01GSZ0F7Q8SJKNRF777GJD271R', 'https://example.org/users/some_user/statuses/01GSZ0G012CBQ7TEKX689S3QRE'] + Items []string `json:"items"` + // Number of items in this collection. + // example: 2 + TotalItems int +} diff --git a/internal/api/activitypub/users/featured.go b/internal/api/activitypub/users/featured.go new file mode 100644 index 00000000..89a33a6a --- /dev/null +++ b/internal/api/activitypub/users/featured.go @@ -0,0 +1,97 @@ +/* + GoToSocial + Copyright (C) 2021-2023 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 users + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" +) + +// FeaturedCollectionGETHandler swagger:operation GET /users/{username}/collections/featured s2sFeaturedCollectionGet +// +// Get the featured collection (pinned posts) for a user. +// +// The response will contain an ordered collection of Note URIs in the `items` property. +// +// It is up to the caller to dereference the provided Note URIs (or not, if they already have them cached). +// +// HTTP signature is required on the request. +// +// --- +// tags: +// - s2s/federation +// +// produces: +// - application/activity+json +// +// responses: +// '200': +// in: body +// schema: +// "$ref": "#/definitions/swaggerFeaturedCollection" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '404': +// description: not found +func (m *Module) FeaturedCollectionGETHandler(c *gin.Context) { + // usernames on our instance are always lowercase + requestedUsername := strings.ToLower(c.Param(UsernameKey)) + if requestedUsername == "" { + err := errors.New("no username specified in request") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) + return + } + + format, err := apiutil.NegotiateAccept(c, apiutil.HTMLOrActivityPubHeaders...) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if format == string(apiutil.TextHTML) { + // This isn't an ActivityPub request; + // redirect to the user's profile. + c.Redirect(http.StatusSeeOther, "/@"+requestedUsername) + return + } + + resp, errWithCode := m.processor.Fedi().FeaturedCollectionGet(apiutil.TransferSignatureContext(c), requestedUsername) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + b, err := json.Marshal(resp) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGetV1) + return + } + + c.Data(http.StatusOK, format, b) +} diff --git a/internal/api/activitypub/users/followers.go b/internal/api/activitypub/users/followers.go index 649e20e4..4eec8abc 100644 --- a/internal/api/activitypub/users/followers.go +++ b/internal/api/activitypub/users/followers.go @@ -46,12 +46,13 @@ func (m *Module) FollowersGETHandler(c *gin.Context) { } if format == string(apiutil.TextHTML) { - // redirect to the user's profile + // This isn't an ActivityPub request; + // redirect to the user's profile. c.Redirect(http.StatusSeeOther, "/@"+requestedUsername) return } - resp, errWithCode := m.processor.Fedi().FollowersGet(apiutil.TransferSignatureContext(c), requestedUsername, c.Request.URL) + resp, errWithCode := m.processor.Fedi().FollowersGet(apiutil.TransferSignatureContext(c), requestedUsername) if errWithCode != nil { apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) return diff --git a/internal/api/activitypub/users/following.go b/internal/api/activitypub/users/following.go index 1a6e99a5..b26226c8 100644 --- a/internal/api/activitypub/users/following.go +++ b/internal/api/activitypub/users/following.go @@ -46,12 +46,13 @@ func (m *Module) FollowingGETHandler(c *gin.Context) { } if format == string(apiutil.TextHTML) { - // redirect to the user's profile + // This isn't an ActivityPub request; + // redirect to the user's profile. c.Redirect(http.StatusSeeOther, "/@"+requestedUsername) return } - resp, errWithCode := m.processor.Fedi().FollowingGet(apiutil.TransferSignatureContext(c), requestedUsername, c.Request.URL) + resp, errWithCode := m.processor.Fedi().FollowingGet(apiutil.TransferSignatureContext(c), requestedUsername) if errWithCode != nil { apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) return diff --git a/internal/api/activitypub/users/outboxget.go b/internal/api/activitypub/users/outboxget.go index c081e4f9..fa03cde6 100644 --- a/internal/api/activitypub/users/outboxget.go +++ b/internal/api/activitypub/users/outboxget.go @@ -101,7 +101,8 @@ func (m *Module) OutboxGETHandler(c *gin.Context) { } if format == string(apiutil.TextHTML) { - // redirect to the user's profile + // This isn't an ActivityPub request; + // redirect to the user's profile. c.Redirect(http.StatusSeeOther, "/@"+requestedUsername) return } @@ -129,7 +130,7 @@ func (m *Module) OutboxGETHandler(c *gin.Context) { maxID = maxIDString } - resp, errWithCode := m.processor.Fedi().OutboxGet(apiutil.TransferSignatureContext(c), requestedUsername, page, maxID, minID, c.Request.URL) + resp, errWithCode := m.processor.Fedi().OutboxGet(apiutil.TransferSignatureContext(c), requestedUsername, page, maxID, minID) if errWithCode != nil { apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) return diff --git a/internal/api/activitypub/users/repliesget.go b/internal/api/activitypub/users/repliesget.go index 2c17a99d..644bc0de 100644 --- a/internal/api/activitypub/users/repliesget.go +++ b/internal/api/activitypub/users/repliesget.go @@ -150,7 +150,7 @@ func (m *Module) StatusRepliesGETHandler(c *gin.Context) { minID = minIDString } - resp, errWithCode := m.processor.Fedi().StatusRepliesGet(apiutil.TransferSignatureContext(c), requestedUsername, requestedStatusID, page, onlyOtherAccounts, minID, c.Request.URL) + resp, errWithCode := m.processor.Fedi().StatusRepliesGet(apiutil.TransferSignatureContext(c), requestedUsername, requestedStatusID, page, onlyOtherAccounts, c.Query("only_other_accounts") != "", minID) if errWithCode != nil { apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) return diff --git a/internal/api/activitypub/users/statusget.go b/internal/api/activitypub/users/statusget.go index 69d873ef..a57b50d0 100644 --- a/internal/api/activitypub/users/statusget.go +++ b/internal/api/activitypub/users/statusget.go @@ -59,7 +59,7 @@ func (m *Module) StatusGETHandler(c *gin.Context) { return } - resp, errWithCode := m.processor.Fedi().StatusGet(apiutil.TransferSignatureContext(c), requestedUsername, requestedStatusID, c.Request.URL) + resp, errWithCode := m.processor.Fedi().StatusGet(apiutil.TransferSignatureContext(c), requestedUsername, requestedStatusID) if errWithCode != nil { apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) return diff --git a/internal/api/activitypub/users/user.go b/internal/api/activitypub/users/user.go index b3101786..c614435b 100644 --- a/internal/api/activitypub/users/user.go +++ b/internal/api/activitypub/users/user.go @@ -50,6 +50,8 @@ const ( FollowersPath = BasePath + "/" + uris.FollowersPath // FollowingPath is for serving GET request's to a user's following list, with the given username key. FollowingPath = BasePath + "/" + uris.FollowingPath + // FeaturedCollectionPath is for serving GET requests to a user's list of featured (pinned) statuses. + FeaturedCollectionPath = BasePath + "/" + uris.CollectionsPath + "/" + uris.FeaturedPath // StatusPath is for serving GET requests to a particular status by a user, with the given username key and status ID StatusPath = BasePath + "/" + uris.StatusesPath + "/:" + StatusIDKey // StatusRepliesPath is for serving the replies collection of a status. @@ -71,6 +73,7 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H attachHandler(http.MethodPost, InboxPath, m.InboxPOSTHandler) attachHandler(http.MethodGet, FollowersPath, m.FollowersGETHandler) attachHandler(http.MethodGet, FollowingPath, m.FollowingGETHandler) + attachHandler(http.MethodGet, FeaturedCollectionPath, m.FeaturedCollectionGETHandler) attachHandler(http.MethodGet, StatusPath, m.StatusGETHandler) attachHandler(http.MethodGet, StatusRepliesPath, m.StatusRepliesGETHandler) attachHandler(http.MethodGet, OutboxPath, m.OutboxGETHandler) diff --git a/internal/api/client/statuses/statuspin_test.go b/internal/api/client/statuses/statuspin_test.go index 69cf34ef..6c008770 100644 --- a/internal/api/client/statuses/statuspin_test.go +++ b/internal/api/client/statuses/statuspin_test.go @@ -129,7 +129,7 @@ func (suite *StatusPinTestSuite) TestPinStatusTwiceError() { *targetStatus = *suite.testStatuses["local_account_1_status_5"] targetStatus.PinnedAt = time.Now() - if err := suite.db.UpdateStatus(context.Background(), targetStatus); err != nil { + if err := suite.db.UpdateStatus(context.Background(), targetStatus, "pinned_at"); err != nil { suite.FailNow(err.Error()) } diff --git a/internal/db/bundb/admin.go b/internal/db/bundb/admin.go index a4bc46a7..6b738261 100644 --- a/internal/db/bundb/admin.go +++ b/internal/db/bundb/admin.go @@ -135,7 +135,7 @@ func (a *adminDB) NewSignup(ctx context.Context, username string, reason string, OutboxURI: accountURIs.OutboxURI, FollowersURI: accountURIs.FollowersURI, FollowingURI: accountURIs.FollowingURI, - FeaturedCollectionURI: accountURIs.CollectionURI, + FeaturedCollectionURI: accountURIs.FeaturedCollectionURI, } // insert the new account! @@ -237,7 +237,7 @@ func (a *adminDB) CreateInstanceAccount(ctx context.Context) db.Error { OutboxURI: newAccountURIs.OutboxURI, FollowersURI: newAccountURIs.FollowersURI, FollowingURI: newAccountURIs.FollowingURI, - FeaturedCollectionURI: newAccountURIs.CollectionURI, + FeaturedCollectionURI: newAccountURIs.FeaturedCollectionURI, } // insert the new account! diff --git a/internal/db/bundb/status.go b/internal/db/bundb/status.go index 2bec0775..8f1df288 100644 --- a/internal/db/bundb/status.go +++ b/internal/db/bundb/status.go @@ -246,7 +246,13 @@ func (s *statusDB) PutStatus(ctx context.Context, status *gtsmodel.Status) db.Er }) } -func (s *statusDB) UpdateStatus(ctx context.Context, status *gtsmodel.Status) db.Error { +func (s *statusDB) UpdateStatus(ctx context.Context, status *gtsmodel.Status, columns ...string) db.Error { + status.UpdatedAt = time.Now() + if len(columns) > 0 { + // If we're updating by column, ensure "updated_at" is included. + columns = append(columns, "updated_at") + } + if err := s.conn.RunInTx(ctx, func(tx bun.Tx) error { // create links between this status and any emojis it uses for _, i := range status.EmojiIDs { @@ -298,6 +304,7 @@ func (s *statusDB) UpdateStatus(ctx context.Context, status *gtsmodel.Status) db _, err := tx. NewUpdate(). Model(status). + Column(columns...). Where("? = ?", bun.Ident("status.id"), status.ID). Exec(ctx) return err diff --git a/internal/db/status.go b/internal/db/status.go index 15d1362f..94f6ff0e 100644 --- a/internal/db/status.go +++ b/internal/db/status.go @@ -41,8 +41,8 @@ type Status interface { // PutStatus stores one status in the database. PutStatus(ctx context.Context, status *gtsmodel.Status) Error - // UpdateStatus updates one status in the database and returns it to the caller. - UpdateStatus(ctx context.Context, status *gtsmodel.Status) Error + // UpdateStatus updates one status in the database. + UpdateStatus(ctx context.Context, status *gtsmodel.Status, columns ...string) Error // DeleteStatusByID deletes one status from the database. DeleteStatusByID(ctx context.Context, id string) Error diff --git a/internal/federation/dereferencing/account.go b/internal/federation/dereferencing/account.go index 93e0e354..041f34a2 100644 --- a/internal/federation/dereferencing/account.go +++ b/internal/federation/dereferencing/account.go @@ -281,8 +281,7 @@ func (d *deref) enrichAccount(ctx context.Context, requestUser string, uri *url. } // Fetch the latest remote account emoji IDs used in account display name/bio. - _, err = d.fetchRemoteAccountEmojis(ctx, latestAcc, requestUser) - if err != nil { + if _, err = d.fetchRemoteAccountEmojis(ctx, latestAcc, requestUser); err != nil { log.Errorf(ctx, "error fetching remote emojis for account %s: %v", uri, err) } @@ -312,6 +311,18 @@ func (d *deref) enrichAccount(ctx context.Context, requestUser string, uri *url. } } + if latestAcc.FeaturedCollectionURI != "" { + // Fetch this account's pinned statuses, now that the account is in the database. + // + // The order is important here: if we tried to fetch the pinned statuses before + // storing the account, the process might end up calling enrichAccount again, + // causing us to get stuck in a loop. By calling it now, we make sure this doesn't + // happen! + if err := d.fetchRemoteAccountFeatured(ctx, requestUser, latestAcc.FeaturedCollectionURI, latestAcc.ID); err != nil { + log.Errorf(ctx, "error fetching featured collection for account %s: %v", uri, err) + } + } + return latestAcc, nil } @@ -569,3 +580,148 @@ func (d *deref) fetchRemoteAccountEmojis(ctx context.Context, targetAccount *gts return changed, nil } + +// fetchRemoteAccountFeatured dereferences an account's featuredCollectionURI (if not empty). +// For each discovered status, this status will be dereferenced (if necessary) and marked as +// pinned (if necessary). Then, old pins will be removed if they're not included in new pins. +func (d *deref) fetchRemoteAccountFeatured(ctx context.Context, requestingUsername string, featuredCollectionURI string, accountID string) error { + uri, err := url.Parse(featuredCollectionURI) + if err != nil { + return err + } + + tsport, err := d.transportController.NewTransportForUsername(ctx, requestingUsername) + if err != nil { + return err + } + + b, err := tsport.Dereference(ctx, uri) + if err != nil { + return err + } + + m := make(map[string]interface{}) + if err := json.Unmarshal(b, &m); err != nil { + return fmt.Errorf("error unmarshalling bytes into json: %w", err) + } + + t, err := streams.ToType(ctx, m) + if err != nil { + return fmt.Errorf("error resolving json into ap vocab type: %w", err) + } + + if t.GetTypeName() != ap.ObjectOrderedCollection { + return fmt.Errorf("%s was not an OrderedCollection", featuredCollectionURI) + } + + collection, ok := t.(vocab.ActivityStreamsOrderedCollection) + if !ok { + return errors.New("couldn't coerce OrderedCollection") + } + + items := collection.GetActivityStreamsOrderedItems() + if items == nil { + return errors.New("nil orderedItems") + } + + // Get previous pinned statuses (we'll need these later). + wasPinned, err := d.db.GetAccountPinnedStatuses(ctx, accountID) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return fmt.Errorf("error getting account pinned statuses: %w", err) + } + + statusURIs := make([]*url.URL, 0, items.Len()) + for iter := items.Begin(); iter != items.End(); iter = iter.Next() { + var statusURI *url.URL + + switch { + case iter.IsActivityStreamsNote(): + // We got a whole Note. Extract the URI. + if note := iter.GetActivityStreamsNote(); note != nil { + if id := note.GetJSONLDId(); id != nil { + statusURI = id.GetIRI() + } + } + case iter.IsActivityStreamsArticle(): + // We got a whole Article. Extract the URI. + if article := iter.GetActivityStreamsArticle(); article != nil { + if id := article.GetJSONLDId(); id != nil { + statusURI = id.GetIRI() + } + } + default: + // Try to get just the URI. + statusURI = iter.GetIRI() + } + + if statusURI == nil { + continue + } + + if statusURI.Host != uri.Host { + // If this status doesn't share a host with its featured + // collection URI, we shouldn't trust it. Just move on. + continue + } + + // Already append this status URI to our slice. + // We do this here so that even if we can't get + // the status in the next part for some reason, + // we still know it was *meant* to be pinned. + statusURIs = append(statusURIs, statusURI) + + status, _, err := d.GetStatus(ctx, requestingUsername, statusURI, false, false) + if err != nil { + // We couldn't get the status, bummer. + // Just log + move on, we can try later. + log.Errorf(ctx, "error getting status from featured collection %s: %s", featuredCollectionURI, err) + continue + } + + // If the status was already pinned, we don't need to do anything. + if !status.PinnedAt.IsZero() { + continue + } + + if status.AccountID != accountID { + // Someone's pinned a status that doesn't + // belong to them, this doesn't work for us. + continue + } + + if status.BoostOfID != "" { + // Someone's pinned a boost. This also + // doesn't work for us. + continue + } + + // All conditions are met for this status to + // be pinned, so we can finally update it. + status.PinnedAt = time.Now() + if err := d.db.UpdateStatus(ctx, status, "pinned_at"); err != nil { + log.Errorf(ctx, "error updating status in featured collection %s: %s", featuredCollectionURI, err) + } + } + + // Now that we know which statuses are pinned, we should + // *unpin* previous pinned statuses that aren't included. +outerLoop: + for _, status := range wasPinned { + for _, statusURI := range statusURIs { + if status.URI == statusURI.String() { + // This status is included in most recent + // pinned uris. No need to keep checking. + continue outerLoop + } + } + + // Status was pinned before, but is not included + // in most recent pinned uris, so unpin it now. + status.PinnedAt = time.Time{} + if err := d.db.UpdateStatus(ctx, status, "pinned_at"); err != nil { + return fmt.Errorf("error unpinning status: %w", err) + } + } + + return nil +} diff --git a/internal/federation/dereferencing/status.go b/internal/federation/dereferencing/status.go index 56545c5e..9242f8db 100644 --- a/internal/federation/dereferencing/status.go +++ b/internal/federation/dereferencing/status.go @@ -35,6 +35,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/transport" ) // EnrichRemoteStatus takes a remote status that's already been inserted into the database in a minimal form, @@ -105,7 +106,12 @@ func (d *deref) GetStatus(ctx context.Context, username string, statusURI *url.U // if we got here, either we didn't have the status // in the db, or we had it but need to refetch it - statusable, derefErr := d.dereferenceStatusable(ctx, username, statusURI) + tsport, err := d.transportController.NewTransportForUsername(ctx, username) + if err != nil { + return nil, nil, newErrTransportError(fmt.Errorf("GetRemoteStatus: error creating transport for %s: %w", username, err)) + } + + statusable, derefErr := d.dereferenceStatusable(ctx, tsport, statusURI) if derefErr != nil { return nil, nil, wrapDerefError(derefErr, "GetRemoteStatus: error dereferencing statusable") } @@ -149,17 +155,12 @@ func (d *deref) GetStatus(ctx context.Context, username string, statusURI *url.U return status, statusable, nil } -func (d *deref) dereferenceStatusable(ctx context.Context, username string, remoteStatusID *url.URL) (ap.Statusable, error) { +func (d *deref) dereferenceStatusable(ctx context.Context, tsport transport.Transport, remoteStatusID *url.URL) (ap.Statusable, error) { if blocked, err := d.db.IsDomainBlocked(ctx, remoteStatusID.Host); blocked || err != nil { return nil, fmt.Errorf("DereferenceStatusable: domain %s is blocked", remoteStatusID.Host) } - transport, err := d.transportController.NewTransportForUsername(ctx, username) - if err != nil { - return nil, fmt.Errorf("DereferenceStatusable: transport err: %s", err) - } - - b, err := transport.Dereference(ctx, remoteStatusID) + b, err := tsport.Dereference(ctx, remoteStatusID) if err != nil { return nil, fmt.Errorf("DereferenceStatusable: error deferencing %s: %s", remoteStatusID.String(), err) } diff --git a/internal/processing/fedi/collections.go b/internal/processing/fedi/collections.go index 33d1b64e..78a65beb 100644 --- a/internal/processing/fedi/collections.go +++ b/internal/processing/fedi/collections.go @@ -20,6 +20,7 @@ package fedi import ( "context" + "errors" "fmt" "net/http" "net/url" @@ -27,141 +28,32 @@ import ( "github.com/superseriousbusiness/activity/streams" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/transport" ) -// FollowersGet handles the getting of a fedi/activitypub representation of a user/account's followers, performing appropriate -// authentication before returning a JSON serializable interface to the caller. -func (p *Processor) FollowersGet(ctx context.Context, requestedUsername string, requestURL *url.URL) (interface{}, gtserror.WithCode) { - // get the account the request is referring to - requestedAccount, err := p.db.GetAccountByUsernameDomain(ctx, requestedUsername, "") - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err)) - } - - // authenticate the request - requestingAccountURI, errWithCode := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername) - if errWithCode != nil { - return nil, errWithCode - } - - requestingAccount, err := p.federator.GetAccountByURI( - transport.WithFastfail(ctx), requestedUsername, requestingAccountURI, false, - ) - if err != nil { - return nil, gtserror.NewErrorUnauthorized(err) - } - - blocked, err := p.db.IsBlocked(ctx, requestedAccount.ID, requestingAccount.ID, true) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - - if blocked { - return nil, gtserror.NewErrorUnauthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID)) - } - - requestedAccountURI, err := url.Parse(requestedAccount.URI) - if err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("error parsing url %s: %s", requestedAccount.URI, err)) - } - - requestedFollowers, err := p.federator.FederatingDB().Followers(ctx, requestedAccountURI) - if err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("error fetching followers for uri %s: %s", requestedAccountURI.String(), err)) - } - - data, err := streams.Serialize(requestedFollowers) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - - return data, nil -} - -// FollowingGet handles the getting of a fedi/activitypub representation of a user/account's following, performing appropriate -// authentication before returning a JSON serializable interface to the caller. -func (p *Processor) FollowingGet(ctx context.Context, requestedUsername string, requestURL *url.URL) (interface{}, gtserror.WithCode) { - // get the account the request is referring to - requestedAccount, err := p.db.GetAccountByUsernameDomain(ctx, requestedUsername, "") - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err)) - } - - // authenticate the request - requestingAccountURI, errWithCode := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername) - if errWithCode != nil { - return nil, errWithCode - } - - requestingAccount, err := p.federator.GetAccountByURI( - transport.WithFastfail(ctx), requestedUsername, requestingAccountURI, false, - ) - if err != nil { - return nil, gtserror.NewErrorUnauthorized(err) - } - - blocked, err := p.db.IsBlocked(ctx, requestedAccount.ID, requestingAccount.ID, true) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - - if blocked { - return nil, gtserror.NewErrorUnauthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID)) - } - - requestedAccountURI, err := url.Parse(requestedAccount.URI) - if err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("error parsing url %s: %s", requestedAccount.URI, err)) - } - - requestedFollowing, err := p.federator.FederatingDB().Following(ctx, requestedAccountURI) - if err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("error fetching following for uri %s: %s", requestedAccountURI.String(), err)) - } - - data, err := streams.Serialize(requestedFollowing) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - - return data, nil +// InboxPost handles POST requests to a user's inbox for new activitypub messages. +// +// InboxPost returns true if the request was handled as an ActivityPub POST to an actor's inbox. +// If false, the request was not an ActivityPub request and may still be handled by the caller in another way, such as serving a web page. +// +// If the error is nil, then the ResponseWriter's headers and response has already been written. If a non-nil error is returned, then no response has been written. +// +// If the Actor was constructed with the Federated Protocol enabled, side effects will occur. +// +// If the Federated Protocol is not enabled, writes the http.StatusMethodNotAllowed status code in the response. No side effects occur. +func (p *Processor) InboxPost(ctx context.Context, w http.ResponseWriter, r *http.Request) (bool, error) { + return p.federator.FederatingActor().PostInbox(ctx, w, r) } // OutboxGet returns the activitypub representation of a local user's outbox. // This contains links to PUBLIC posts made by this user. -func (p *Processor) OutboxGet(ctx context.Context, requestedUsername string, page bool, maxID string, minID string, requestURL *url.URL) (interface{}, gtserror.WithCode) { - // get the account the request is referring to - requestedAccount, err := p.db.GetAccountByUsernameDomain(ctx, requestedUsername, "") - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err)) - } - - // authenticate the request - requestingAccountURI, errWithCode := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername) +func (p *Processor) OutboxGet(ctx context.Context, requestedUsername string, page bool, maxID string, minID string) (interface{}, gtserror.WithCode) { + requestedAccount, _, errWithCode := p.authenticate(ctx, requestedUsername) if errWithCode != nil { return nil, errWithCode } - requestingAccount, err := p.federator.GetAccountByURI( - transport.WithFastfail(ctx), requestedUsername, requestingAccountURI, false, - ) - if err != nil { - return nil, gtserror.NewErrorUnauthorized(err) - } - - // authorize the request: - // 1. check if a block exists between the requester and the requestee - blocked, err := p.db.IsBlocked(ctx, requestedAccount.ID, requestingAccount.ID, true) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - if blocked { - return nil, gtserror.NewErrorUnauthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID)) - } - var data map[string]interface{} - // now there are two scenarios: + // There are two scenarios: // 1. we're asked for the whole collection and not a page -- we can just return the collection, with no items, but a link to 'first' page. // 2. we're asked for a specific page; this can be either the first page or any other page @@ -209,16 +101,82 @@ func (p *Processor) OutboxGet(ctx context.Context, requestedUsername string, pag return data, nil } -// InboxPost handles POST requests to a user's inbox for new activitypub messages. -// -// InboxPost returns true if the request was handled as an ActivityPub POST to an actor's inbox. -// If false, the request was not an ActivityPub request and may still be handled by the caller in another way, such as serving a web page. -// -// If the error is nil, then the ResponseWriter's headers and response has already been written. If a non-nil error is returned, then no response has been written. -// -// If the Actor was constructed with the Federated Protocol enabled, side effects will occur. -// -// If the Federated Protocol is not enabled, writes the http.StatusMethodNotAllowed status code in the response. No side effects occur. -func (p *Processor) InboxPost(ctx context.Context, w http.ResponseWriter, r *http.Request) (bool, error) { - return p.federator.FederatingActor().PostInbox(ctx, w, r) +// FollowersGet handles the getting of a fedi/activitypub representation of a user/account's followers, performing appropriate +// authentication before returning a JSON serializable interface to the caller. +func (p *Processor) FollowersGet(ctx context.Context, requestedUsername string) (interface{}, gtserror.WithCode) { + requestedAccount, _, errWithCode := p.authenticate(ctx, requestedUsername) + if errWithCode != nil { + return nil, errWithCode + } + + requestedAccountURI, err := url.Parse(requestedAccount.URI) + if err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error parsing url %s: %s", requestedAccount.URI, err)) + } + + requestedFollowers, err := p.federator.FederatingDB().Followers(ctx, requestedAccountURI) + if err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error fetching followers for uri %s: %s", requestedAccountURI.String(), err)) + } + + data, err := streams.Serialize(requestedFollowers) + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + + return data, nil +} + +// FollowingGet handles the getting of a fedi/activitypub representation of a user/account's following, performing appropriate +// authentication before returning a JSON serializable interface to the caller. +func (p *Processor) FollowingGet(ctx context.Context, requestedUsername string) (interface{}, gtserror.WithCode) { + requestedAccount, _, errWithCode := p.authenticate(ctx, requestedUsername) + if errWithCode != nil { + return nil, errWithCode + } + + requestedAccountURI, err := url.Parse(requestedAccount.URI) + if err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error parsing url %s: %s", requestedAccount.URI, err)) + } + + requestedFollowing, err := p.federator.FederatingDB().Following(ctx, requestedAccountURI) + if err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error fetching following for uri %s: %s", requestedAccountURI.String(), err)) + } + + data, err := streams.Serialize(requestedFollowing) + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + + return data, nil +} + +// FeaturedCollectionGet returns an ordered collection of the requested username's Pinned posts. +// The returned collection have an `items` property which contains an ordered list of status URIs. +func (p *Processor) FeaturedCollectionGet(ctx context.Context, requestedUsername string) (interface{}, gtserror.WithCode) { + requestedAccount, _, errWithCode := p.authenticate(ctx, requestedUsername) + if errWithCode != nil { + return nil, errWithCode + } + + statuses, err := p.db.GetAccountPinnedStatuses(ctx, requestedAccount.ID) + if err != nil { + if !errors.Is(err, db.ErrNoEntries) { + return nil, gtserror.NewErrorInternalError(err) + } + } + + collection, err := p.tc.StatusesToASFeaturedCollection(ctx, requestedAccount.FeaturedCollectionURI, statuses) + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + + data, err := streams.Serialize(collection) + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + + return data, nil } diff --git a/internal/processing/fedi/common.go b/internal/processing/fedi/common.go new file mode 100644 index 00000000..37c604de --- /dev/null +++ b/internal/processing/fedi/common.go @@ -0,0 +1,60 @@ +/* + GoToSocial + Copyright (C) 2021-2023 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 fedi + +import ( + "context" + "fmt" + "net/url" + + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/transport" +) + +func (p *Processor) authenticate(ctx context.Context, requestedUsername string) (requestedAccount, requestingAccount *gtsmodel.Account, errWithCode gtserror.WithCode) { + requestedAccount, err := p.db.GetAccountByUsernameDomain(ctx, requestedUsername, "") + if err != nil { + errWithCode = gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err)) + return + } + + var requestingAccountURI *url.URL + requestingAccountURI, errWithCode = p.federator.AuthenticateFederatedRequest(ctx, requestedUsername) + if errWithCode != nil { + return + } + + if requestingAccount, err = p.federator.GetAccountByURI(transport.WithFastfail(ctx), requestedUsername, requestingAccountURI, false); err != nil { + errWithCode = gtserror.NewErrorUnauthorized(err) + return + } + + blocked, err := p.db.IsBlocked(ctx, requestedAccount.ID, requestingAccount.ID, true) + if err != nil { + errWithCode = gtserror.NewErrorInternalError(err) + return + } + + if blocked { + errWithCode = gtserror.NewErrorUnauthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID)) + } + + return +} diff --git a/internal/processing/fedi/emoji.go b/internal/processing/fedi/emoji.go index a2eb2688..0b1dd344 100644 --- a/internal/processing/fedi/emoji.go +++ b/internal/processing/fedi/emoji.go @@ -21,14 +21,13 @@ package fedi import ( "context" "fmt" - "net/url" "github.com/superseriousbusiness/activity/streams" "github.com/superseriousbusiness/gotosocial/internal/gtserror" ) // EmojiGet handles the GET for a federated emoji originating from this instance. -func (p *Processor) EmojiGet(ctx context.Context, requestedEmojiID string, requestURL *url.URL) (interface{}, gtserror.WithCode) { +func (p *Processor) EmojiGet(ctx context.Context, requestedEmojiID string) (interface{}, gtserror.WithCode) { if _, errWithCode := p.federator.AuthenticateFederatedRequest(ctx, ""); errWithCode != nil { return nil, errWithCode } diff --git a/internal/processing/fedi/status.go b/internal/processing/fedi/status.go index 0e4c99b6..fbadcb29 100644 --- a/internal/processing/fedi/status.go +++ b/internal/processing/fedi/status.go @@ -24,65 +24,36 @@ import ( "net/url" "github.com/superseriousbusiness/activity/streams" - "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/transport" ) // StatusGet handles the getting of a fedi/activitypub representation of a particular status, performing appropriate // authentication before returning a JSON serializable interface to the caller. -func (p *Processor) StatusGet(ctx context.Context, requestedUsername string, requestedStatusID string, requestURL *url.URL) (interface{}, gtserror.WithCode) { - // get the account the request is referring to - requestedAccount, err := p.db.GetAccountByUsernameDomain(ctx, requestedUsername, "") - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err)) - } - - // authenticate the request - requestingAccountURI, errWithCode := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername) +func (p *Processor) StatusGet(ctx context.Context, requestedUsername string, requestedStatusID string) (interface{}, gtserror.WithCode) { + requestedAccount, requestingAccount, errWithCode := p.authenticate(ctx, requestedUsername) if errWithCode != nil { return nil, errWithCode } - requestingAccount, err := p.federator.GetAccountByURI( - transport.WithFastfail(ctx), requestedUsername, requestingAccountURI, false, - ) + status, err := p.db.GetStatusByID(ctx, requestedStatusID) if err != nil { - return nil, gtserror.NewErrorUnauthorized(err) + return nil, gtserror.NewErrorNotFound(err) } - // authorize the request: - // 1. check if a block exists between the requester and the requestee - blocked, err := p.db.IsBlocked(ctx, requestedAccount.ID, requestingAccount.ID, true) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) + if status.AccountID != requestedAccount.ID { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("status with id %s does not belong to account with id %s", status.ID, requestedAccount.ID)) } - if blocked { - return nil, gtserror.NewErrorUnauthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID)) - } - - // get the status out of the database here - s, err := p.db.GetStatusByID(ctx, requestedStatusID) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting status with id %s and account id %s: %s", requestedStatusID, requestedAccount.ID, err)) - } - - if s.AccountID != requestedAccount.ID { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("status with id %s does not belong to account with id %s", s.ID, requestedAccount.ID)) - } - - visible, err := p.filter.StatusVisible(ctx, s, requestingAccount) + visible, err := p.filter.StatusVisible(ctx, status, requestingAccount) if err != nil { return nil, gtserror.NewErrorInternalError(err) } if !visible { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("status with id %s not visible to user with id %s", s.ID, requestingAccount.ID)) + return nil, gtserror.NewErrorNotFound(fmt.Errorf("status with id %s not visible to user with id %s", status.ID, requestingAccount.ID)) } - // requester is authorized to view the status, so convert it to AP representation and serialize it - asStatus, err := p.tc.StatusToAS(ctx, s) + asStatus, err := p.tc.StatusToAS(ctx, status) if err != nil { return nil, gtserror.NewErrorInternalError(err) } @@ -97,52 +68,27 @@ func (p *Processor) StatusGet(ctx context.Context, requestedUsername string, req // GetStatus handles the getting of a fedi/activitypub representation of replies to a status, performing appropriate // authentication before returning a JSON serializable interface to the caller. -func (p *Processor) StatusRepliesGet(ctx context.Context, requestedUsername string, requestedStatusID string, page bool, onlyOtherAccounts bool, minID string, requestURL *url.URL) (interface{}, gtserror.WithCode) { - // get the account the request is referring to - requestedAccount, err := p.db.GetAccountByUsernameDomain(ctx, requestedUsername, "") - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err)) - } - - // authenticate the request - requestingAccountURI, errWithCode := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername) +func (p *Processor) StatusRepliesGet(ctx context.Context, requestedUsername string, requestedStatusID string, page bool, onlyOtherAccounts bool, onlyOtherAccountsSet bool, minID string) (interface{}, gtserror.WithCode) { + requestedAccount, requestingAccount, errWithCode := p.authenticate(ctx, requestedUsername) if errWithCode != nil { return nil, errWithCode } - requestingAccount, err := p.federator.GetAccountByURI( - transport.WithFastfail(ctx), requestedUsername, requestingAccountURI, false, - ) + status, err := p.db.GetStatusByID(ctx, requestedStatusID) if err != nil { - return nil, gtserror.NewErrorUnauthorized(err) + return nil, gtserror.NewErrorNotFound(err) } - // authorize the request: - // 1. check if a block exists between the requester and the requestee - blocked, err := p.db.IsBlocked(ctx, requestedAccount.ID, requestingAccount.ID, true) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) + if status.AccountID != requestedAccount.ID { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("status with id %s does not belong to account with id %s", status.ID, requestedAccount.ID)) } - if blocked { - return nil, gtserror.NewErrorUnauthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID)) - } - - // get the status out of the database here - s := >smodel.Status{} - if err := p.db.GetWhere(ctx, []db.Where{ - {Key: "id", Value: requestedStatusID}, - {Key: "account_id", Value: requestedAccount.ID}, - }, s); err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting status with id %s and account id %s: %s", requestedStatusID, requestedAccount.ID, err)) - } - - visible, err := p.filter.StatusVisible(ctx, s, requestingAccount) + visible, err := p.filter.StatusVisible(ctx, status, requestingAccount) if err != nil { return nil, gtserror.NewErrorInternalError(err) } if !visible { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("status with id %s not visible to user with id %s", s.ID, requestingAccount.ID)) + return nil, gtserror.NewErrorNotFound(fmt.Errorf("status with id %s not visible to user with id %s", status.ID, requestingAccount.ID)) } var data map[string]interface{} @@ -155,7 +101,7 @@ func (p *Processor) StatusRepliesGet(ctx context.Context, requestedUsername stri case !page: // scenario 1 // get the collection - collection, err := p.tc.StatusToASRepliesCollection(ctx, s, onlyOtherAccounts) + collection, err := p.tc.StatusToASRepliesCollection(ctx, status, onlyOtherAccounts) if err != nil { return nil, gtserror.NewErrorInternalError(err) } @@ -164,10 +110,10 @@ func (p *Processor) StatusRepliesGet(ctx context.Context, requestedUsername stri if err != nil { return nil, gtserror.NewErrorInternalError(err) } - case page && requestURL.Query().Get("only_other_accounts") == "": + case page && !onlyOtherAccountsSet: // scenario 2 // get the collection - collection, err := p.tc.StatusToASRepliesCollection(ctx, s, onlyOtherAccounts) + collection, err := p.tc.StatusToASRepliesCollection(ctx, status, onlyOtherAccounts) if err != nil { return nil, gtserror.NewErrorInternalError(err) } @@ -179,7 +125,7 @@ func (p *Processor) StatusRepliesGet(ctx context.Context, requestedUsername stri default: // scenario 3 // get immediate children - replies, err := p.db.GetStatusChildren(ctx, s, true, minID) + replies, err := p.db.GetStatusChildren(ctx, status, true, minID) if err != nil { return nil, gtserror.NewErrorInternalError(err) } @@ -217,7 +163,7 @@ func (p *Processor) StatusRepliesGet(ctx context.Context, requestedUsername stri replyURIs[r.ID] = rURI } - repliesPage, err := p.tc.StatusURIsToASRepliesPage(ctx, s, onlyOtherAccounts, minID, replyURIs) + repliesPage, err := p.tc.StatusURIsToASRepliesPage(ctx, status, onlyOtherAccounts, minID, replyURIs) if err != nil { return nil, gtserror.NewErrorInternalError(err) } diff --git a/internal/processing/status/pin.go b/internal/processing/status/pin.go index addd2515..3e50b0c7 100644 --- a/internal/processing/status/pin.go +++ b/internal/processing/status/pin.go @@ -95,7 +95,7 @@ func (p *Processor) PinCreate(ctx context.Context, requestingAccount *gtsmodel.A } targetStatus.PinnedAt = time.Now() - if err := p.db.UpdateStatus(ctx, targetStatus); err != nil { + if err := p.db.UpdateStatus(ctx, targetStatus, "pinned_at"); err != nil { return nil, gtserror.NewErrorInternalError(fmt.Errorf("db error pinning status: %w", err)) } @@ -126,7 +126,7 @@ func (p *Processor) PinRemove(ctx context.Context, requestingAccount *gtsmodel.A if targetStatus.PinnedAt.IsZero() { targetStatus.PinnedAt = time.Time{} - if err := p.db.UpdateStatus(ctx, targetStatus); err != nil { + if err := p.db.UpdateStatus(ctx, targetStatus, "pinned_at"); err != nil { return nil, gtserror.NewErrorInternalError(fmt.Errorf("db error unpinning status: %w", err)) } } diff --git a/internal/typeutils/astointernal.go b/internal/typeutils/astointernal.go index 11633ad4..4e1b5961 100644 --- a/internal/typeutils/astointernal.go +++ b/internal/typeutils/astointernal.go @@ -181,9 +181,14 @@ func (c *converter) ASRepresentationToAccount(ctx context.Context, accountable a acct.FollowersURI = accountable.GetActivityStreamsFollowers().GetIRI().String() } - // FeaturedURI - if accountable.GetTootFeatured() != nil && accountable.GetTootFeatured().GetIRI() != nil { - acct.FeaturedCollectionURI = accountable.GetTootFeatured().GetIRI().String() + // FeaturedURI aka pinned collection: + // Only trust featured URI if it has at least two domains, + // from the right, in common with the domain of the account + if featured := accountable.GetTootFeatured(); featured != nil && featured.IsIRI() { + if featuredURI := featured.GetIRI(); // nocollapse + featuredURI != nil && dns.CompareDomainName(acct.Domain, featuredURI.Host) >= 2 { + acct.FeaturedCollectionURI = featuredURI.String() + } } // TODO: FeaturedTagsURI diff --git a/internal/typeutils/converter.go b/internal/typeutils/converter.go index c63bd8d8..ec0c1bb8 100644 --- a/internal/typeutils/converter.go +++ b/internal/typeutils/converter.go @@ -178,6 +178,9 @@ type TypeConverter interface { // // Appropriate 'next' and 'prev' fields will be created based on the highest and lowest IDs present in the statuses slice. StatusesToASOutboxPage(ctx context.Context, outboxID string, maxID string, minID string, statuses []*gtsmodel.Status) (vocab.ActivityStreamsOrderedCollectionPage, error) + // StatusesToASFeaturedCollection converts a slice of statuses into an ordered collection + // of URIs, suitable for serializing and serving via the activitypub API. + StatusesToASFeaturedCollection(ctx context.Context, featuredCollectionID string, statuses []*gtsmodel.Status) (vocab.ActivityStreamsOrderedCollection, error) // ReportToASFlag converts a gts model report into an activitystreams FLAG, suitable for federation. ReportToASFlag(ctx context.Context, r *gtsmodel.Report) (vocab.ActivityStreamsFlag, error) diff --git a/internal/typeutils/internaltoas.go b/internal/typeutils/internaltoas.go index 06b49c18..bbcf6c84 100644 --- a/internal/typeutils/internaltoas.go +++ b/internal/typeutils/internaltoas.go @@ -1296,6 +1296,34 @@ func (c *converter) OutboxToASCollection(ctx context.Context, outboxID string) ( return collection, nil } +func (c *converter) StatusesToASFeaturedCollection(ctx context.Context, featuredCollectionID string, statuses []*gtsmodel.Status) (vocab.ActivityStreamsOrderedCollection, error) { + collection := streams.NewActivityStreamsOrderedCollection() + + collectionIDProp := streams.NewJSONLDIdProperty() + featuredCollectionIDURI, err := url.Parse(featuredCollectionID) + if err != nil { + return nil, fmt.Errorf("error parsing url %s", featuredCollectionID) + } + collectionIDProp.SetIRI(featuredCollectionIDURI) + collection.SetJSONLDId(collectionIDProp) + + itemsProp := streams.NewActivityStreamsOrderedItemsProperty() + for _, s := range statuses { + uri, err := url.Parse(s.URI) + if err != nil { + return nil, fmt.Errorf("error parsing url %s", s.URI) + } + itemsProp.AppendIRI(uri) + } + collection.SetActivityStreamsOrderedItems(itemsProp) + + totalItemsProp := streams.NewActivityStreamsTotalItemsProperty() + totalItemsProp.Set(len(statuses)) + collection.SetActivityStreamsTotalItems(totalItemsProp) + + return collection, nil +} + func (c *converter) ReportToASFlag(ctx context.Context, r *gtsmodel.Report) (vocab.ActivityStreamsFlag, error) { flag := streams.NewActivityStreamsFlag() diff --git a/internal/typeutils/internaltoas_test.go b/internal/typeutils/internaltoas_test.go index 2ea393db..887d7888 100644 --- a/internal/typeutils/internaltoas_test.go +++ b/internal/typeutils/internaltoas_test.go @@ -21,11 +21,13 @@ package typeutils_test import ( "context" "encoding/json" + "errors" "strings" "testing" "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/activity/streams" + "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/testrig" ) @@ -544,6 +546,96 @@ func (suite *InternalToASTestSuite) TestReportToAS() { }`, string(bytes)) } +func (suite *InternalToASTestSuite) TestPinnedStatusesToASSomeItems() { + ctx := context.Background() + + testAccount := suite.testAccounts["admin_account"] + statuses, err := suite.db.GetAccountPinnedStatuses(ctx, testAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + + collection, err := suite.typeconverter.StatusesToASFeaturedCollection(ctx, testAccount.FeaturedCollectionURI, statuses) + if err != nil { + suite.FailNow(err.Error()) + } + + ser, err := streams.Serialize(collection) + suite.NoError(err) + + bytes, err := json.MarshalIndent(ser, "", " ") + suite.NoError(err) + + suite.Equal(`{ + "@context": "https://www.w3.org/ns/activitystreams", + "id": "http://localhost:8080/users/admin/collections/featured", + "orderedItems": [ + "http://localhost:8080/users/admin/statuses/01F8MHAAY43M6RJ473VQFCVH37", + "http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R" + ], + "totalItems": 2, + "type": "OrderedCollection" +}`, string(bytes)) +} + +func (suite *InternalToASTestSuite) TestPinnedStatusesToASNoItems() { + ctx := context.Background() + + testAccount := suite.testAccounts["local_account_1"] + statuses, err := suite.db.GetAccountPinnedStatuses(ctx, testAccount.ID) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + suite.FailNow(err.Error()) + } + + collection, err := suite.typeconverter.StatusesToASFeaturedCollection(ctx, testAccount.FeaturedCollectionURI, statuses) + if err != nil { + suite.FailNow(err.Error()) + } + + ser, err := streams.Serialize(collection) + suite.NoError(err) + + bytes, err := json.MarshalIndent(ser, "", " ") + suite.NoError(err) + + suite.Equal(`{ + "@context": "https://www.w3.org/ns/activitystreams", + "id": "http://localhost:8080/users/the_mighty_zork/collections/featured", + "orderedItems": [], + "totalItems": 0, + "type": "OrderedCollection" +}`, string(bytes)) +} + +func (suite *InternalToASTestSuite) TestPinnedStatusesToASOneItem() { + ctx := context.Background() + + testAccount := suite.testAccounts["local_account_2"] + statuses, err := suite.db.GetAccountPinnedStatuses(ctx, testAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + + collection, err := suite.typeconverter.StatusesToASFeaturedCollection(ctx, testAccount.FeaturedCollectionURI, statuses) + if err != nil { + suite.FailNow(err.Error()) + } + + ser, err := streams.Serialize(collection) + suite.NoError(err) + + bytes, err := json.MarshalIndent(ser, "", " ") + suite.NoError(err) + + suite.Equal(`{ + "@context": "https://www.w3.org/ns/activitystreams", + "id": "http://localhost:8080/users/1happyturtle/collections/featured", + "orderedItems": "http://localhost:8080/users/1happyturtle/statuses/01G20ZM733MGN8J344T4ZDDFY1", + "totalItems": 1, + "type": "OrderedCollection" +}`, string(bytes)) +} + func TestInternalToASTestSuite(t *testing.T) { suite.Run(t, new(InternalToASTestSuite)) } diff --git a/internal/uris/uri.go b/internal/uris/uri.go index f6e06ca2..8d0189ca 100644 --- a/internal/uris/uri.go +++ b/internal/uris/uri.go @@ -70,7 +70,7 @@ type UserURIs struct { // The activitypub URI for this user's liked posts eg., https://example.org/users/example_user/liked LikedURI string // The activitypub URI for this user's featured collections, eg., https://example.org/users/example_user/collections/featured - CollectionURI string + FeaturedCollectionURI string // The URI for this user's public key, eg., https://example.org/users/example_user/publickey PublicKeyURI string } @@ -152,15 +152,15 @@ func GenerateURIsForAccount(username string) *UserURIs { UserURL: userURL, StatusesURL: statusesURL, - UserURI: userURI, - StatusesURI: statusesURI, - InboxURI: inboxURI, - OutboxURI: outboxURI, - FollowersURI: followersURI, - FollowingURI: followingURI, - LikedURI: likedURI, - CollectionURI: collectionURI, - PublicKeyURI: publicKeyURI, + UserURI: userURI, + StatusesURI: statusesURI, + InboxURI: inboxURI, + OutboxURI: outboxURI, + FollowersURI: followersURI, + FollowingURI: followingURI, + LikedURI: likedURI, + FeaturedCollectionURI: collectionURI, + PublicKeyURI: publicKeyURI, } } diff --git a/internal/web/thread.go b/internal/web/thread.go index e657aa91..bd2f11dc 100644 --- a/internal/web/thread.go +++ b/internal/web/thread.go @@ -132,7 +132,7 @@ func (m *Module) returnAPStatus(ctx context.Context, c *gin.Context, username st ctx = context.WithValue(ctx, ap.ContextRequestingPublicKeySignature, signature) } - status, errWithCode := m.processor.Fedi().StatusGet(ctx, username, statusID, c.Request.URL) + status, errWithCode := m.processor.Fedi().StatusGet(ctx, username, statusID) if errWithCode != nil { apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) //nolint:contextcheck return