From fc11deeb83a76a0dbe550842ce6594602a9fb8bc Mon Sep 17 00:00:00 2001 From: kim <89579420+NyaaaWhatsUpDoc@users.noreply.github.com> Date: Wed, 20 Sep 2023 16:49:46 +0100 Subject: [PATCH] [feature] add paging to AP following / followers endpoints (#2198) --- internal/ap/activitystreams.go | 203 ++++++++++++++++ internal/ap/activitystreams_test.go | 221 ++++++++++++++++++ internal/api/activitypub/users/followers.go | 13 +- internal/api/activitypub/users/following.go | 13 +- internal/federation/federatingdb/followers.go | 14 +- internal/federation/federatingdb/following.go | 16 +- internal/paging/page.go | 17 +- internal/processing/fedi/collections.go | 160 +++++++++++-- internal/typeutils/internaltoas.go | 2 +- 9 files changed, 618 insertions(+), 41 deletions(-) create mode 100644 internal/ap/activitystreams_test.go diff --git a/internal/ap/activitystreams.go b/internal/ap/activitystreams.go index e8a362800..34a3013a4 100644 --- a/internal/ap/activitystreams.go +++ b/internal/ap/activitystreams.go @@ -17,6 +17,15 @@ package ap +import ( + "net/url" + "strconv" + + "github.com/superseriousbusiness/activity/streams" + "github.com/superseriousbusiness/activity/streams/vocab" + "github.com/superseriousbusiness/gotosocial/internal/paging" +) + // https://www.w3.org/TR/activitystreams-vocabulary const ( ActivityAccept = "Accept" // ActivityStreamsAccept https://www.w3.org/TR/activitystreams-vocabulary/#dfn-accept @@ -77,3 +86,197 @@ // and https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tag TagHashtag = "Hashtag" ) + +type CollectionParams struct { + // Containing collection + // ID (i.e. NOT the page). + ID *url.URL + + // Total no. items. + Total int +} + +type CollectionPageParams struct { + // containing collection. + CollectionParams + + // Paging details. + Current *paging.Page + Next *paging.Page + Prev *paging.Page + Query url.Values + + // Item appender for each item at index. + Append func(int, ItemsPropertyBuilder) + Count int +} + +// CollectionPage is a simplified interface type +// that can be fulfilled by either of (where required): +// vocab.ActivityStreamsCollection +// vocab.ActivityStreamsOrderedCollection +type CollectionBuilder interface { + SetJSONLDId(vocab.JSONLDIdProperty) + SetActivityStreamsFirst(vocab.ActivityStreamsFirstProperty) + SetActivityStreamsTotalItems(i vocab.ActivityStreamsTotalItemsProperty) +} + +// CollectionPageBuilder is a simplified interface type +// that can be fulfilled by either of (where required): +// vocab.ActivityStreamsCollectionPage +// vocab.ActivityStreamsOrderedCollectionPage +type CollectionPageBuilder interface { + SetJSONLDId(vocab.JSONLDIdProperty) + SetActivityStreamsPartOf(vocab.ActivityStreamsPartOfProperty) + SetActivityStreamsNext(vocab.ActivityStreamsNextProperty) + SetActivityStreamsPrev(vocab.ActivityStreamsPrevProperty) + SetActivityStreamsTotalItems(i vocab.ActivityStreamsTotalItemsProperty) +} + +// ItemsPropertyBuilder is a simplified interface type +// that can be fulfilled by either of (where required): +// vocab.ActivityStreamsItemsProperty +// vocab.ActivityStreamsOrderedItemsProperty +type ItemsPropertyBuilder interface { + AppendIRI(*url.URL) + + // NOTE: add more of the items-property-like interface + // functions here as you require them for building pages. +} + +// NewASCollection builds and returns a new ActivityStreams Collection from given parameters. +func NewASCollection(params CollectionParams) vocab.ActivityStreamsCollection { + collection := streams.NewActivityStreamsCollection() + buildCollection(collection, params, 40) + return collection +} + +// NewASCollectionPage builds and returns a new ActivityStreams CollectionPage from given parameters (including item property appending function). +func NewASCollectionPage(params CollectionPageParams) vocab.ActivityStreamsCollectionPage { + collectionPage := streams.NewActivityStreamsCollectionPage() + itemsProp := streams.NewActivityStreamsItemsProperty() + buildCollectionPage(collectionPage, itemsProp, collectionPage.SetActivityStreamsItems, params) + return collectionPage +} + +// NewASOrderedCollection builds and returns a new ActivityStreams OrderedCollection from given parameters. +func NewASOrderedCollection(params CollectionParams) vocab.ActivityStreamsOrderedCollection { + collection := streams.NewActivityStreamsOrderedCollection() + buildCollection(collection, params, 40) + return collection +} + +// NewASOrderedCollectionPage builds and returns a new ActivityStreams OrderedCollectionPage from given parameters (including item property appending function). +func NewASOrderedCollectionPage(params CollectionPageParams) vocab.ActivityStreamsOrderedCollectionPage { + collectionPage := streams.NewActivityStreamsOrderedCollectionPage() + itemsProp := streams.NewActivityStreamsOrderedItemsProperty() + buildCollectionPage(collectionPage, itemsProp, collectionPage.SetActivityStreamsOrderedItems, params) + return collectionPage +} + +func buildCollection[C CollectionBuilder](collection C, params CollectionParams, pageLimit int) { + // Add the collection ID property. + idProp := streams.NewJSONLDIdProperty() + idProp.SetIRI(params.ID) + collection.SetJSONLDId(idProp) + + // Add the collection totalItems count property. + totalItems := streams.NewActivityStreamsTotalItemsProperty() + totalItems.Set(params.Total) + collection.SetActivityStreamsTotalItems(totalItems) + + // Clone the collection ID page + // to add first page query data. + firstIRI := new(url.URL) + *firstIRI = *params.ID + + // Note that simply adding a limit signals to our + // endpoint to use paging (which will start at beginning). + limit := "limit=" + strconv.Itoa(pageLimit) + firstIRI.RawQuery = appendQuery(firstIRI.RawQuery, limit) + + // Add the collection first IRI property. + first := streams.NewActivityStreamsFirstProperty() + first.SetIRI(firstIRI) + collection.SetActivityStreamsFirst(first) +} + +func buildCollectionPage[C CollectionPageBuilder, I ItemsPropertyBuilder](collectionPage C, itemsProp I, setItems func(I), params CollectionPageParams) { + // Add the partOf property for its containing collection ID. + partOfProp := streams.NewActivityStreamsPartOfProperty() + partOfProp.SetIRI(params.ID) + collectionPage.SetActivityStreamsPartOf(partOfProp) + + // Build the current page link IRI. + currentIRI := params.Current.ToLinkURL( + params.ID.Scheme, + params.ID.Host, + params.ID.Path, + params.Query, + ) + + // Add the collection ID property for + // the *current* collection page params. + idProp := streams.NewJSONLDIdProperty() + idProp.SetIRI(currentIRI) + collectionPage.SetJSONLDId(idProp) + + // Build the next page link IRI. + nextIRI := params.Next.ToLinkURL( + params.ID.Scheme, + params.ID.Host, + params.ID.Path, + params.Query, + ) + + if nextIRI != nil { + // Add the collection next property for the next page. + nextProp := streams.NewActivityStreamsNextProperty() + nextProp.SetIRI(nextIRI) + collectionPage.SetActivityStreamsNext(nextProp) + } + + // Build the prev page link IRI. + prevIRI := params.Prev.ToLinkURL( + params.ID.Scheme, + params.ID.Host, + params.ID.Path, + params.Query, + ) + + if prevIRI != nil { + // Add the collection prev property for the prev page. + prevProp := streams.NewActivityStreamsPrevProperty() + prevProp.SetIRI(prevIRI) + collectionPage.SetActivityStreamsPrev(prevProp) + } + + // Add the collection totalItems count property. + totalItems := streams.NewActivityStreamsTotalItemsProperty() + totalItems.Set(params.Total) + collectionPage.SetActivityStreamsTotalItems(totalItems) + + if params.Append == nil { + // nil check outside the for loop. + panic("nil params.Append function") + } + + // Append each of the items to the provided + // pre-allocated items property builder type. + for i := 0; i < params.Count; i++ { + params.Append(i, itemsProp) + } + + // Set the collection + // page items property. + setItems(itemsProp) +} + +// appendQuery appends part to an existing raw +// query with ampersand, else just returning part. +func appendQuery(raw, part string) string { + if raw != "" { + return raw + "&" + part + } + return part +} diff --git a/internal/ap/activitystreams_test.go b/internal/ap/activitystreams_test.go new file mode 100644 index 000000000..ee03f9b0f --- /dev/null +++ b/internal/ap/activitystreams_test.go @@ -0,0 +1,221 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +package ap_test + +import ( + "encoding/json" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/superseriousbusiness/activity/streams/vocab" + "github.com/superseriousbusiness/gotosocial/internal/ap" + "github.com/superseriousbusiness/gotosocial/internal/paging" +) + +func TestASCollection(t *testing.T) { + const ( + proto = "https" + host = "zorg.flabormagorg.xyz" + path = "/users/itsa_me_mario" + + idURI = proto + "://" + host + path + total = 10 + ) + + // Create JSON string of expected output. + expect := toJSON(map[string]any{ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Collection", + "id": idURI, + "first": idURI + "?limit=40", + "totalItems": total, + }) + + // Create new collection using builder function. + c := ap.NewASCollection(ap.CollectionParams{ + ID: parseURI(idURI), + Total: total, + }) + + // Serialize collection. + s := toJSON(c) + + // Ensure outputs are equal. + assert.Equal(t, s, expect) +} + +func TestASCollectionPage(t *testing.T) { + const ( + proto = "https" + host = "zorg.flabormagorg.xyz" + path = "/users/itsa_me_mario" + + idURI = proto + "://" + host + path + total = 10 + + minID = "minimum" + maxID = "maximum" + limit = 40 + count = 2 + ) + + // Create the current page. + currPg := &paging.Page{ + Limit: 40, + } + + // Create JSON string of expected output. + expect := toJSON(map[string]any{ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "CollectionPage", + "id": currPg.ToLink(proto, host, path, nil), + "partOf": idURI, + "next": currPg.Next(minID, maxID).ToLink(proto, host, path, nil), + "prev": currPg.Prev(minID, maxID).ToLink(proto, host, path, nil), + "items": []interface{}{}, + "totalItems": total, + }) + + // Create new collection page using builder function. + p := ap.NewASCollectionPage(ap.CollectionPageParams{ + CollectionParams: ap.CollectionParams{ + ID: parseURI(idURI), + Total: total, + }, + + Current: currPg, + Next: currPg.Next(minID, maxID), + Prev: currPg.Prev(minID, maxID), + + Append: func(i int, ipb ap.ItemsPropertyBuilder) {}, + Count: count, + }) + + // Serialize page. + s := toJSON(p) + + // Ensure outputs are equal. + assert.Equal(t, s, expect) +} + +func TestASOrderedCollection(t *testing.T) { + const ( + idURI = "https://zorg.flabormagorg.xyz/users/itsa_me_mario" + total = 10 + ) + + // Create JSON string of expected output. + expect := toJSON(map[string]any{ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "OrderedCollection", + "id": idURI, + "first": idURI + "?limit=40", + "totalItems": total, + }) + + // Create new collection using builder function. + c := ap.NewASOrderedCollection(ap.CollectionParams{ + ID: parseURI(idURI), + Total: total, + }) + + // Serialize collection. + s := toJSON(c) + + // Ensure outputs are equal. + assert.Equal(t, s, expect) +} + +func TestASOrderedCollectionPage(t *testing.T) { + const ( + proto = "https" + host = "zorg.flabormagorg.xyz" + path = "/users/itsa_me_mario" + + idURI = proto + "://" + host + path + total = 10 + + minID = "minimum" + maxID = "maximum" + limit = 40 + count = 2 + ) + + // Create the current page. + currPg := &paging.Page{ + Limit: 40, + } + + // Create JSON string of expected output. + expect := toJSON(map[string]any{ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "OrderedCollectionPage", + "id": currPg.ToLink(proto, host, path, nil), + "partOf": idURI, + "next": currPg.Next(minID, maxID).ToLink(proto, host, path, nil), + "prev": currPg.Prev(minID, maxID).ToLink(proto, host, path, nil), + "orderedItems": []interface{}{}, + "totalItems": total, + }) + + // Create new collection page using builder function. + p := ap.NewASOrderedCollectionPage(ap.CollectionPageParams{ + CollectionParams: ap.CollectionParams{ + ID: parseURI(idURI), + Total: total, + }, + + Current: currPg, + Next: currPg.Next(minID, maxID), + Prev: currPg.Prev(minID, maxID), + + Append: func(i int, ipb ap.ItemsPropertyBuilder) {}, + Count: count, + }) + + // Serialize page. + s := toJSON(p) + + // Ensure outputs are equal. + assert.Equal(t, s, expect) +} + +func parseURI(s string) *url.URL { + u, err := url.Parse(s) + if err != nil { + panic(err) + } + return u +} + +// toJSON will return indented JSON serialized form of 'a'. +func toJSON(a any) string { + v, ok := a.(vocab.Type) + if ok { + m, err := ap.Serialize(v) + if err != nil { + panic(err) + } + a = m + } + b, err := json.MarshalIndent(a, "", " ") + if err != nil { + panic(err) + } + return string(b) +} diff --git a/internal/api/activitypub/users/followers.go b/internal/api/activitypub/users/followers.go index 07bf5a421..e93ef8d4d 100644 --- a/internal/api/activitypub/users/followers.go +++ b/internal/api/activitypub/users/followers.go @@ -26,6 +26,7 @@ "github.com/gin-gonic/gin" apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/paging" ) // FollowersGETHandler returns a collection of URIs for followers of the target user, formatted so that other AP servers can understand it. @@ -51,7 +52,17 @@ func (m *Module) FollowersGETHandler(c *gin.Context) { return } - resp, errWithCode := m.processor.Fedi().FollowersGet(c.Request.Context(), requestedUsername) + page, errWithCode := paging.ParseIDPage(c, + 1, // min limit + 80, // max limit + 0, // default = disabled + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + resp, errWithCode := m.processor.Fedi().FollowersGet(c.Request.Context(), requestedUsername, page) 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 126ef99b2..54fb3b676 100644 --- a/internal/api/activitypub/users/following.go +++ b/internal/api/activitypub/users/following.go @@ -26,6 +26,7 @@ "github.com/gin-gonic/gin" apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/paging" ) // FollowingGETHandler returns a collection of URIs for accounts that the target user follows, formatted so that other AP servers can understand it. @@ -51,7 +52,17 @@ func (m *Module) FollowingGETHandler(c *gin.Context) { return } - resp, errWithCode := m.processor.Fedi().FollowingGet(c.Request.Context(), requestedUsername) + page, errWithCode := paging.ParseIDPage(c, + 1, // min limit + 80, // max limit + 0, // default = disabled + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + resp, errWithCode := m.processor.Fedi().FollowingGet(c.Request.Context(), requestedUsername, page) if errWithCode != nil { apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) return diff --git a/internal/federation/federatingdb/followers.go b/internal/federation/federatingdb/followers.go index eada48c1b..88cf02d6b 100644 --- a/internal/federation/federatingdb/followers.go +++ b/internal/federation/federatingdb/followers.go @@ -23,7 +23,7 @@ "net/url" "github.com/superseriousbusiness/activity/streams/vocab" - "github.com/superseriousbusiness/gotosocial/internal/log" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" ) // Followers obtains the Followers Collection for an actor with the @@ -38,25 +38,19 @@ func (f *federatingDB) Followers(ctx context.Context, actorIRI *url.URL) (follow return nil, err } + // Fetch followers for account from database. follows, err := f.state.DB.GetAccountFollowers(ctx, acct.ID, nil) if err != nil { return nil, fmt.Errorf("Followers: db error getting followers for account id %s: %s", acct.ID, err) } + // Convert the followers to a slice of account URIs. iris := make([]*url.URL, 0, len(follows)) for _, follow := range follows { - if follow.Account == nil { - // Follow account no longer exists, - // for some reason. Skip this one. - log.WithContext(ctx).WithField("follow", follow).Warnf("follow missing account %s", follow.AccountID) - continue - } - u, err := url.Parse(follow.Account.URI) if err != nil { - return nil, err + return nil, gtserror.Newf("invalid account uri: %v", err) } - iris = append(iris, u) } diff --git a/internal/federation/federatingdb/following.go b/internal/federation/federatingdb/following.go index deb965564..c30c13317 100644 --- a/internal/federation/federatingdb/following.go +++ b/internal/federation/federatingdb/following.go @@ -19,11 +19,10 @@ import ( "context" - "fmt" "net/url" "github.com/superseriousbusiness/activity/streams/vocab" - "github.com/superseriousbusiness/gotosocial/internal/log" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" ) // Following obtains the Following Collection for an actor with the @@ -38,23 +37,18 @@ func (f *federatingDB) Following(ctx context.Context, actorIRI *url.URL) (follow return nil, err } + // Fetch follows for account from database. follows, err := f.state.DB.GetAccountFollows(ctx, acct.ID, nil) if err != nil { - return nil, fmt.Errorf("Following: db error getting following for account id %s: %w", acct.ID, err) + return nil, gtserror.Newf("db error getting following for account id %s: %w", acct.ID, err) } + // Convert the follows to a slice of account URIs. iris := make([]*url.URL, 0, len(follows)) for _, follow := range follows { - if follow.TargetAccount == nil { - // Follow target account no longer exists, - // for some reason. Skip this one. - log.WithContext(ctx).WithField("follow", follow).Warnf("follow missing target account %s", follow.TargetAccountID) - continue - } - u, err := url.Parse(follow.TargetAccount.URI) if err != nil { - return nil, err + return nil, gtserror.Newf("invalid account uri: %v", err) } iris = append(iris, u) } diff --git a/internal/paging/page.go b/internal/paging/page.go index 0a9bc71b1..a56f674dd 100644 --- a/internal/paging/page.go +++ b/internal/paging/page.go @@ -205,12 +205,21 @@ func (p *Page) Prev(lo, hi string) *Page { return p2 } +// ToLink performs ToLinkURL() and calls .String() on the resulting URL. +func (p *Page) ToLink(proto, host, path string, queryParams url.Values) string { + u := p.ToLinkURL(proto, host, path, queryParams) + if u == nil { + return "" + } + return u.String() +} + // ToLink builds a URL link for given endpoint information and extra query parameters, // appending this Page's minimum / maximum boundaries and available limit (if any). -func (p *Page) ToLink(proto, host, path string, queryParams url.Values) string { +func (p *Page) ToLinkURL(proto, host, path string, queryParams url.Values) *url.URL { if p == nil { // no paging. - return "" + return nil } if queryParams == nil { @@ -234,10 +243,10 @@ func (p *Page) ToLink(proto, host, path string, queryParams url.Values) string { } // Build URL string. - return (&url.URL{ + return &url.URL{ Scheme: proto, Host: host, Path: path, RawQuery: queryParams.Encode(), - }).String() + } } diff --git a/internal/processing/fedi/collections.go b/internal/processing/fedi/collections.go index a56b001b7..5f9c117e1 100644 --- a/internal/processing/fedi/collections.go +++ b/internal/processing/fedi/collections.go @@ -20,13 +20,15 @@ import ( "context" "errors" - "fmt" "net/http" "net/url" + "github.com/superseriousbusiness/activity/streams/vocab" "github.com/superseriousbusiness/gotosocial/internal/ap" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/log" + "github.com/superseriousbusiness/gotosocial/internal/paging" ) // InboxPost handles POST requests to a user's inbox for new activitypub messages. @@ -102,24 +104,90 @@ func (p *Processor) OutboxGet(ctx context.Context, requestedUsername string, pag // 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) { +func (p *Processor) FollowersGet(ctx context.Context, requestedUsername string, page *paging.Page) (interface{}, gtserror.WithCode) { requestedAccount, _, errWithCode := p.authenticate(ctx, requestedUsername) if errWithCode != nil { return nil, errWithCode } - requestedAccountURI, err := url.Parse(requestedAccount.URI) + // Parse the collection ID object from account's followers URI. + collectionID, err := url.Parse(requestedAccount.FollowersURI) if err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("error parsing url %s: %s", requestedAccount.URI, err)) + err := gtserror.Newf("error parsing account followers uri %s: %w", requestedAccount.FollowersURI, err) + return nil, gtserror.NewErrorInternalError(err) } - requestedFollowers, err := p.federator.FederatingDB().Followers(ctx, requestedAccountURI) + // Calculate total number of followers available for account. + total, err := p.state.DB.CountAccountFollowers(ctx, requestedAccount.ID) if err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("error fetching followers for uri %s: %s", requestedAccountURI.String(), err)) + err := gtserror.Newf("error counting followers: %w", err) + return nil, gtserror.NewErrorInternalError(err) } - data, err := ap.Serialize(requestedFollowers) + var obj vocab.Type + + // Start building AS collection params. + var params ap.CollectionParams + params.ID = collectionID + params.Total = total + + if page == nil { + // i.e. paging disabled, the simplest case. + // + // Just build collection object from params. + obj = ap.NewASOrderedCollection(params) + } else { + // i.e. paging enabled + + // Get the request page of full follower objects with attached accounts. + followers, err := p.state.DB.GetAccountFollowers(ctx, requestedAccount.ID, page) + if err != nil { + err := gtserror.Newf("error getting followers: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + // Get the lowest and highest + // ID values, used for paging. + lo := followers[len(followers)-1].ID + hi := followers[0].ID + + // Start building AS collection page params. + var pageParams ap.CollectionPageParams + pageParams.CollectionParams = params + + // Current page details. + pageParams.Current = page + pageParams.Count = len(followers) + + // Set linked next/prev parameters. + pageParams.Next = page.Next(lo, hi) + pageParams.Prev = page.Prev(lo, hi) + + // Set the collection item property builder function. + pageParams.Append = func(i int, itemsProp ap.ItemsPropertyBuilder) { + // Get follower URI at index. + follow := followers[i] + accURI := follow.Account.URI + + // Parse URL object from URI. + iri, err := url.Parse(accURI) + if err != nil { + log.Errorf(ctx, "error parsing account uri %s: %v", accURI, err) + return + } + + // Add to item property. + itemsProp.AppendIRI(iri) + } + + // Build AS collection page object from params. + obj = ap.NewASOrderedCollectionPage(pageParams) + } + + // Serialized the prepared object. + data, err := ap.Serialize(obj) if err != nil { + err := gtserror.Newf("error serializing: %w", err) return nil, gtserror.NewErrorInternalError(err) } @@ -128,24 +196,90 @@ func (p *Processor) FollowersGet(ctx context.Context, requestedUsername string) // 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) { +func (p *Processor) FollowingGet(ctx context.Context, requestedUsername string, page *paging.Page) (interface{}, gtserror.WithCode) { requestedAccount, _, errWithCode := p.authenticate(ctx, requestedUsername) if errWithCode != nil { return nil, errWithCode } - requestedAccountURI, err := url.Parse(requestedAccount.URI) + // Parse the collection ID object from account's following URI. + collectionID, err := url.Parse(requestedAccount.FollowingURI) if err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("error parsing url %s: %s", requestedAccount.URI, err)) + err := gtserror.Newf("error parsing account following uri %s: %w", requestedAccount.FollowingURI, err) + return nil, gtserror.NewErrorInternalError(err) } - requestedFollowing, err := p.federator.FederatingDB().Following(ctx, requestedAccountURI) + // Calculate total number of following available for account. + total, err := p.state.DB.CountAccountFollows(ctx, requestedAccount.ID) if err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("error fetching following for uri %s: %s", requestedAccountURI.String(), err)) + err := gtserror.Newf("error counting follows: %w", err) + return nil, gtserror.NewErrorInternalError(err) } - data, err := ap.Serialize(requestedFollowing) + var obj vocab.Type + + // Start building AS collection params. + var params ap.CollectionParams + params.ID = collectionID + params.Total = total + + if page == nil { + // i.e. paging disabled, the simplest case. + // + // Just build collection object from params. + obj = ap.NewASOrderedCollection(params) + } else { + // i.e. paging enabled + + // Get the request page of full follower objects with attached accounts. + follows, err := p.state.DB.GetAccountFollows(ctx, requestedAccount.ID, page) + if err != nil { + err := gtserror.Newf("error getting follows: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + // Get the lowest and highest + // ID values, used for paging. + lo := follows[len(follows)-1].ID + hi := follows[0].ID + + // Start building AS collection page params. + var pageParams ap.CollectionPageParams + pageParams.CollectionParams = params + + // Current page details. + pageParams.Current = page + pageParams.Count = len(follows) + + // Set linked next/prev parameters. + pageParams.Next = page.Next(lo, hi) + pageParams.Prev = page.Prev(lo, hi) + + // Set the collection item property builder function. + pageParams.Append = func(i int, itemsProp ap.ItemsPropertyBuilder) { + // Get follower URI at index. + follow := follows[i] + accURI := follow.Account.URI + + // Parse URL object from URI. + iri, err := url.Parse(accURI) + if err != nil { + log.Errorf(ctx, "error parsing account uri %s: %v", accURI, err) + return + } + + // Add to item property. + itemsProp.AppendIRI(iri) + } + + // Build AS collection page object from params. + obj = ap.NewASOrderedCollectionPage(pageParams) + } + + // Serialized the prepared object. + data, err := ap.Serialize(obj) if err != nil { + err := gtserror.Newf("error serializing: %w", err) return nil, gtserror.NewErrorInternalError(err) } diff --git a/internal/typeutils/internaltoas.go b/internal/typeutils/internaltoas.go index f10205b13..9404e2ec7 100644 --- a/internal/typeutils/internaltoas.go +++ b/internal/typeutils/internaltoas.go @@ -729,7 +729,7 @@ func (c *converter) StatusToASDelete(ctx context.Context, s *gtsmodel.Status) (v // For direct messages, add URI // to To, else just add to CC. - var f func(v *url.URL) + var f func(*url.URL) if s.Visibility == gtsmodel.VisibilityDirect { f = toProp.AppendIRI } else {