diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/ap/activitystreams.go | 203 | ||||
| -rw-r--r-- | internal/ap/activitystreams_test.go | 221 | ||||
| -rw-r--r-- | internal/api/activitypub/users/followers.go | 13 | ||||
| -rw-r--r-- | internal/api/activitypub/users/following.go | 13 | ||||
| -rw-r--r-- | internal/federation/federatingdb/followers.go | 14 | ||||
| -rw-r--r-- | internal/federation/federatingdb/following.go | 16 | ||||
| -rw-r--r-- | internal/paging/page.go | 17 | ||||
| -rw-r--r-- | internal/processing/fedi/collections.go | 160 | ||||
| -rw-r--r-- | internal/typeutils/internaltoas.go | 2 | 
9 files changed, 618 insertions, 41 deletions
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 @@ const (  	// 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 <http://www.gnu.org/licenses/>. +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 @@ import (  	"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 @@ import (  	"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 @@ import (  	"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 @@ package federatingdb  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 @@ package fedi  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) +	} + +	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)  	} -	data, err := ap.Serialize(requestedFollowers) +	// 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) +	} + +	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)  	} -	data, err := ap.Serialize(requestedFollowing) +	// 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 {  | 
