mirror of
				https://github.com/superseriousbusiness/gotosocial.git
				synced 2025-10-31 08:42:27 -05:00 
			
		
		
		
	[bugfix] self-referencing collection pages for status replies (#2364)
This commit is contained in:
		
					parent
					
						
							
								efefdb1323
							
						
					
				
			
			
				commit
				
					
						16275853eb
					
				
			
		
					 24 changed files with 611 additions and 427 deletions
				
			
		|  | @ -49,6 +49,7 @@ func TestASCollection(t *testing.T) { | ||||||
| 	// Create new collection using builder function. | 	// Create new collection using builder function. | ||||||
| 	c := ap.NewASCollection(ap.CollectionParams{ | 	c := ap.NewASCollection(ap.CollectionParams{ | ||||||
| 		ID:    parseURI(idURI), | 		ID:    parseURI(idURI), | ||||||
|  | 		Query: url.Values{"limit": []string{"40"}}, | ||||||
| 		Total: total, | 		Total: total, | ||||||
| 	}) | 	}) | ||||||
| 
 | 
 | ||||||
|  | @ -56,7 +57,7 @@ func TestASCollection(t *testing.T) { | ||||||
| 	s := toJSON(c) | 	s := toJSON(c) | ||||||
| 
 | 
 | ||||||
| 	// Ensure outputs are equal. | 	// Ensure outputs are equal. | ||||||
| 	assert.Equal(t, s, expect) | 	assert.Equal(t, expect, s) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func TestASCollectionPage(t *testing.T) { | func TestASCollectionPage(t *testing.T) { | ||||||
|  | @ -110,7 +111,7 @@ func TestASCollectionPage(t *testing.T) { | ||||||
| 	s := toJSON(p) | 	s := toJSON(p) | ||||||
| 
 | 
 | ||||||
| 	// Ensure outputs are equal. | 	// Ensure outputs are equal. | ||||||
| 	assert.Equal(t, s, expect) | 	assert.Equal(t, expect, s) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func TestASOrderedCollection(t *testing.T) { | func TestASOrderedCollection(t *testing.T) { | ||||||
|  | @ -131,6 +132,7 @@ func TestASOrderedCollection(t *testing.T) { | ||||||
| 	// Create new collection using builder function. | 	// Create new collection using builder function. | ||||||
| 	c := ap.NewASOrderedCollection(ap.CollectionParams{ | 	c := ap.NewASOrderedCollection(ap.CollectionParams{ | ||||||
| 		ID:    parseURI(idURI), | 		ID:    parseURI(idURI), | ||||||
|  | 		Query: url.Values{"limit": []string{"40"}}, | ||||||
| 		Total: total, | 		Total: total, | ||||||
| 	}) | 	}) | ||||||
| 
 | 
 | ||||||
|  | @ -138,7 +140,7 @@ func TestASOrderedCollection(t *testing.T) { | ||||||
| 	s := toJSON(c) | 	s := toJSON(c) | ||||||
| 
 | 
 | ||||||
| 	// Ensure outputs are equal. | 	// Ensure outputs are equal. | ||||||
| 	assert.Equal(t, s, expect) | 	assert.Equal(t, expect, s) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func TestASOrderedCollectionPage(t *testing.T) { | func TestASOrderedCollectionPage(t *testing.T) { | ||||||
|  | @ -192,7 +194,7 @@ func TestASOrderedCollectionPage(t *testing.T) { | ||||||
| 	s := toJSON(p) | 	s := toJSON(p) | ||||||
| 
 | 
 | ||||||
| 	// Ensure outputs are equal. | 	// Ensure outputs are equal. | ||||||
| 	assert.Equal(t, s, expect) | 	assert.Equal(t, expect, s) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func parseURI(s string) *url.URL { | func parseURI(s string) *url.URL { | ||||||
|  |  | ||||||
|  | @ -20,7 +20,6 @@ package ap | ||||||
| import ( | import ( | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"net/url" | 	"net/url" | ||||||
| 	"strconv" |  | ||||||
| 
 | 
 | ||||||
| 	"github.com/superseriousbusiness/activity/streams" | 	"github.com/superseriousbusiness/activity/streams" | ||||||
| 	"github.com/superseriousbusiness/activity/streams/vocab" | 	"github.com/superseriousbusiness/activity/streams/vocab" | ||||||
|  | @ -169,6 +168,10 @@ type CollectionParams struct { | ||||||
| 	// ID (i.e. NOT the page). | 	// ID (i.e. NOT the page). | ||||||
| 	ID *url.URL | 	ID *url.URL | ||||||
| 
 | 
 | ||||||
|  | 	// First page details. | ||||||
|  | 	First paging.Page | ||||||
|  | 	Query url.Values | ||||||
|  | 
 | ||||||
| 	// Total no. items. | 	// Total no. items. | ||||||
| 	Total int | 	Total int | ||||||
| } | } | ||||||
|  | @ -224,7 +227,7 @@ type ItemsPropertyBuilder interface { | ||||||
| // NewASCollection builds and returns a new ActivityStreams Collection from given parameters. | // NewASCollection builds and returns a new ActivityStreams Collection from given parameters. | ||||||
| func NewASCollection(params CollectionParams) vocab.ActivityStreamsCollection { | func NewASCollection(params CollectionParams) vocab.ActivityStreamsCollection { | ||||||
| 	collection := streams.NewActivityStreamsCollection() | 	collection := streams.NewActivityStreamsCollection() | ||||||
| 	buildCollection(collection, params, 40) | 	buildCollection(collection, params) | ||||||
| 	return collection | 	return collection | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -239,7 +242,7 @@ func NewASCollectionPage(params CollectionPageParams) vocab.ActivityStreamsColle | ||||||
| // NewASOrderedCollection builds and returns a new ActivityStreams OrderedCollection from given parameters. | // NewASOrderedCollection builds and returns a new ActivityStreams OrderedCollection from given parameters. | ||||||
| func NewASOrderedCollection(params CollectionParams) vocab.ActivityStreamsOrderedCollection { | func NewASOrderedCollection(params CollectionParams) vocab.ActivityStreamsOrderedCollection { | ||||||
| 	collection := streams.NewActivityStreamsOrderedCollection() | 	collection := streams.NewActivityStreamsOrderedCollection() | ||||||
| 	buildCollection(collection, params, 40) | 	buildCollection(collection, params) | ||||||
| 	return collection | 	return collection | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -251,7 +254,7 @@ func NewASOrderedCollectionPage(params CollectionPageParams) vocab.ActivityStrea | ||||||
| 	return collectionPage | 	return collectionPage | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func buildCollection[C CollectionBuilder](collection C, params CollectionParams, pageLimit int) { | func buildCollection[C CollectionBuilder](collection C, params CollectionParams) { | ||||||
| 	// Add the collection ID property. | 	// Add the collection ID property. | ||||||
| 	idProp := streams.NewJSONLDIdProperty() | 	idProp := streams.NewJSONLDIdProperty() | ||||||
| 	idProp.SetIRI(params.ID) | 	idProp.SetIRI(params.ID) | ||||||
|  | @ -262,15 +265,20 @@ func buildCollection[C CollectionBuilder](collection C, params CollectionParams, | ||||||
| 	totalItems.Set(params.Total) | 	totalItems.Set(params.Total) | ||||||
| 	collection.SetActivityStreamsTotalItems(totalItems) | 	collection.SetActivityStreamsTotalItems(totalItems) | ||||||
| 
 | 
 | ||||||
| 	// Clone the collection ID page | 	// Append paging query params | ||||||
| 	// to add first page query data. | 	// to those already in ID prop. | ||||||
| 	firstIRI := new(url.URL) | 	pageQueryParams := appendQuery( | ||||||
| 	*firstIRI = *params.ID | 		params.Query, | ||||||
|  | 		params.ID.Query(), | ||||||
|  | 	) | ||||||
| 
 | 
 | ||||||
| 	// Note that simply adding a limit signals to our | 	// Build the first page link IRI. | ||||||
| 	// endpoint to use paging (which will start at beginning). | 	firstIRI := params.First.ToLinkURL( | ||||||
| 	limit := "limit=" + strconv.Itoa(pageLimit) | 		params.ID.Scheme, | ||||||
| 	firstIRI.RawQuery = appendQuery(firstIRI.RawQuery, limit) | 		params.ID.Host, | ||||||
|  | 		params.ID.Path, | ||||||
|  | 		pageQueryParams, | ||||||
|  | 	) | ||||||
| 
 | 
 | ||||||
| 	// Add the collection first IRI property. | 	// Add the collection first IRI property. | ||||||
| 	first := streams.NewActivityStreamsFirstProperty() | 	first := streams.NewActivityStreamsFirstProperty() | ||||||
|  | @ -284,12 +292,19 @@ func buildCollectionPage[C CollectionPageBuilder, I ItemsPropertyBuilder](collec | ||||||
| 	partOfProp.SetIRI(params.ID) | 	partOfProp.SetIRI(params.ID) | ||||||
| 	collectionPage.SetActivityStreamsPartOf(partOfProp) | 	collectionPage.SetActivityStreamsPartOf(partOfProp) | ||||||
| 
 | 
 | ||||||
|  | 	// Append paging query params | ||||||
|  | 	// to those already in ID prop. | ||||||
|  | 	pageQueryParams := appendQuery( | ||||||
|  | 		params.Query, | ||||||
|  | 		params.ID.Query(), | ||||||
|  | 	) | ||||||
|  | 
 | ||||||
| 	// Build the current page link IRI. | 	// Build the current page link IRI. | ||||||
| 	currentIRI := params.Current.ToLinkURL( | 	currentIRI := params.Current.ToLinkURL( | ||||||
| 		params.ID.Scheme, | 		params.ID.Scheme, | ||||||
| 		params.ID.Host, | 		params.ID.Host, | ||||||
| 		params.ID.Path, | 		params.ID.Path, | ||||||
| 		params.Query, | 		pageQueryParams, | ||||||
| 	) | 	) | ||||||
| 
 | 
 | ||||||
| 	// Add the collection ID property for | 	// Add the collection ID property for | ||||||
|  | @ -303,7 +318,7 @@ func buildCollectionPage[C CollectionPageBuilder, I ItemsPropertyBuilder](collec | ||||||
| 		params.ID.Scheme, | 		params.ID.Scheme, | ||||||
| 		params.ID.Host, | 		params.ID.Host, | ||||||
| 		params.ID.Path, | 		params.ID.Path, | ||||||
| 		params.Query, | 		pageQueryParams, | ||||||
| 	) | 	) | ||||||
| 
 | 
 | ||||||
| 	if nextIRI != nil { | 	if nextIRI != nil { | ||||||
|  | @ -318,7 +333,7 @@ func buildCollectionPage[C CollectionPageBuilder, I ItemsPropertyBuilder](collec | ||||||
| 		params.ID.Scheme, | 		params.ID.Scheme, | ||||||
| 		params.ID.Host, | 		params.ID.Host, | ||||||
| 		params.ID.Path, | 		params.ID.Path, | ||||||
| 		params.Query, | 		pageQueryParams, | ||||||
| 	) | 	) | ||||||
| 
 | 
 | ||||||
| 	if prevIRI != nil { | 	if prevIRI != nil { | ||||||
|  | @ -349,11 +364,13 @@ func buildCollectionPage[C CollectionPageBuilder, I ItemsPropertyBuilder](collec | ||||||
| 	setItems(itemsProp) | 	setItems(itemsProp) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // appendQuery appends part to an existing raw | // appendQuery appends query values in 'src' to 'dst', returning 'dst'. | ||||||
| // query with ampersand, else just returning part. | func appendQuery(dst, src url.Values) url.Values { | ||||||
| func appendQuery(raw, part string) string { | 	if dst == nil { | ||||||
| 	if raw != "" { | 		return src | ||||||
| 		return raw + "&" + part |  | ||||||
| 	} | 	} | ||||||
| 	return part | 	for k, vs := range src { | ||||||
|  | 		dst[k] = append(dst[k], vs...) | ||||||
|  | 	} | ||||||
|  | 	return dst | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -20,14 +20,13 @@ package users | ||||||
| import ( | import ( | ||||||
| 	"encoding/json" | 	"encoding/json" | ||||||
| 	"errors" | 	"errors" | ||||||
| 	"fmt" |  | ||||||
| 	"net/http" | 	"net/http" | ||||||
| 	"strconv" |  | ||||||
| 	"strings" | 	"strings" | ||||||
| 
 | 
 | ||||||
| 	"github.com/gin-gonic/gin" | 	"github.com/gin-gonic/gin" | ||||||
| 	apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" | 	apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/gtserror" | 	"github.com/superseriousbusiness/gotosocial/internal/gtserror" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/paging" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| // StatusRepliesGETHandler swagger:operation GET /users/{username}/statuses/{status}/replies s2sRepliesGet | // StatusRepliesGETHandler swagger:operation GET /users/{username}/statuses/{status}/replies s2sRepliesGet | ||||||
|  | @ -120,36 +119,43 @@ func (m *Module) StatusRepliesGETHandler(c *gin.Context) { | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	var page bool | 	// Look for supplied 'only_other_accounts' query key. | ||||||
| 	if pageString := c.Query(PageKey); pageString != "" { | 	onlyOtherAccounts, errWithCode := apiutil.ParseOnlyOtherAccounts( | ||||||
| 		i, err := strconv.ParseBool(pageString) | 		c.Query(apiutil.OnlyOtherAccountsKey), | ||||||
| 		if err != nil { | 		true, // default = enabled | ||||||
| 			err := fmt.Errorf("error parsing %s: %s", PageKey, err) | 	) | ||||||
| 			apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) | 	if errWithCode != nil { | ||||||
| 			return | 		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) | ||||||
| 		} | 		return | ||||||
| 		page = i |  | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	onlyOtherAccounts := false | 	// Look for given paging query parameters. | ||||||
| 	onlyOtherAccountsString := c.Query(OnlyOtherAccountsKey) | 	page, errWithCode := paging.ParseIDPage(c, | ||||||
| 	if onlyOtherAccountsString != "" { | 		1,  // min limit | ||||||
| 		i, err := strconv.ParseBool(onlyOtherAccountsString) | 		40, // max limit | ||||||
| 		if err != nil { | 		0,  // default = disabled | ||||||
| 			err := fmt.Errorf("error parsing %s: %s", OnlyOtherAccountsKey, err) | 	) | ||||||
| 			apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) | 	if errWithCode != nil { | ||||||
| 			return | 		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) | ||||||
| 		} | 		return | ||||||
| 		onlyOtherAccounts = i |  | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	minID := "" | 	// COMPATIBILITY FIX: 'page=true' enables paging. | ||||||
| 	minIDString := c.Query(MinIDKey) | 	if page == nil && c.Query("page") == "true" { | ||||||
| 	if minIDString != "" { | 		page = new(paging.Page) | ||||||
| 		minID = minIDString | 		page.Max = paging.MaxID("") | ||||||
|  | 		page.Min = paging.MinID("") | ||||||
|  | 		page.Limit = 20 // default | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	resp, errWithCode := m.processor.Fedi().StatusRepliesGet(c.Request.Context(), requestedUsername, requestedStatusID, page, onlyOtherAccounts, c.Query("only_other_accounts") != "", minID) | 	// Fetch serialized status replies response for input status. | ||||||
|  | 	resp, errWithCode := m.processor.Fedi().StatusRepliesGet( | ||||||
|  | 		c.Request.Context(), | ||||||
|  | 		requestedUsername, | ||||||
|  | 		requestedStatusID, | ||||||
|  | 		page, | ||||||
|  | 		onlyOtherAccounts, | ||||||
|  | 	) | ||||||
| 	if errWithCode != nil { | 	if errWithCode != nil { | ||||||
| 		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) | 		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) | ||||||
| 		return | 		return | ||||||
|  | @ -157,7 +163,8 @@ func (m *Module) StatusRepliesGETHandler(c *gin.Context) { | ||||||
| 
 | 
 | ||||||
| 	b, err := json.Marshal(resp) | 	b, err := json.Marshal(resp) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGetV1) | 		errWithCode := gtserror.NewErrorInternalError(err) | ||||||
|  | 		apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -18,10 +18,10 @@ | ||||||
| package users_test | package users_test | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
|  | 	"bytes" | ||||||
| 	"context" | 	"context" | ||||||
| 	"encoding/json" | 	"encoding/json" | ||||||
| 	"fmt" | 	"io" | ||||||
| 	"io/ioutil" |  | ||||||
| 	"net/http" | 	"net/http" | ||||||
| 	"net/http/httptest" | 	"net/http/httptest" | ||||||
| 	"testing" | 	"testing" | ||||||
|  | @ -31,6 +31,7 @@ import ( | ||||||
| 	"github.com/stretchr/testify/suite" | 	"github.com/stretchr/testify/suite" | ||||||
| 	"github.com/superseriousbusiness/activity/streams" | 	"github.com/superseriousbusiness/activity/streams" | ||||||
| 	"github.com/superseriousbusiness/activity/streams/vocab" | 	"github.com/superseriousbusiness/activity/streams/vocab" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/ap" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/api/activitypub/users" | 	"github.com/superseriousbusiness/gotosocial/internal/api/activitypub/users" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/testrig" | 	"github.com/superseriousbusiness/gotosocial/testrig" | ||||||
| ) | ) | ||||||
|  | @ -49,7 +50,7 @@ func (suite *RepliesGetTestSuite) TestGetReplies() { | ||||||
| 	// setup request | 	// setup request | ||||||
| 	recorder := httptest.NewRecorder() | 	recorder := httptest.NewRecorder() | ||||||
| 	ctx, _ := testrig.CreateGinTestContext(recorder, nil) | 	ctx, _ := testrig.CreateGinTestContext(recorder, nil) | ||||||
| 	ctx.Request = httptest.NewRequest(http.MethodGet, targetStatus.URI+"/replies", nil) // the endpoint we're hitting | 	ctx.Request = httptest.NewRequest(http.MethodGet, targetStatus.URI+"/replies?only_other_accounts=false", nil) // the endpoint we're hitting | ||||||
| 	ctx.Request.Header.Set("accept", "application/activity+json") | 	ctx.Request.Header.Set("accept", "application/activity+json") | ||||||
| 	ctx.Request.Header.Set("Signature", signedRequest.SignatureHeader) | 	ctx.Request.Header.Set("Signature", signedRequest.SignatureHeader) | ||||||
| 	ctx.Request.Header.Set("Date", signedRequest.DateHeader) | 	ctx.Request.Header.Set("Date", signedRequest.DateHeader) | ||||||
|  | @ -76,13 +77,26 @@ func (suite *RepliesGetTestSuite) TestGetReplies() { | ||||||
| 	// check response | 	// check response | ||||||
| 	suite.EqualValues(http.StatusOK, recorder.Code) | 	suite.EqualValues(http.StatusOK, recorder.Code) | ||||||
| 
 | 
 | ||||||
|  | 	// Read response body. | ||||||
| 	result := recorder.Result() | 	result := recorder.Result() | ||||||
| 	defer result.Body.Close() | 	defer result.Body.Close() | ||||||
| 	b, err := ioutil.ReadAll(result.Body) | 	b, err := io.ReadAll(result.Body) | ||||||
| 	assert.NoError(suite.T(), err) | 	assert.NoError(suite.T(), err) | ||||||
| 	assert.Equal(suite.T(), `{"@context":"https://www.w3.org/ns/activitystreams","first":{"id":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies?page=true","next":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies?only_other_accounts=false\u0026page=true","partOf":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies","type":"CollectionPage"},"id":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies","type":"Collection"}`, string(b)) |  | ||||||
| 
 | 
 | ||||||
| 	// should be a Collection | 	// Indent JSON | ||||||
|  | 	// for readability. | ||||||
|  | 	b = indentJSON(b) | ||||||
|  | 
 | ||||||
|  | 	// Create JSON string of expected output. | ||||||
|  | 	expect := toJSON(map[string]any{ | ||||||
|  | 		"@context":   "https://www.w3.org/ns/activitystreams", | ||||||
|  | 		"type":       "OrderedCollection", | ||||||
|  | 		"id":         targetStatus.URI + "/replies?only_other_accounts=false", | ||||||
|  | 		"first":      targetStatus.URI + "/replies?limit=20&only_other_accounts=false", | ||||||
|  | 		"totalItems": 1, | ||||||
|  | 	}) | ||||||
|  | 	assert.Equal(suite.T(), expect, string(b)) | ||||||
|  | 
 | ||||||
| 	m := make(map[string]interface{}) | 	m := make(map[string]interface{}) | ||||||
| 	err = json.Unmarshal(b, &m) | 	err = json.Unmarshal(b, &m) | ||||||
| 	assert.NoError(suite.T(), err) | 	assert.NoError(suite.T(), err) | ||||||
|  | @ -90,7 +104,7 @@ func (suite *RepliesGetTestSuite) TestGetReplies() { | ||||||
| 	t, err := streams.ToType(context.Background(), m) | 	t, err := streams.ToType(context.Background(), m) | ||||||
| 	assert.NoError(suite.T(), err) | 	assert.NoError(suite.T(), err) | ||||||
| 
 | 
 | ||||||
| 	_, ok := t.(vocab.ActivityStreamsCollection) | 	_, ok := t.(vocab.ActivityStreamsOrderedCollection) | ||||||
| 	assert.True(suite.T(), ok) | 	assert.True(suite.T(), ok) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -131,14 +145,29 @@ func (suite *RepliesGetTestSuite) TestGetRepliesNext() { | ||||||
| 	// check response | 	// check response | ||||||
| 	suite.EqualValues(http.StatusOK, recorder.Code) | 	suite.EqualValues(http.StatusOK, recorder.Code) | ||||||
| 
 | 
 | ||||||
|  | 	// Read response body. | ||||||
| 	result := recorder.Result() | 	result := recorder.Result() | ||||||
| 	defer result.Body.Close() | 	defer result.Body.Close() | ||||||
| 	b, err := ioutil.ReadAll(result.Body) | 	b, err := io.ReadAll(result.Body) | ||||||
| 	assert.NoError(suite.T(), err) | 	assert.NoError(suite.T(), err) | ||||||
| 
 | 
 | ||||||
| 	assert.Equal(suite.T(), `{"@context":"https://www.w3.org/ns/activitystreams","id":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies?page=true\u0026only_other_accounts=false","items":"http://localhost:8080/users/admin/statuses/01FF25D5Q0DH7CHD57CTRS6WK0","next":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies?only_other_accounts=false\u0026page=true\u0026min_id=01FF25D5Q0DH7CHD57CTRS6WK0","partOf":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies","type":"CollectionPage"}`, string(b)) | 	// Indent JSON | ||||||
|  | 	// for readability. | ||||||
|  | 	b = indentJSON(b) | ||||||
|  | 
 | ||||||
|  | 	// Create JSON string of expected output. | ||||||
|  | 	expect := toJSON(map[string]any{ | ||||||
|  | 		"@context":     "https://www.w3.org/ns/activitystreams", | ||||||
|  | 		"type":         "OrderedCollectionPage", | ||||||
|  | 		"id":           targetStatus.URI + "/replies?limit=20&only_other_accounts=false", | ||||||
|  | 		"partOf":       targetStatus.URI + "/replies?only_other_accounts=false", | ||||||
|  | 		"next":         "http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies?limit=20&min_id=01FF25D5Q0DH7CHD57CTRS6WK0&only_other_accounts=false", | ||||||
|  | 		"prev":         "http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies?limit=20&max_id=01FF25D5Q0DH7CHD57CTRS6WK0&only_other_accounts=false", | ||||||
|  | 		"orderedItems": "http://localhost:8080/users/admin/statuses/01FF25D5Q0DH7CHD57CTRS6WK0", | ||||||
|  | 		"totalItems":   1, | ||||||
|  | 	}) | ||||||
|  | 	assert.Equal(suite.T(), expect, string(b)) | ||||||
| 
 | 
 | ||||||
| 	// should be a Collection |  | ||||||
| 	m := make(map[string]interface{}) | 	m := make(map[string]interface{}) | ||||||
| 	err = json.Unmarshal(b, &m) | 	err = json.Unmarshal(b, &m) | ||||||
| 	assert.NoError(suite.T(), err) | 	assert.NoError(suite.T(), err) | ||||||
|  | @ -146,10 +175,10 @@ func (suite *RepliesGetTestSuite) TestGetRepliesNext() { | ||||||
| 	t, err := streams.ToType(context.Background(), m) | 	t, err := streams.ToType(context.Background(), m) | ||||||
| 	assert.NoError(suite.T(), err) | 	assert.NoError(suite.T(), err) | ||||||
| 
 | 
 | ||||||
| 	page, ok := t.(vocab.ActivityStreamsCollectionPage) | 	page, ok := t.(vocab.ActivityStreamsOrderedCollectionPage) | ||||||
| 	assert.True(suite.T(), ok) | 	assert.True(suite.T(), ok) | ||||||
| 
 | 
 | ||||||
| 	assert.Equal(suite.T(), page.GetActivityStreamsItems().Len(), 1) | 	assert.Equal(suite.T(), page.GetActivityStreamsOrderedItems().Len(), 1) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (suite *RepliesGetTestSuite) TestGetRepliesLast() { | func (suite *RepliesGetTestSuite) TestGetRepliesLast() { | ||||||
|  | @ -162,7 +191,7 @@ func (suite *RepliesGetTestSuite) TestGetRepliesLast() { | ||||||
| 	// setup request | 	// setup request | ||||||
| 	recorder := httptest.NewRecorder() | 	recorder := httptest.NewRecorder() | ||||||
| 	ctx, _ := testrig.CreateGinTestContext(recorder, nil) | 	ctx, _ := testrig.CreateGinTestContext(recorder, nil) | ||||||
| 	ctx.Request = httptest.NewRequest(http.MethodGet, targetStatus.URI+"/replies?only_other_accounts=false&page=true&min_id=01FF25D5Q0DH7CHD57CTRS6WK0", nil) // the endpoint we're hitting | 	ctx.Request = httptest.NewRequest(http.MethodGet, targetStatus.URI+"/replies?min_id=01FF25D5Q0DH7CHD57CTRS6WK0&only_other_accounts=false", nil) | ||||||
| 	ctx.Request.Header.Set("accept", "application/activity+json") | 	ctx.Request.Header.Set("accept", "application/activity+json") | ||||||
| 	ctx.Request.Header.Set("Signature", signedRequest.SignatureHeader) | 	ctx.Request.Header.Set("Signature", signedRequest.SignatureHeader) | ||||||
| 	ctx.Request.Header.Set("Date", signedRequest.DateHeader) | 	ctx.Request.Header.Set("Date", signedRequest.DateHeader) | ||||||
|  | @ -189,15 +218,27 @@ func (suite *RepliesGetTestSuite) TestGetRepliesLast() { | ||||||
| 	// check response | 	// check response | ||||||
| 	suite.EqualValues(http.StatusOK, recorder.Code) | 	suite.EqualValues(http.StatusOK, recorder.Code) | ||||||
| 
 | 
 | ||||||
|  | 	// Read response body. | ||||||
| 	result := recorder.Result() | 	result := recorder.Result() | ||||||
| 	defer result.Body.Close() | 	defer result.Body.Close() | ||||||
| 	b, err := ioutil.ReadAll(result.Body) | 	b, err := io.ReadAll(result.Body) | ||||||
| 	assert.NoError(suite.T(), err) | 	assert.NoError(suite.T(), err) | ||||||
| 
 | 
 | ||||||
| 	fmt.Println(string(b)) | 	// Indent JSON | ||||||
| 	assert.Equal(suite.T(), `{"@context":"https://www.w3.org/ns/activitystreams","id":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies?page=true\u0026only_other_accounts=false\u0026min_id=01FF25D5Q0DH7CHD57CTRS6WK0","items":[],"next":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies?only_other_accounts=false\u0026page=true","partOf":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies","type":"CollectionPage"}`, string(b)) | 	// for readability. | ||||||
|  | 	b = indentJSON(b) | ||||||
|  | 
 | ||||||
|  | 	// Create JSON string of expected output. | ||||||
|  | 	expect := toJSON(map[string]any{ | ||||||
|  | 		"@context":     "https://www.w3.org/ns/activitystreams", | ||||||
|  | 		"type":         "OrderedCollectionPage", | ||||||
|  | 		"id":           targetStatus.URI + "/replies?min_id=01FF25D5Q0DH7CHD57CTRS6WK0&only_other_accounts=false", | ||||||
|  | 		"partOf":       targetStatus.URI + "/replies?only_other_accounts=false", | ||||||
|  | 		"orderedItems": []any{}, // empty | ||||||
|  | 		"totalItems":   1, | ||||||
|  | 	}) | ||||||
|  | 	assert.Equal(suite.T(), expect, string(b)) | ||||||
| 
 | 
 | ||||||
| 	// should be a Collection |  | ||||||
| 	m := make(map[string]interface{}) | 	m := make(map[string]interface{}) | ||||||
| 	err = json.Unmarshal(b, &m) | 	err = json.Unmarshal(b, &m) | ||||||
| 	assert.NoError(suite.T(), err) | 	assert.NoError(suite.T(), err) | ||||||
|  | @ -205,12 +246,39 @@ func (suite *RepliesGetTestSuite) TestGetRepliesLast() { | ||||||
| 	t, err := streams.ToType(context.Background(), m) | 	t, err := streams.ToType(context.Background(), m) | ||||||
| 	assert.NoError(suite.T(), err) | 	assert.NoError(suite.T(), err) | ||||||
| 
 | 
 | ||||||
| 	page, ok := t.(vocab.ActivityStreamsCollectionPage) | 	page, ok := t.(vocab.ActivityStreamsOrderedCollectionPage) | ||||||
| 	assert.True(suite.T(), ok) | 	assert.True(suite.T(), ok) | ||||||
| 
 | 
 | ||||||
| 	assert.Equal(suite.T(), page.GetActivityStreamsItems().Len(), 0) | 	assert.Equal(suite.T(), page.GetActivityStreamsOrderedItems().Len(), 0) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func TestRepliesGetTestSuite(t *testing.T) { | func TestRepliesGetTestSuite(t *testing.T) { | ||||||
| 	suite.Run(t, new(RepliesGetTestSuite)) | 	suite.Run(t, new(RepliesGetTestSuite)) | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | // 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) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // indentJSON will return indented JSON from raw provided JSON. | ||||||
|  | func indentJSON(b []byte) []byte { | ||||||
|  | 	var dst bytes.Buffer | ||||||
|  | 	err := json.Indent(&dst, b, "", "  ") | ||||||
|  | 	if err != nil { | ||||||
|  | 		panic(err) | ||||||
|  | 	} | ||||||
|  | 	return dst.Bytes() | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -41,6 +41,10 @@ const ( | ||||||
| 	SinceIDKey = "since_id" | 	SinceIDKey = "since_id" | ||||||
| 	MinIDKey   = "min_id" | 	MinIDKey   = "min_id" | ||||||
| 
 | 
 | ||||||
|  | 	/* AP endpoint keys */ | ||||||
|  | 
 | ||||||
|  | 	OnlyOtherAccountsKey = "only_other_accounts" | ||||||
|  | 
 | ||||||
| 	/* Search keys */ | 	/* Search keys */ | ||||||
| 
 | 
 | ||||||
| 	SearchExcludeUnreviewedKey = "exclude_unreviewed" | 	SearchExcludeUnreviewedKey = "exclude_unreviewed" | ||||||
|  | @ -66,20 +70,6 @@ const ( | ||||||
| 	DomainPermissionImportKey = "import" | 	DomainPermissionImportKey = "import" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| // parseError returns gtserror.WithCode set to 400 Bad Request, to indicate |  | ||||||
| // to the caller that a key was set to a value that could not be parsed. |  | ||||||
| func parseError(key string, value, defaultValue any, err error) gtserror.WithCode { |  | ||||||
| 	err = fmt.Errorf("error parsing key %s with value %s as %T: %w", key, value, defaultValue, err) |  | ||||||
| 	return gtserror.NewErrorBadRequest(err, err.Error()) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // requiredError returns gtserror.WithCode set to 400 Bad Request, to indicate |  | ||||||
| // to the caller a required key value was not provided, or was empty. |  | ||||||
| func requiredError(key string) gtserror.WithCode { |  | ||||||
| 	err := fmt.Errorf("required key %s was not set or had empty value", key) |  | ||||||
| 	return gtserror.NewErrorBadRequest(err, err.Error()) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| /* | /* | ||||||
| 	Parse functions for *OPTIONAL* parameters with default values. | 	Parse functions for *OPTIONAL* parameters with default values. | ||||||
| */ | */ | ||||||
|  | @ -129,6 +119,10 @@ func ParseDomainPermissionImport(value string, defaultValue bool) (bool, gtserro | ||||||
| 	return parseBool(value, defaultValue, DomainPermissionImportKey) | 	return parseBool(value, defaultValue, DomainPermissionImportKey) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | func ParseOnlyOtherAccounts(value string, defaultValue bool) (bool, gtserror.WithCode) { | ||||||
|  | 	return parseBool(value, defaultValue, OnlyOtherAccountsKey) | ||||||
|  | } | ||||||
|  | 
 | ||||||
| /* | /* | ||||||
| 	Parse functions for *REQUIRED* parameters. | 	Parse functions for *REQUIRED* parameters. | ||||||
| */ | */ | ||||||
|  | @ -248,3 +242,17 @@ func parseInt(value string, defaultValue int, max int, min int, key string) (int | ||||||
| 
 | 
 | ||||||
| 	return i, nil | 	return i, nil | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | // parseError returns gtserror.WithCode set to 400 Bad Request, to indicate | ||||||
|  | // to the caller that a key was set to a value that could not be parsed. | ||||||
|  | func parseError(key string, value, defaultValue any, err error) gtserror.WithCode { | ||||||
|  | 	err = fmt.Errorf("error parsing key %s with value %s as %T: %w", key, value, defaultValue, err) | ||||||
|  | 	return gtserror.NewErrorBadRequest(err, err.Error()) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // requiredError returns gtserror.WithCode set to 400 Bad Request, to indicate | ||||||
|  | // to the caller a required key value was not provided, or was empty. | ||||||
|  | func requiredError(key string) gtserror.WithCode { | ||||||
|  | 	err := fmt.Errorf("required key %s was not set or had empty value", key) | ||||||
|  | 	return gtserror.NewErrorBadRequest(err, err.Error()) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -18,7 +18,6 @@ | ||||||
| package bundb | package bundb | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"container/list" |  | ||||||
| 	"context" | 	"context" | ||||||
| 	"errors" | 	"errors" | ||||||
| 	"time" | 	"time" | ||||||
|  | @ -515,16 +514,7 @@ func (s *statusDB) GetStatusesUsingEmoji(ctx context.Context, emojiID string) ([ | ||||||
| 	return s.GetStatusesByIDs(ctx, statusIDs) | 	return s.GetStatusesByIDs(ctx, statusIDs) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (s *statusDB) GetStatusParents(ctx context.Context, status *gtsmodel.Status, onlyDirect bool) ([]*gtsmodel.Status, error) { | func (s *statusDB) GetStatusParents(ctx context.Context, status *gtsmodel.Status) ([]*gtsmodel.Status, error) { | ||||||
| 	if onlyDirect { |  | ||||||
| 		// Only want the direct parent, no further than first level |  | ||||||
| 		parent, err := s.GetStatusByID(ctx, status.InReplyToID) |  | ||||||
| 		if err != nil { |  | ||||||
| 			return nil, err |  | ||||||
| 		} |  | ||||||
| 		return []*gtsmodel.Status{parent}, nil |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	var parents []*gtsmodel.Status | 	var parents []*gtsmodel.Status | ||||||
| 
 | 
 | ||||||
| 	for id := status.InReplyToID; id != ""; { | 	for id := status.InReplyToID; id != ""; { | ||||||
|  | @ -533,7 +523,7 @@ func (s *statusDB) GetStatusParents(ctx context.Context, status *gtsmodel.Status | ||||||
| 			return nil, err | 			return nil, err | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		// Append parent to slice | 		// Append parent status to slice | ||||||
| 		parents = append(parents, parent) | 		parents = append(parents, parent) | ||||||
| 
 | 
 | ||||||
| 		// Set the next parent ID | 		// Set the next parent ID | ||||||
|  | @ -543,67 +533,33 @@ func (s *statusDB) GetStatusParents(ctx context.Context, status *gtsmodel.Status | ||||||
| 	return parents, nil | 	return parents, nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (s *statusDB) GetStatusChildren(ctx context.Context, status *gtsmodel.Status, onlyDirect bool, minID string) ([]*gtsmodel.Status, error) { | func (s *statusDB) GetStatusChildren(ctx context.Context, statusID string) ([]*gtsmodel.Status, error) { | ||||||
| 	foundStatuses := &list.List{} | 	// Get all replies for the currently set status. | ||||||
| 	foundStatuses.PushFront(status) | 	replies, err := s.GetStatusReplies(ctx, statusID) | ||||||
| 	s.statusChildren(ctx, status, foundStatuses, onlyDirect, minID) | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 	children := []*gtsmodel.Status{} | 	// Make estimated preallocation based on direct replies. | ||||||
| 	for e := foundStatuses.Front(); e != nil; e = e.Next() { | 	children := make([]*gtsmodel.Status, 0, len(replies)*2) | ||||||
| 		// only append children, not the overall parent status | 
 | ||||||
| 		entry, ok := e.Value.(*gtsmodel.Status) | 	for _, status := range replies { | ||||||
| 		if !ok { | 		// Append status to children. | ||||||
| 			log.Panic(ctx, "found status could not be asserted to *gtsmodel.Status") | 		children = append(children, status) | ||||||
|  | 
 | ||||||
|  | 		// Further, recursively get all children for this reply. | ||||||
|  | 		grandChildren, err := s.GetStatusChildren(ctx, status.ID) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return nil, err | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		if entry.ID != status.ID { | 		// Append all sub children after status. | ||||||
| 			children = append(children, entry) | 		children = append(children, grandChildren...) | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	return children, nil | 	return children, nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (s *statusDB) statusChildren(ctx context.Context, status *gtsmodel.Status, foundStatuses *list.List, onlyDirect bool, minID string) { |  | ||||||
| 	childIDs, err := s.getStatusReplyIDs(ctx, status.ID) |  | ||||||
| 	if err != nil && !errors.Is(err, db.ErrNoEntries) { |  | ||||||
| 		log.Errorf(ctx, "error getting status %s children: %v", status.ID, err) |  | ||||||
| 		return |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	for _, id := range childIDs { |  | ||||||
| 		if id <= minID { |  | ||||||
| 			continue |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		// Fetch child with ID from database |  | ||||||
| 		child, err := s.GetStatusByID(ctx, id) |  | ||||||
| 		if err != nil { |  | ||||||
| 			log.Errorf(ctx, "error getting child status %q: %v", id, err) |  | ||||||
| 			continue |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 	insertLoop: |  | ||||||
| 		for e := foundStatuses.Front(); e != nil; e = e.Next() { |  | ||||||
| 			entry, ok := e.Value.(*gtsmodel.Status) |  | ||||||
| 			if !ok { |  | ||||||
| 				log.Panic(ctx, "found status could not be asserted to *gtsmodel.Status") |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			if child.InReplyToAccountID != "" && entry.ID == child.InReplyToID { |  | ||||||
| 				foundStatuses.InsertAfter(child, e) |  | ||||||
| 				break insertLoop |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		// if we're not only looking for direct children of status, then do the same children-finding |  | ||||||
| 		// operation for the found child status too. |  | ||||||
| 		if !onlyDirect { |  | ||||||
| 			s.statusChildren(ctx, child, foundStatuses, false, minID) |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (s *statusDB) GetStatusReplies(ctx context.Context, statusID string) ([]*gtsmodel.Status, error) { | func (s *statusDB) GetStatusReplies(ctx context.Context, statusID string) ([]*gtsmodel.Status, error) { | ||||||
| 	statusIDs, err := s.getStatusReplyIDs(ctx, statusID) | 	statusIDs, err := s.getStatusReplyIDs(ctx, statusID) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
|  |  | ||||||
|  | @ -163,9 +163,21 @@ func (suite *StatusTestSuite) TestGetStatusTwice() { | ||||||
| 	suite.Less(duration2, duration1) | 	suite.Less(duration2, duration1) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | func (suite *StatusTestSuite) TestGetStatusReplies() { | ||||||
|  | 	targetStatus := suite.testStatuses["local_account_1_status_1"] | ||||||
|  | 	children, err := suite.db.GetStatusReplies(context.Background(), targetStatus.ID) | ||||||
|  | 	suite.NoError(err) | ||||||
|  | 	suite.Len(children, 2) | ||||||
|  | 	for _, c := range children { | ||||||
|  | 		suite.Equal(targetStatus.URI, c.InReplyToURI) | ||||||
|  | 		suite.Equal(targetStatus.AccountID, c.InReplyToAccountID) | ||||||
|  | 		suite.Equal(targetStatus.ID, c.InReplyToID) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
| func (suite *StatusTestSuite) TestGetStatusChildren() { | func (suite *StatusTestSuite) TestGetStatusChildren() { | ||||||
| 	targetStatus := suite.testStatuses["local_account_1_status_1"] | 	targetStatus := suite.testStatuses["local_account_1_status_1"] | ||||||
| 	children, err := suite.db.GetStatusChildren(context.Background(), targetStatus, true, "") | 	children, err := suite.db.GetStatusChildren(context.Background(), targetStatus.ID) | ||||||
| 	suite.NoError(err) | 	suite.NoError(err) | ||||||
| 	suite.Len(children, 2) | 	suite.Len(children, 2) | ||||||
| 	for _, c := range children { | 	for _, c := range children { | ||||||
|  |  | ||||||
|  | @ -18,6 +18,7 @@ | ||||||
| package bundb | package bundb | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
|  | 	"slices" | ||||||
| 	"strings" | 	"strings" | ||||||
| 
 | 
 | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/cache" | 	"github.com/superseriousbusiness/gotosocial/internal/cache" | ||||||
|  | @ -99,7 +100,7 @@ func loadPagedIDs(cache *cache.SliceCache[string], key string, page *paging.Page | ||||||
| 	// order. Depending on the paging requested | 	// order. Depending on the paging requested | ||||||
| 	// this may be an unexpected order. | 	// this may be an unexpected order. | ||||||
| 	if page.GetOrder().Ascending() { | 	if page.GetOrder().Ascending() { | ||||||
| 		ids = paging.Reverse(ids) | 		slices.Reverse(ids) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Page the resulting IDs. | 	// Page the resulting IDs. | ||||||
|  |  | ||||||
|  | @ -55,7 +55,7 @@ type Status interface { | ||||||
| 	// GetStatusesUsingEmoji fetches all status models using emoji with given ID stored in their 'emojis' column. | 	// GetStatusesUsingEmoji fetches all status models using emoji with given ID stored in their 'emojis' column. | ||||||
| 	GetStatusesUsingEmoji(ctx context.Context, emojiID string) ([]*gtsmodel.Status, error) | 	GetStatusesUsingEmoji(ctx context.Context, emojiID string) ([]*gtsmodel.Status, error) | ||||||
| 
 | 
 | ||||||
| 	// GetStatusReplies returns the *direct* (i.e. in_reply_to_id column) replies to this status ID. | 	// GetStatusReplies returns the *direct* (i.e. in_reply_to_id column) replies to this status ID, ordered DESC by ID. | ||||||
| 	GetStatusReplies(ctx context.Context, statusID string) ([]*gtsmodel.Status, error) | 	GetStatusReplies(ctx context.Context, statusID string) ([]*gtsmodel.Status, error) | ||||||
| 
 | 
 | ||||||
| 	// CountStatusReplies returns the number of stored *direct* (i.e. in_reply_to_id column) replies to this status ID. | 	// CountStatusReplies returns the number of stored *direct* (i.e. in_reply_to_id column) replies to this status ID. | ||||||
|  | @ -71,14 +71,10 @@ type Status interface { | ||||||
| 	IsStatusBoostedBy(ctx context.Context, statusID string, accountID string) (bool, error) | 	IsStatusBoostedBy(ctx context.Context, statusID string, accountID string) (bool, error) | ||||||
| 
 | 
 | ||||||
| 	// GetStatusParents gets the parent statuses of a given status. | 	// GetStatusParents gets the parent statuses of a given status. | ||||||
| 	// | 	GetStatusParents(ctx context.Context, status *gtsmodel.Status) ([]*gtsmodel.Status, error) | ||||||
| 	// If onlyDirect is true, only the immediate parent will be returned. |  | ||||||
| 	GetStatusParents(ctx context.Context, status *gtsmodel.Status, onlyDirect bool) ([]*gtsmodel.Status, error) |  | ||||||
| 
 | 
 | ||||||
| 	// GetStatusChildren gets the child statuses of a given status. | 	// GetStatusChildren gets the child statuses of a given status. | ||||||
| 	// | 	GetStatusChildren(ctx context.Context, statusID string) ([]*gtsmodel.Status, error) | ||||||
| 	// If onlyDirect is true, only the immediate children will be returned. |  | ||||||
| 	GetStatusChildren(ctx context.Context, status *gtsmodel.Status, onlyDirect bool, minID string) ([]*gtsmodel.Status, error) |  | ||||||
| 
 | 
 | ||||||
| 	// IsStatusBookmarkedBy checks if a given status has been bookmarked by a given account ID | 	// IsStatusBookmarkedBy checks if a given status has been bookmarked by a given account ID | ||||||
| 	IsStatusBookmarkedBy(ctx context.Context, status *gtsmodel.Status, accountID string) (bool, error) | 	IsStatusBookmarkedBy(ctx context.Context, status *gtsmodel.Status, accountID string) (bool, error) | ||||||
|  |  | ||||||
|  | @ -131,3 +131,20 @@ func (b Boundary) Find(in []string) int { | ||||||
| 	} | 	} | ||||||
| 	return -1 | 	return -1 | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | // Boundary_FindFunc is functionally equivalent to Boundary{}.Find() but for an arbitrary type with ID. | ||||||
|  | // Note: this is not a Boundary{} method as Go generics are not supported in method receiver functions. | ||||||
|  | func Boundary_FindFunc[T any](b Boundary, in []T, get func(T) string) int { //nolint:revive | ||||||
|  | 	if get == nil { | ||||||
|  | 		panic("nil function") | ||||||
|  | 	} | ||||||
|  | 	if b.Value == "" { | ||||||
|  | 		return -1 | ||||||
|  | 	} | ||||||
|  | 	for i := range in { | ||||||
|  | 		if get(in[i]) == b.Value { | ||||||
|  | 			return i | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	return -1 | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -19,9 +19,8 @@ package paging | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"net/url" | 	"net/url" | ||||||
|  | 	"slices" | ||||||
| 	"strconv" | 	"strconv" | ||||||
| 
 |  | ||||||
| 	"golang.org/x/exp/slices" |  | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| type Page struct { | type Page struct { | ||||||
|  | @ -117,7 +116,7 @@ func (p *Page) Page(in []string) []string { | ||||||
| 
 | 
 | ||||||
| 			// Output slice must | 			// Output slice must | ||||||
| 			// ALWAYS be descending. | 			// ALWAYS be descending. | ||||||
| 			in = Reverse(in) | 			slices.Reverse(in) | ||||||
| 		} | 		} | ||||||
| 	} else { | 	} else { | ||||||
| 		// Default sort is descending, | 		// Default sort is descending, | ||||||
|  | @ -143,6 +142,66 @@ func (p *Page) Page(in []string) []string { | ||||||
| 	return in | 	return in | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | // Page_PageFunc is functionally equivalent to Page{}.Page(), but for an arbitrary type with ID. | ||||||
|  | // Note: this is not a Page{} method as Go generics are not supported in method receiver functions. | ||||||
|  | func Page_PageFunc[WithID any](p *Page, in []WithID, get func(WithID) string) []WithID { //nolint:revive | ||||||
|  | 	if p == nil { | ||||||
|  | 		// no paging. | ||||||
|  | 		return in | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if p.order().Ascending() { | ||||||
|  | 		// Sort type is ascending, input | ||||||
|  | 		// data is assumed to be ascending. | ||||||
|  | 
 | ||||||
|  | 		if minIdx := Boundary_FindFunc(p.Min, in, get); minIdx != -1 { | ||||||
|  | 			// Reslice skipping up to min. | ||||||
|  | 			in = in[minIdx+1:] | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		if maxIdx := Boundary_FindFunc(p.Max, in, get); maxIdx != -1 { | ||||||
|  | 			// Reslice stripping past max. | ||||||
|  | 			in = in[:maxIdx] | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		if p.Limit > 0 && p.Limit < len(in) { | ||||||
|  | 			// Reslice input to limit. | ||||||
|  | 			in = in[:p.Limit] | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		if len(in) > 1 { | ||||||
|  | 			// Clone input before | ||||||
|  | 			// any modifications. | ||||||
|  | 			in = slices.Clone(in) | ||||||
|  | 
 | ||||||
|  | 			// Output slice must | ||||||
|  | 			// ALWAYS be descending. | ||||||
|  | 			slices.Reverse(in) | ||||||
|  | 		} | ||||||
|  | 	} else { | ||||||
|  | 		// Default sort is descending, | ||||||
|  | 		// catching all cases when NOT | ||||||
|  | 		// ascending (even zero value). | ||||||
|  | 
 | ||||||
|  | 		if maxIdx := Boundary_FindFunc(p.Max, in, get); maxIdx != -1 { | ||||||
|  | 			// Reslice skipping up to max. | ||||||
|  | 			in = in[maxIdx+1:] | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		if minIdx := Boundary_FindFunc(p.Min, in, get); minIdx != -1 { | ||||||
|  | 			// Reslice stripping past min. | ||||||
|  | 			in = in[:minIdx] | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		if p.Limit > 0 && p.Limit < len(in) { | ||||||
|  | 			// Reslice input to limit. | ||||||
|  | 			in = in[:p.Limit] | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return in | ||||||
|  | } | ||||||
|  | 
 | ||||||
| // Next creates a new instance for the next returnable page, using | // Next creates a new instance for the next returnable page, using | ||||||
| // given max value. This preserves original limit and max key name. | // given max value. This preserves original limit and max key name. | ||||||
| func (p *Page) Next(lo, hi string) *Page { | func (p *Page) Next(lo, hi string) *Page { | ||||||
|  | @ -225,21 +284,24 @@ func (p *Page) ToLinkURL(proto, host, path string, queryParams url.Values) *url. | ||||||
| 	if queryParams == nil { | 	if queryParams == nil { | ||||||
| 		// Allocate new query parameters. | 		// Allocate new query parameters. | ||||||
| 		queryParams = make(url.Values) | 		queryParams = make(url.Values) | ||||||
|  | 	} else { | ||||||
|  | 		// Before edit clone existing params. | ||||||
|  | 		queryParams = cloneQuery(queryParams) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if p.Min.Value != "" { | 	if p.Min.Value != "" { | ||||||
| 		// A page-minimum query parameter is available. | 		// A page-minimum query parameter is available. | ||||||
| 		queryParams.Add(p.Min.Name, p.Min.Value) | 		queryParams.Set(p.Min.Name, p.Min.Value) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if p.Max.Value != "" { | 	if p.Max.Value != "" { | ||||||
| 		// A page-maximum query parameter is available. | 		// A page-maximum query parameter is available. | ||||||
| 		queryParams.Add(p.Max.Name, p.Max.Value) | 		queryParams.Set(p.Max.Name, p.Max.Value) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if p.Limit > 0 { | 	if p.Limit > 0 { | ||||||
| 		// A page limit query parameter is available. | 		// A page limit query parameter is available. | ||||||
| 		queryParams.Add("limit", strconv.Itoa(p.Limit)) | 		queryParams.Set("limit", strconv.Itoa(p.Limit)) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Build URL string. | 	// Build URL string. | ||||||
|  | @ -250,3 +312,12 @@ func (p *Page) ToLinkURL(proto, host, path string, queryParams url.Values) *url. | ||||||
| 		RawQuery: queryParams.Encode(), | 		RawQuery: queryParams.Encode(), | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | // cloneQuery clones input map of url values. | ||||||
|  | func cloneQuery(src url.Values) url.Values { | ||||||
|  | 	dst := make(url.Values, len(src)) | ||||||
|  | 	for k, vs := range src { | ||||||
|  | 		dst[k] = slices.Clone(vs) | ||||||
|  | 	} | ||||||
|  | 	return dst | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -19,12 +19,12 @@ package paging_test | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"math/rand" | 	"math/rand" | ||||||
|  | 	"slices" | ||||||
| 	"testing" | 	"testing" | ||||||
| 	"time" | 	"time" | ||||||
| 
 | 
 | ||||||
| 	"github.com/oklog/ulid" | 	"github.com/oklog/ulid" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/paging" | 	"github.com/superseriousbusiness/gotosocial/internal/paging" | ||||||
| 	"golang.org/x/exp/slices" |  | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| // random reader according to current-time source seed. | // random reader according to current-time source seed. | ||||||
|  | @ -77,9 +77,7 @@ func TestPage(t *testing.T) { | ||||||
| var cases = []Case{ | var cases = []Case{ | ||||||
| 	CreateCase("minID and maxID set", func(ids []string) ([]string, *paging.Page, []string) { | 	CreateCase("minID and maxID set", func(ids []string) ([]string, *paging.Page, []string) { | ||||||
| 		// Ensure input slice sorted ascending for min_id | 		// Ensure input slice sorted ascending for min_id | ||||||
| 		slices.SortFunc(ids, func(a, b string) bool { | 		slices.SortFunc(ids, ascending) | ||||||
| 			return a > b // i.e. largest at lowest idx |  | ||||||
| 		}) |  | ||||||
| 
 | 
 | ||||||
| 		// Select random indices in slice. | 		// Select random indices in slice. | ||||||
| 		minIdx := randRd.Intn(len(ids)) | 		minIdx := randRd.Intn(len(ids)) | ||||||
|  | @ -93,7 +91,7 @@ var cases = []Case{ | ||||||
| 		expect := slices.Clone(ids) | 		expect := slices.Clone(ids) | ||||||
| 		expect = cutLower(expect, minID) | 		expect = cutLower(expect, minID) | ||||||
| 		expect = cutUpper(expect, maxID) | 		expect = cutUpper(expect, maxID) | ||||||
| 		expect = paging.Reverse(expect) | 		slices.Reverse(expect) | ||||||
| 
 | 
 | ||||||
| 		// Return page and expected IDs. | 		// Return page and expected IDs. | ||||||
| 		return ids, &paging.Page{ | 		return ids, &paging.Page{ | ||||||
|  | @ -103,9 +101,7 @@ var cases = []Case{ | ||||||
| 	}), | 	}), | ||||||
| 	CreateCase("minID, maxID and limit set", func(ids []string) ([]string, *paging.Page, []string) { | 	CreateCase("minID, maxID and limit set", func(ids []string) ([]string, *paging.Page, []string) { | ||||||
| 		// Ensure input slice sorted ascending for min_id | 		// Ensure input slice sorted ascending for min_id | ||||||
| 		slices.SortFunc(ids, func(a, b string) bool { | 		slices.SortFunc(ids, ascending) | ||||||
| 			return a > b // i.e. largest at lowest idx |  | ||||||
| 		}) |  | ||||||
| 
 | 
 | ||||||
| 		// Select random parameters in slice. | 		// Select random parameters in slice. | ||||||
| 		minIdx := randRd.Intn(len(ids)) | 		minIdx := randRd.Intn(len(ids)) | ||||||
|  | @ -120,7 +116,7 @@ var cases = []Case{ | ||||||
| 		expect := slices.Clone(ids) | 		expect := slices.Clone(ids) | ||||||
| 		expect = cutLower(expect, minID) | 		expect = cutLower(expect, minID) | ||||||
| 		expect = cutUpper(expect, maxID) | 		expect = cutUpper(expect, maxID) | ||||||
| 		expect = paging.Reverse(expect) | 		slices.Reverse(expect) | ||||||
| 
 | 
 | ||||||
| 		// Now limit the slice. | 		// Now limit the slice. | ||||||
| 		if limit < len(expect) { | 		if limit < len(expect) { | ||||||
|  | @ -136,9 +132,7 @@ var cases = []Case{ | ||||||
| 	}), | 	}), | ||||||
| 	CreateCase("minID, maxID and too-large limit set", func(ids []string) ([]string, *paging.Page, []string) { | 	CreateCase("minID, maxID and too-large limit set", func(ids []string) ([]string, *paging.Page, []string) { | ||||||
| 		// Ensure input slice sorted ascending for min_id | 		// Ensure input slice sorted ascending for min_id | ||||||
| 		slices.SortFunc(ids, func(a, b string) bool { | 		slices.SortFunc(ids, ascending) | ||||||
| 			return a > b // i.e. largest at lowest idx |  | ||||||
| 		}) |  | ||||||
| 
 | 
 | ||||||
| 		// Select random parameters in slice. | 		// Select random parameters in slice. | ||||||
| 		minIdx := randRd.Intn(len(ids)) | 		minIdx := randRd.Intn(len(ids)) | ||||||
|  | @ -152,7 +146,7 @@ var cases = []Case{ | ||||||
| 		expect := slices.Clone(ids) | 		expect := slices.Clone(ids) | ||||||
| 		expect = cutLower(expect, minID) | 		expect = cutLower(expect, minID) | ||||||
| 		expect = cutUpper(expect, maxID) | 		expect = cutUpper(expect, maxID) | ||||||
| 		expect = paging.Reverse(expect) | 		slices.Reverse(expect) | ||||||
| 
 | 
 | ||||||
| 		// Return page and expected IDs. | 		// Return page and expected IDs. | ||||||
| 		return ids, &paging.Page{ | 		return ids, &paging.Page{ | ||||||
|  | @ -163,9 +157,7 @@ var cases = []Case{ | ||||||
| 	}), | 	}), | ||||||
| 	CreateCase("sinceID and maxID set", func(ids []string) ([]string, *paging.Page, []string) { | 	CreateCase("sinceID and maxID set", func(ids []string) ([]string, *paging.Page, []string) { | ||||||
| 		// Ensure input slice sorted descending for since_id | 		// Ensure input slice sorted descending for since_id | ||||||
| 		slices.SortFunc(ids, func(a, b string) bool { | 		slices.SortFunc(ids, descending) | ||||||
| 			return a < b // i.e. smallest at lowest idx |  | ||||||
| 		}) |  | ||||||
| 
 | 
 | ||||||
| 		// Select random indices in slice. | 		// Select random indices in slice. | ||||||
| 		sinceIdx := randRd.Intn(len(ids)) | 		sinceIdx := randRd.Intn(len(ids)) | ||||||
|  | @ -188,9 +180,7 @@ var cases = []Case{ | ||||||
| 	}), | 	}), | ||||||
| 	CreateCase("maxID set", func(ids []string) ([]string, *paging.Page, []string) { | 	CreateCase("maxID set", func(ids []string) ([]string, *paging.Page, []string) { | ||||||
| 		// Ensure input slice sorted descending for max_id | 		// Ensure input slice sorted descending for max_id | ||||||
| 		slices.SortFunc(ids, func(a, b string) bool { | 		slices.SortFunc(ids, descending) | ||||||
| 			return a < b // i.e. smallest at lowest idx |  | ||||||
| 		}) |  | ||||||
| 
 | 
 | ||||||
| 		// Select random indices in slice. | 		// Select random indices in slice. | ||||||
| 		maxIdx := randRd.Intn(len(ids)) | 		maxIdx := randRd.Intn(len(ids)) | ||||||
|  | @ -209,9 +199,7 @@ var cases = []Case{ | ||||||
| 	}), | 	}), | ||||||
| 	CreateCase("sinceID set", func(ids []string) ([]string, *paging.Page, []string) { | 	CreateCase("sinceID set", func(ids []string) ([]string, *paging.Page, []string) { | ||||||
| 		// Ensure input slice sorted descending for since_id | 		// Ensure input slice sorted descending for since_id | ||||||
| 		slices.SortFunc(ids, func(a, b string) bool { | 		slices.SortFunc(ids, descending) | ||||||
| 			return a < b |  | ||||||
| 		}) |  | ||||||
| 
 | 
 | ||||||
| 		// Select random indices in slice. | 		// Select random indices in slice. | ||||||
| 		sinceIdx := randRd.Intn(len(ids)) | 		sinceIdx := randRd.Intn(len(ids)) | ||||||
|  | @ -230,9 +218,7 @@ var cases = []Case{ | ||||||
| 	}), | 	}), | ||||||
| 	CreateCase("minID set", func(ids []string) ([]string, *paging.Page, []string) { | 	CreateCase("minID set", func(ids []string) ([]string, *paging.Page, []string) { | ||||||
| 		// Ensure input slice sorted ascending for min_id | 		// Ensure input slice sorted ascending for min_id | ||||||
| 		slices.SortFunc(ids, func(a, b string) bool { | 		slices.SortFunc(ids, ascending) | ||||||
| 			return a > b // i.e. largest at lowest idx |  | ||||||
| 		}) |  | ||||||
| 
 | 
 | ||||||
| 		// Select random indices in slice. | 		// Select random indices in slice. | ||||||
| 		minIdx := randRd.Intn(len(ids)) | 		minIdx := randRd.Intn(len(ids)) | ||||||
|  | @ -243,7 +229,7 @@ var cases = []Case{ | ||||||
| 		// Create expected output. | 		// Create expected output. | ||||||
| 		expect := slices.Clone(ids) | 		expect := slices.Clone(ids) | ||||||
| 		expect = cutLower(expect, minID) | 		expect = cutLower(expect, minID) | ||||||
| 		expect = paging.Reverse(expect) | 		slices.Reverse(expect) | ||||||
| 
 | 
 | ||||||
| 		// Return page and expected IDs. | 		// Return page and expected IDs. | ||||||
| 		return ids, &paging.Page{ | 		return ids, &paging.Page{ | ||||||
|  | @ -296,3 +282,21 @@ func generateSlice(len int) []string { | ||||||
| 	} | 	} | ||||||
| 	return in | 	return in | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | func ascending(a, b string) int { | ||||||
|  | 	if a > b { | ||||||
|  | 		return 1 | ||||||
|  | 	} else if a < b { | ||||||
|  | 		return -1 | ||||||
|  | 	} | ||||||
|  | 	return 0 | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func descending(a, b string) int { | ||||||
|  | 	if a < b { | ||||||
|  | 		return 1 | ||||||
|  | 	} else if a > b { | ||||||
|  | 		return -1 | ||||||
|  | 	} | ||||||
|  | 	return 0 | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -1,43 +0,0 @@ | ||||||
| // 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 paging |  | ||||||
| 
 |  | ||||||
| // Reverse will reverse the given input slice. |  | ||||||
| func Reverse(in []string) []string { |  | ||||||
| 	var ( |  | ||||||
| 		// Start at front. |  | ||||||
| 		i = 0 |  | ||||||
| 
 |  | ||||||
| 		// Start at back. |  | ||||||
| 		j = len(in) - 1 |  | ||||||
| 	) |  | ||||||
| 
 |  | ||||||
| 	for i < j { |  | ||||||
| 		// Swap i,j index values in slice. |  | ||||||
| 		in[i], in[j] = in[j], in[i] |  | ||||||
| 
 |  | ||||||
| 		// incr + decr, |  | ||||||
| 		// looping until |  | ||||||
| 		// they meet in |  | ||||||
| 		// the middle. |  | ||||||
| 		i++ |  | ||||||
| 		j-- |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	return in |  | ||||||
| } |  | ||||||
|  | @ -47,8 +47,15 @@ func (p *Processor) InboxPost(ctx context.Context, w http.ResponseWriter, r *htt | ||||||
| 
 | 
 | ||||||
| // OutboxGet returns the activitypub representation of a local user's outbox. | // OutboxGet returns the activitypub representation of a local user's outbox. | ||||||
| // This contains links to PUBLIC posts made by this user. | // 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) (interface{}, gtserror.WithCode) { | func (p *Processor) OutboxGet( | ||||||
| 	requestedAccount, _, errWithCode := p.authenticate(ctx, requestedUsername) | 	ctx context.Context, | ||||||
|  | 	requestedUser string, | ||||||
|  | 	page bool, | ||||||
|  | 	maxID string, | ||||||
|  | 	minID string, | ||||||
|  | ) (interface{}, gtserror.WithCode) { | ||||||
|  | 	// Authenticate the incoming request, getting related user accounts. | ||||||
|  | 	_, receiver, errWithCode := p.authenticate(ctx, requestedUser) | ||||||
| 	if errWithCode != nil { | 	if errWithCode != nil { | ||||||
| 		return nil, errWithCode | 		return nil, errWithCode | ||||||
| 	} | 	} | ||||||
|  | @ -70,7 +77,7 @@ func (p *Processor) OutboxGet(ctx context.Context, requestedUsername string, pag | ||||||
| 				"last": "https://example.org/users/whatever/outbox?min_id=0&page=true" | 				"last": "https://example.org/users/whatever/outbox?min_id=0&page=true" | ||||||
| 			} | 			} | ||||||
| 		*/ | 		*/ | ||||||
| 		collection, err := p.converter.OutboxToASCollection(ctx, requestedAccount.OutboxURI) | 		collection, err := p.converter.OutboxToASCollection(ctx, receiver.OutboxURI) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return nil, gtserror.NewErrorInternalError(err) | 			return nil, gtserror.NewErrorInternalError(err) | ||||||
| 		} | 		} | ||||||
|  | @ -85,15 +92,16 @@ func (p *Processor) OutboxGet(ctx context.Context, requestedUsername string, pag | ||||||
| 
 | 
 | ||||||
| 	// scenario 2 -- get the requested page | 	// scenario 2 -- get the requested page | ||||||
| 	// limit pages to 30 entries per page | 	// limit pages to 30 entries per page | ||||||
| 	publicStatuses, err := p.state.DB.GetAccountStatuses(ctx, requestedAccount.ID, 30, true, true, maxID, minID, false, true) | 	publicStatuses, err := p.state.DB.GetAccountStatuses(ctx, receiver.ID, 30, true, true, maxID, minID, false, true) | ||||||
| 	if err != nil && !errors.Is(err, db.ErrNoEntries) { | 	if err != nil && !errors.Is(err, db.ErrNoEntries) { | ||||||
| 		return nil, gtserror.NewErrorInternalError(err) | 		return nil, gtserror.NewErrorInternalError(err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	outboxPage, err := p.converter.StatusesToASOutboxPage(ctx, requestedAccount.OutboxURI, maxID, minID, publicStatuses) | 	outboxPage, err := p.converter.StatusesToASOutboxPage(ctx, receiver.OutboxURI, maxID, minID, publicStatuses) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return nil, gtserror.NewErrorInternalError(err) | 		return nil, gtserror.NewErrorInternalError(err) | ||||||
| 	} | 	} | ||||||
|  | 
 | ||||||
| 	data, err = ap.Serialize(outboxPage) | 	data, err = ap.Serialize(outboxPage) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return nil, gtserror.NewErrorInternalError(err) | 		return nil, gtserror.NewErrorInternalError(err) | ||||||
|  | @ -104,21 +112,22 @@ 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 | // 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. | // authentication before returning a JSON serializable interface to the caller. | ||||||
| func (p *Processor) FollowersGet(ctx context.Context, requestedUsername string, page *paging.Page) (interface{}, gtserror.WithCode) { | func (p *Processor) FollowersGet(ctx context.Context, requestedUser string, page *paging.Page) (interface{}, gtserror.WithCode) { | ||||||
| 	requestedAccount, _, errWithCode := p.authenticate(ctx, requestedUsername) | 	// Authenticate the incoming request, getting related user accounts. | ||||||
|  | 	_, receiver, errWithCode := p.authenticate(ctx, requestedUser) | ||||||
| 	if errWithCode != nil { | 	if errWithCode != nil { | ||||||
| 		return nil, errWithCode | 		return nil, errWithCode | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Parse the collection ID object from account's followers URI. | 	// Parse the collection ID object from account's followers URI. | ||||||
| 	collectionID, err := url.Parse(requestedAccount.FollowersURI) | 	collectionID, err := url.Parse(receiver.FollowersURI) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		err := gtserror.Newf("error parsing account followers uri %s: %w", requestedAccount.FollowersURI, err) | 		err := gtserror.Newf("error parsing account followers uri %s: %w", receiver.FollowersURI, err) | ||||||
| 		return nil, gtserror.NewErrorInternalError(err) | 		return nil, gtserror.NewErrorInternalError(err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Calculate total number of followers available for account. | 	// Calculate total number of followers available for account. | ||||||
| 	total, err := p.state.DB.CountAccountFollowers(ctx, requestedAccount.ID) | 	total, err := p.state.DB.CountAccountFollowers(ctx, receiver.ID) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		err := gtserror.Newf("error counting followers: %w", err) | 		err := gtserror.Newf("error counting followers: %w", err) | ||||||
| 		return nil, gtserror.NewErrorInternalError(err) | 		return nil, gtserror.NewErrorInternalError(err) | ||||||
|  | @ -126,30 +135,36 @@ func (p *Processor) FollowersGet(ctx context.Context, requestedUsername string, | ||||||
| 
 | 
 | ||||||
| 	var obj vocab.Type | 	var obj vocab.Type | ||||||
| 
 | 
 | ||||||
| 	// Start building AS collection params. | 	// Start the AS collection params. | ||||||
| 	var params ap.CollectionParams | 	var params ap.CollectionParams | ||||||
| 	params.ID = collectionID | 	params.ID = collectionID | ||||||
| 	params.Total = total | 	params.Total = total | ||||||
| 
 | 
 | ||||||
| 	if page == nil { | 	if page == nil { | ||||||
| 		// i.e. paging disabled, the simplest case. | 		// i.e. paging disabled, return collection | ||||||
| 		// | 		// that links to first page (i.e. path below). | ||||||
| 		// Just build collection object from params. | 		params.Query = make(url.Values, 1) | ||||||
|  | 		params.Query.Set("limit", "40") // enables paging | ||||||
| 		obj = ap.NewASOrderedCollection(params) | 		obj = ap.NewASOrderedCollection(params) | ||||||
| 	} else { | 	} else { | ||||||
| 		// i.e. paging enabled | 		// i.e. paging enabled | ||||||
| 
 | 
 | ||||||
| 		// Get the request page of full follower objects with attached accounts. | 		// Get the request page of full follower objects with attached accounts. | ||||||
| 		followers, err := p.state.DB.GetAccountFollowers(ctx, requestedAccount.ID, page) | 		followers, err := p.state.DB.GetAccountFollowers(ctx, receiver.ID, page) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			err := gtserror.Newf("error getting followers: %w", err) | 			err := gtserror.Newf("error getting followers: %w", err) | ||||||
| 			return nil, gtserror.NewErrorInternalError(err) | 			return nil, gtserror.NewErrorInternalError(err) | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		// Get the lowest and highest | 		// page ID values. | ||||||
| 		// ID values, used for paging. | 		var lo, hi string | ||||||
| 		lo := followers[len(followers)-1].ID | 
 | ||||||
| 		hi := followers[0].ID | 		if len(followers) > 0 { | ||||||
|  | 			// 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. | 		// Start building AS collection page params. | ||||||
| 		var pageParams ap.CollectionPageParams | 		var pageParams ap.CollectionPageParams | ||||||
|  | @ -196,21 +211,22 @@ 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 | // 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. | // authentication before returning a JSON serializable interface to the caller. | ||||||
| func (p *Processor) FollowingGet(ctx context.Context, requestedUsername string, page *paging.Page) (interface{}, gtserror.WithCode) { | func (p *Processor) FollowingGet(ctx context.Context, requestedUser string, page *paging.Page) (interface{}, gtserror.WithCode) { | ||||||
| 	requestedAccount, _, errWithCode := p.authenticate(ctx, requestedUsername) | 	// Authenticate the incoming request, getting related user accounts. | ||||||
|  | 	_, receiver, errWithCode := p.authenticate(ctx, requestedUser) | ||||||
| 	if errWithCode != nil { | 	if errWithCode != nil { | ||||||
| 		return nil, errWithCode | 		return nil, errWithCode | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Parse the collection ID object from account's following URI. | 	// Parse collection ID from account's following URI. | ||||||
| 	collectionID, err := url.Parse(requestedAccount.FollowingURI) | 	collectionID, err := url.Parse(receiver.FollowingURI) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		err := gtserror.Newf("error parsing account following uri %s: %w", requestedAccount.FollowingURI, err) | 		err := gtserror.Newf("error parsing account following uri %s: %w", receiver.FollowingURI, err) | ||||||
| 		return nil, gtserror.NewErrorInternalError(err) | 		return nil, gtserror.NewErrorInternalError(err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Calculate total number of following available for account. | 	// Calculate total number of following available for account. | ||||||
| 	total, err := p.state.DB.CountAccountFollows(ctx, requestedAccount.ID) | 	total, err := p.state.DB.CountAccountFollows(ctx, receiver.ID) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		err := gtserror.Newf("error counting follows: %w", err) | 		err := gtserror.Newf("error counting follows: %w", err) | ||||||
| 		return nil, gtserror.NewErrorInternalError(err) | 		return nil, gtserror.NewErrorInternalError(err) | ||||||
|  | @ -218,32 +234,38 @@ func (p *Processor) FollowingGet(ctx context.Context, requestedUsername string, | ||||||
| 
 | 
 | ||||||
| 	var obj vocab.Type | 	var obj vocab.Type | ||||||
| 
 | 
 | ||||||
| 	// Start building AS collection params. | 	// Start AS collection params. | ||||||
| 	var params ap.CollectionParams | 	var params ap.CollectionParams | ||||||
| 	params.ID = collectionID | 	params.ID = collectionID | ||||||
| 	params.Total = total | 	params.Total = total | ||||||
| 
 | 
 | ||||||
| 	if page == nil { | 	if page == nil { | ||||||
| 		// i.e. paging disabled, the simplest case. | 		// i.e. paging disabled, return collection | ||||||
| 		// | 		// that links to first page (i.e. path below). | ||||||
| 		// Just build collection object from params. | 		params.Query = make(url.Values, 1) | ||||||
|  | 		params.Query.Set("limit", "40") // enables paging | ||||||
| 		obj = ap.NewASOrderedCollection(params) | 		obj = ap.NewASOrderedCollection(params) | ||||||
| 	} else { | 	} else { | ||||||
| 		// i.e. paging enabled | 		// i.e. paging enabled | ||||||
| 
 | 
 | ||||||
| 		// Get the request page of full follower objects with attached accounts. | 		// Get the request page of full follower objects with attached accounts. | ||||||
| 		follows, err := p.state.DB.GetAccountFollows(ctx, requestedAccount.ID, page) | 		follows, err := p.state.DB.GetAccountFollows(ctx, receiver.ID, page) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			err := gtserror.Newf("error getting follows: %w", err) | 			err := gtserror.Newf("error getting follows: %w", err) | ||||||
| 			return nil, gtserror.NewErrorInternalError(err) | 			return nil, gtserror.NewErrorInternalError(err) | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		// Get the lowest and highest | 		// page ID values. | ||||||
| 		// ID values, used for paging. | 		var lo, hi string | ||||||
| 		lo := follows[len(follows)-1].ID |  | ||||||
| 		hi := follows[0].ID |  | ||||||
| 
 | 
 | ||||||
| 		// Start building AS collection page params. | 		if len(follows) > 0 { | ||||||
|  | 			// Get the lowest and highest | ||||||
|  | 			// ID values, used for paging. | ||||||
|  | 			lo = follows[len(follows)-1].ID | ||||||
|  | 			hi = follows[0].ID | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		// Start AS collection page params. | ||||||
| 		var pageParams ap.CollectionPageParams | 		var pageParams ap.CollectionPageParams | ||||||
| 		pageParams.CollectionParams = params | 		pageParams.CollectionParams = params | ||||||
| 
 | 
 | ||||||
|  | @ -288,20 +310,21 @@ func (p *Processor) FollowingGet(ctx context.Context, requestedUsername string, | ||||||
| 
 | 
 | ||||||
| // FeaturedCollectionGet returns an ordered collection of the requested username's Pinned posts. | // 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. | // 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) { | func (p *Processor) FeaturedCollectionGet(ctx context.Context, requestedUser string) (interface{}, gtserror.WithCode) { | ||||||
| 	requestedAccount, _, errWithCode := p.authenticate(ctx, requestedUsername) | 	// Authenticate the incoming request, getting related user accounts. | ||||||
|  | 	_, receiver, errWithCode := p.authenticate(ctx, requestedUser) | ||||||
| 	if errWithCode != nil { | 	if errWithCode != nil { | ||||||
| 		return nil, errWithCode | 		return nil, errWithCode | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	statuses, err := p.state.DB.GetAccountPinnedStatuses(ctx, requestedAccount.ID) | 	statuses, err := p.state.DB.GetAccountPinnedStatuses(ctx, receiver.ID) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		if !errors.Is(err, db.ErrNoEntries) { | 		if !errors.Is(err, db.ErrNoEntries) { | ||||||
| 			return nil, gtserror.NewErrorInternalError(err) | 			return nil, gtserror.NewErrorInternalError(err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	collection, err := p.converter.StatusesToASFeaturedCollection(ctx, requestedAccount.FeaturedCollectionURI, statuses) | 	collection, err := p.converter.StatusesToASFeaturedCollection(ctx, receiver.FeaturedCollectionURI, statuses) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return nil, gtserror.NewErrorInternalError(err) | 		return nil, gtserror.NewErrorInternalError(err) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | @ -20,7 +20,6 @@ package fedi | ||||||
| import ( | import ( | ||||||
| 	"context" | 	"context" | ||||||
| 	"errors" | 	"errors" | ||||||
| 	"fmt" |  | ||||||
| 
 | 
 | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/db" | 	"github.com/superseriousbusiness/gotosocial/internal/db" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/gtscontext" | 	"github.com/superseriousbusiness/gotosocial/internal/gtscontext" | ||||||
|  | @ -28,17 +27,17 @@ import ( | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| func (p *Processor) authenticate(ctx context.Context, requestedUsername string) ( | func (p *Processor) authenticate(ctx context.Context, requestedUser string) ( | ||||||
| 	*gtsmodel.Account, // requestedAccount | 	*gtsmodel.Account, // requester: i.e. user making the request | ||||||
| 	*gtsmodel.Account, // requestingAccount | 	*gtsmodel.Account, // receiver: i.e. the receiving inbox user | ||||||
| 	gtserror.WithCode, | 	gtserror.WithCode, | ||||||
| ) { | ) { | ||||||
| 	// Get LOCAL account with the requested username. | 	// First get the requested (receiving) LOCAL account with username from database. | ||||||
| 	requestedAccount, err := p.state.DB.GetAccountByUsernameDomain(ctx, requestedUsername, "") | 	receiver, err := p.state.DB.GetAccountByUsernameDomain(ctx, requestedUser, "") | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		if !errors.Is(err, db.ErrNoEntries) { | 		if !errors.Is(err, db.ErrNoEntries) { | ||||||
| 			// Real db error. | 			// Real db error. | ||||||
| 			err = gtserror.Newf("db error getting account %s: %w", requestedUsername, err) | 			err = gtserror.Newf("db error getting account %s: %w", requestedUser, err) | ||||||
| 			return nil, nil, gtserror.NewErrorInternalError(err) | 			return nil, nil, gtserror.NewErrorInternalError(err) | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
|  | @ -46,41 +45,43 @@ func (p *Processor) authenticate(ctx context.Context, requestedUsername string) | ||||||
| 		return nil, nil, gtserror.NewErrorNotFound(err) | 		return nil, nil, gtserror.NewErrorNotFound(err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	var requester *gtsmodel.Account | ||||||
|  | 
 | ||||||
| 	// Ensure request signed, and use signature URI to | 	// Ensure request signed, and use signature URI to | ||||||
| 	// get requesting account, dereferencing if necessary. | 	// get requesting account, dereferencing if necessary. | ||||||
| 	pubKeyAuth, errWithCode := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername) | 	pubKeyAuth, errWithCode := p.federator.AuthenticateFederatedRequest(ctx, requestedUser) | ||||||
| 	if errWithCode != nil { | 	if errWithCode != nil { | ||||||
| 		return nil, nil, errWithCode | 		return nil, nil, errWithCode | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	requestingAccount, _, err := p.federator.GetAccountByURI( | 	if requester = pubKeyAuth.Owner; requester == nil { | ||||||
| 		gtscontext.SetFastFail(ctx), | 		requester, _, err = p.federator.GetAccountByURI( | ||||||
| 		requestedUsername, | 			gtscontext.SetFastFail(ctx), | ||||||
| 		pubKeyAuth.OwnerURI, | 			requestedUser, | ||||||
| 	) | 			pubKeyAuth.OwnerURI, | ||||||
| 	if err != nil { | 		) | ||||||
| 		err = gtserror.Newf("error getting account %s: %w", pubKeyAuth.OwnerURI, err) | 		if err != nil { | ||||||
| 		return nil, nil, gtserror.NewErrorUnauthorized(err) | 			err = gtserror.Newf("error getting account %s: %w", pubKeyAuth.OwnerURI, err) | ||||||
|  | 			return nil, nil, gtserror.NewErrorUnauthorized(err) | ||||||
|  | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if !requestingAccount.SuspendedAt.IsZero() { | 	if !requester.SuspendedAt.IsZero() { | ||||||
| 		// Account was marked as suspended by a | 		// Account was marked as suspended by a | ||||||
| 		// local admin action. Stop request early. | 		// local admin action. Stop request early. | ||||||
| 		err = fmt.Errorf("account %s marked as suspended", requestingAccount.ID) | 		const text = "requesting account is suspended" | ||||||
| 		return nil, nil, gtserror.NewErrorForbidden(err) | 		return nil, nil, gtserror.NewErrorForbidden(errors.New(text)) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Ensure no block exists between requester + requested. | 	// Ensure no block exists between requester + requested. | ||||||
| 	blocked, err := p.state.DB.IsEitherBlocked(ctx, requestedAccount.ID, requestingAccount.ID) | 	blocked, err := p.state.DB.IsEitherBlocked(ctx, receiver.ID, requester.ID) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		err = gtserror.Newf("db error getting checking block: %w", err) | 		err = gtserror.Newf("db error getting checking block: %w", err) | ||||||
| 		return nil, nil, gtserror.NewErrorInternalError(err) | 		return nil, nil, gtserror.NewErrorInternalError(err) | ||||||
| 	} | 	} else if blocked { | ||||||
| 
 | 		err = gtserror.Newf("block exists between accounts %s and %s", requester.ID, receiver.ID) | ||||||
| 	if blocked { |  | ||||||
| 		err = fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID) |  | ||||||
| 		return nil, nil, gtserror.NewErrorForbidden(err) | 		return nil, nil, gtserror.NewErrorForbidden(err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	return requestedAccount, requestingAccount, nil | 	return requester, receiver, nil | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -19,21 +19,33 @@ package fedi | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/federation" | 	"github.com/superseriousbusiness/gotosocial/internal/federation" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/processing/common" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/state" | 	"github.com/superseriousbusiness/gotosocial/internal/state" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/typeutils" | 	"github.com/superseriousbusiness/gotosocial/internal/typeutils" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/visibility" | 	"github.com/superseriousbusiness/gotosocial/internal/visibility" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| type Processor struct { | type Processor struct { | ||||||
|  | 	// embed common logic | ||||||
|  | 	c *common.Processor | ||||||
|  | 
 | ||||||
| 	state     *state.State | 	state     *state.State | ||||||
| 	federator *federation.Federator | 	federator *federation.Federator | ||||||
| 	converter *typeutils.Converter | 	converter *typeutils.Converter | ||||||
| 	filter    *visibility.Filter | 	filter    *visibility.Filter | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // New returns a new fedi processor. | // New returns a | ||||||
| func New(state *state.State, converter *typeutils.Converter, federator *federation.Federator, filter *visibility.Filter) Processor { | // new fedi processor. | ||||||
|  | func New( | ||||||
|  | 	state *state.State, | ||||||
|  | 	common *common.Processor, | ||||||
|  | 	converter *typeutils.Converter, | ||||||
|  | 	federator *federation.Federator, | ||||||
|  | 	filter *visibility.Filter, | ||||||
|  | ) Processor { | ||||||
| 	return Processor{ | 	return Processor{ | ||||||
|  | 		c:         common, | ||||||
| 		state:     state, | 		state:     state, | ||||||
| 		federator: federator, | 		federator: federator, | ||||||
| 		converter: converter, | 		converter: converter, | ||||||
|  |  | ||||||
|  | @ -19,161 +19,192 @@ package fedi | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"context" | 	"context" | ||||||
| 	"fmt" | 	"errors" | ||||||
| 	"net/url" | 	"net/url" | ||||||
|  | 	"slices" | ||||||
|  | 	"strconv" | ||||||
| 
 | 
 | ||||||
|  | 	"github.com/superseriousbusiness/activity/streams/vocab" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/ap" | 	"github.com/superseriousbusiness/gotosocial/internal/ap" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/gtserror" | 	"github.com/superseriousbusiness/gotosocial/internal/gtserror" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/log" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/paging" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| // StatusGet handles the getting of a fedi/activitypub representation of a local status. | // StatusGet handles the getting of a fedi/activitypub representation of a local status. | ||||||
| // It performs appropriate authentication before returning a JSON serializable interface. | // It performs appropriate authentication before returning a JSON serializable interface. | ||||||
| func (p *Processor) StatusGet(ctx context.Context, requestedUsername string, requestedStatusID string) (interface{}, gtserror.WithCode) { | func (p *Processor) StatusGet(ctx context.Context, requestedUser string, statusID string) (interface{}, gtserror.WithCode) { | ||||||
| 	// Authenticate using http signature. | 	// Authenticate using http signature. | ||||||
| 	requestedAccount, requestingAccount, errWithCode := p.authenticate(ctx, requestedUsername) | 	// Authenticate the incoming request, getting related user accounts. | ||||||
|  | 	requester, receiver, errWithCode := p.authenticate(ctx, requestedUser) | ||||||
| 	if errWithCode != nil { | 	if errWithCode != nil { | ||||||
| 		return nil, errWithCode | 		return nil, errWithCode | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	status, err := p.state.DB.GetStatusByID(ctx, requestedStatusID) | 	status, err := p.state.DB.GetStatusByID(ctx, statusID) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return nil, gtserror.NewErrorNotFound(err) | 		return nil, gtserror.NewErrorNotFound(err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if status.AccountID != requestedAccount.ID { | 	if status.AccountID != receiver.ID { | ||||||
| 		err := fmt.Errorf("status with id %s does not belong to account with id %s", status.ID, requestedAccount.ID) | 		const text = "status does not belong to receiving account" | ||||||
| 		return nil, gtserror.NewErrorNotFound(err) | 		return nil, gtserror.NewErrorNotFound(errors.New(text)) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	visible, err := p.filter.StatusVisible(ctx, requestingAccount, status) | 	visible, err := p.filter.StatusVisible(ctx, requester, status) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return nil, gtserror.NewErrorInternalError(err) | 		return nil, gtserror.NewErrorInternalError(err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if !visible { | 	if !visible { | ||||||
| 		err := fmt.Errorf("status with id %s not visible to user with id %s", status.ID, requestingAccount.ID) | 		const text = "status not vising to requesting account" | ||||||
| 		return nil, gtserror.NewErrorNotFound(err) | 		return nil, gtserror.NewErrorNotFound(errors.New(text)) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	statusable, err := p.converter.StatusToAS(ctx, status) | 	statusable, err := p.converter.StatusToAS(ctx, status) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
|  | 		err := gtserror.Newf("error converting status: %w", err) | ||||||
| 		return nil, gtserror.NewErrorInternalError(err) | 		return nil, gtserror.NewErrorInternalError(err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	data, err := ap.Serialize(statusable) | 	data, err := ap.Serialize(statusable) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
|  | 		err := gtserror.Newf("error serializing status: %w", err) | ||||||
| 		return nil, gtserror.NewErrorInternalError(err) | 		return nil, gtserror.NewErrorInternalError(err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	return data, nil | 	return data, nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // GetStatus handles the getting of a fedi/activitypub representation of replies to a status, performing appropriate | // GetStatus handles the getting of a fedi/activitypub representation of replies to a status, | ||||||
| // authentication before returning a JSON serializable interface to the caller. | // 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, onlyOtherAccountsSet bool, minID string) (interface{}, gtserror.WithCode) { | func (p *Processor) StatusRepliesGet( | ||||||
| 	requestedAccount, requestingAccount, errWithCode := p.authenticate(ctx, requestedUsername) | 	ctx context.Context, | ||||||
|  | 	requestedUser string, | ||||||
|  | 	statusID string, | ||||||
|  | 	page *paging.Page, | ||||||
|  | 	onlyOtherAccounts bool, | ||||||
|  | ) (interface{}, gtserror.WithCode) { | ||||||
|  | 	// Authenticate the incoming request, getting related user accounts. | ||||||
|  | 	requester, receiver, errWithCode := p.authenticate(ctx, requestedUser) | ||||||
| 	if errWithCode != nil { | 	if errWithCode != nil { | ||||||
| 		return nil, errWithCode | 		return nil, errWithCode | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	status, err := p.state.DB.GetStatusByID(ctx, requestedStatusID) | 	// Get target status and ensure visible to requester. | ||||||
| 	if err != nil { | 	status, errWithCode := p.c.GetVisibleTargetStatus(ctx, | ||||||
| 		return nil, gtserror.NewErrorNotFound(err) | 		requester, | ||||||
|  | 		statusID, | ||||||
|  | 	) | ||||||
|  | 	if errWithCode != nil { | ||||||
|  | 		return nil, errWithCode | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if status.AccountID != requestedAccount.ID { | 	// Ensure status is by receiving account. | ||||||
| 		return nil, gtserror.NewErrorNotFound(fmt.Errorf("status with id %s does not belong to account with id %s", status.ID, requestedAccount.ID)) | 	if status.AccountID != receiver.ID { | ||||||
|  | 		const text = "status does not belong to receiving account" | ||||||
|  | 		return nil, gtserror.NewErrorNotFound(errors.New(text)) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	visible, err := p.filter.StatusVisible(ctx, requestedAccount, status) | 	// Parse replies collection ID from status' URI with onlyOtherAccounts param. | ||||||
|  | 	onlyOtherAccStr := "only_other_accounts=" + strconv.FormatBool(onlyOtherAccounts) | ||||||
|  | 	collectionID, err := url.Parse(status.URI + "/replies?" + onlyOtherAccStr) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
|  | 		err := gtserror.Newf("error parsing status uri %s: %w", status.URI, err) | ||||||
| 		return nil, gtserror.NewErrorInternalError(err) | 		return nil, gtserror.NewErrorInternalError(err) | ||||||
| 	} | 	} | ||||||
| 	if !visible { | 
 | ||||||
| 		return nil, gtserror.NewErrorNotFound(fmt.Errorf("status with id %s not visible to user with id %s", status.ID, requestingAccount.ID)) | 	// Get *all* available replies for status (i.e. without paging). | ||||||
|  | 	replies, err := p.state.DB.GetStatusReplies(ctx, status.ID) | ||||||
|  | 	if err != nil { | ||||||
|  | 		err := gtserror.Newf("error getting status replies: %w", err) | ||||||
|  | 		return nil, gtserror.NewErrorInternalError(err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	var data map[string]interface{} | 	if onlyOtherAccounts { | ||||||
|  | 		// If 'onlyOtherAccounts' is set, drop all by original status author. | ||||||
|  | 		replies = slices.DeleteFunc(replies, func(reply *gtsmodel.Status) bool { | ||||||
|  | 			return reply.AccountID == status.AccountID | ||||||
|  | 		}) | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 	// now there are three scenarios: | 	// Reslice replies dropping all those invisible to requester. | ||||||
| 	// 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. | 	replies, err = p.filter.StatusesVisible(ctx, requester, replies) | ||||||
| 	// 2. we're asked for a page but only_other_accounts has not been set in the query -- so we should just return the first page of the collection, with no items. | 	if err != nil { | ||||||
| 	// 3. we're asked for a page, and only_other_accounts has been set, and min_id has optionally been set -- so we need to return some actual items! | 		err := gtserror.Newf("error filtering status replies: %w", err) | ||||||
| 	switch { | 		return nil, gtserror.NewErrorInternalError(err) | ||||||
| 	case !page: | 	} | ||||||
| 		// scenario 1 | 
 | ||||||
| 		// get the collection | 	var obj vocab.Type | ||||||
| 		collection, err := p.converter.StatusToASRepliesCollection(ctx, status, onlyOtherAccounts) | 
 | ||||||
| 		if err != nil { | 	// Start AS collection params. | ||||||
| 			return nil, gtserror.NewErrorInternalError(err) | 	var params ap.CollectionParams | ||||||
|  | 	params.ID = collectionID | ||||||
|  | 	params.Total = len(replies) | ||||||
|  | 
 | ||||||
|  | 	if page == nil { | ||||||
|  | 		// i.e. paging disabled, return collection | ||||||
|  | 		// that links to first page (i.e. path below). | ||||||
|  | 		params.Query = make(url.Values, 1) | ||||||
|  | 		params.Query.Set("limit", "20") // enables paging | ||||||
|  | 		obj = ap.NewASOrderedCollection(params) | ||||||
|  | 	} else { | ||||||
|  | 		// i.e. paging enabled | ||||||
|  | 
 | ||||||
|  | 		// Page and reslice the replies according to given parameters. | ||||||
|  | 		replies = paging.Page_PageFunc(page, replies, func(reply *gtsmodel.Status) string { | ||||||
|  | 			return reply.ID | ||||||
|  | 		}) | ||||||
|  | 
 | ||||||
|  | 		// page ID values. | ||||||
|  | 		var lo, hi string | ||||||
|  | 
 | ||||||
|  | 		if len(replies) > 0 { | ||||||
|  | 			// Get the lowest and highest | ||||||
|  | 			// ID values, used for paging. | ||||||
|  | 			lo = replies[len(replies)-1].ID | ||||||
|  | 			hi = replies[0].ID | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		data, err = ap.Serialize(collection) | 		// Start AS collection page params. | ||||||
| 		if err != nil { | 		var pageParams ap.CollectionPageParams | ||||||
| 			return nil, gtserror.NewErrorInternalError(err) | 		pageParams.CollectionParams = params | ||||||
| 		} |  | ||||||
| 	case page && !onlyOtherAccountsSet: |  | ||||||
| 		// scenario 2 |  | ||||||
| 		// get the collection |  | ||||||
| 		collection, err := p.converter.StatusToASRepliesCollection(ctx, status, onlyOtherAccounts) |  | ||||||
| 		if err != nil { |  | ||||||
| 			return nil, gtserror.NewErrorInternalError(err) |  | ||||||
| 		} |  | ||||||
| 		// but only return the first page |  | ||||||
| 		data, err = ap.Serialize(collection.GetActivityStreamsFirst().GetActivityStreamsCollectionPage()) |  | ||||||
| 		if err != nil { |  | ||||||
| 			return nil, gtserror.NewErrorInternalError(err) |  | ||||||
| 		} |  | ||||||
| 	default: |  | ||||||
| 		// scenario 3 |  | ||||||
| 		// get immediate children |  | ||||||
| 		replies, err := p.state.DB.GetStatusChildren(ctx, status, true, minID) |  | ||||||
| 		if err != nil { |  | ||||||
| 			return nil, gtserror.NewErrorInternalError(err) |  | ||||||
| 		} |  | ||||||
| 
 | 
 | ||||||
| 		// filter children and extract URIs | 		// Current page details. | ||||||
| 		replyURIs := map[string]*url.URL{} | 		pageParams.Current = page | ||||||
| 		for _, r := range replies { | 		pageParams.Count = len(replies) | ||||||
| 			// only show public or unlocked statuses as replies |  | ||||||
| 			if r.Visibility != gtsmodel.VisibilityPublic && r.Visibility != gtsmodel.VisibilityUnlocked { |  | ||||||
| 				continue |  | ||||||
| 			} |  | ||||||
| 
 | 
 | ||||||
| 			// respect onlyOtherAccounts parameter | 		// Set linked next/prev parameters. | ||||||
| 			if onlyOtherAccounts && r.AccountID == requestedAccount.ID { | 		pageParams.Next = page.Next(lo, hi) | ||||||
| 				continue | 		pageParams.Prev = page.Prev(lo, hi) | ||||||
| 			} |  | ||||||
| 
 | 
 | ||||||
| 			// only show replies that the status owner can see | 		// Set the collection item property builder function. | ||||||
| 			visibleToStatusOwner, err := p.filter.StatusVisible(ctx, requestedAccount, r) | 		pageParams.Append = func(i int, itemsProp ap.ItemsPropertyBuilder) { | ||||||
| 			if err != nil || !visibleToStatusOwner { | 			// Get follower URI at index. | ||||||
| 				continue | 			status := replies[i] | ||||||
| 			} | 			uri := status.URI | ||||||
| 
 | 
 | ||||||
| 			// only show replies that the requester can see | 			// Parse URL object from URI. | ||||||
| 			visibleToRequester, err := p.filter.StatusVisible(ctx, requestingAccount, r) | 			iri, err := url.Parse(uri) | ||||||
| 			if err != nil || !visibleToRequester { |  | ||||||
| 				continue |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			rURI, err := url.Parse(r.URI) |  | ||||||
| 			if err != nil { | 			if err != nil { | ||||||
| 				continue | 				log.Errorf(ctx, "error parsing status uri %s: %v", uri, err) | ||||||
|  | 				return | ||||||
| 			} | 			} | ||||||
| 
 | 
 | ||||||
| 			replyURIs[r.ID] = rURI | 			// Add to item property. | ||||||
|  | 			itemsProp.AppendIRI(iri) | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		repliesPage, err := p.converter.StatusURIsToASRepliesPage(ctx, status, onlyOtherAccounts, minID, replyURIs) | 		// Build AS collection page object from params. | ||||||
| 		if err != nil { | 		obj = ap.NewASOrderedCollectionPage(pageParams) | ||||||
| 			return nil, gtserror.NewErrorInternalError(err) | 	} | ||||||
| 		} | 
 | ||||||
| 		data, err = ap.Serialize(repliesPage) | 	// Serialized the prepared object. | ||||||
| 		if err != nil { | 	data, err := ap.Serialize(obj) | ||||||
| 			return nil, gtserror.NewErrorInternalError(err) | 	if err != nil { | ||||||
| 		} | 		err := gtserror.Newf("error serializing: %w", err) | ||||||
|  | 		return nil, gtserror.NewErrorInternalError(err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	return data, nil | 	return data, nil | ||||||
|  |  | ||||||
|  | @ -156,23 +156,23 @@ func NewProcessor( | ||||||
| 	// | 	// | ||||||
| 	// Start with sub processors that will | 	// Start with sub processors that will | ||||||
| 	// be required by the workers processor. | 	// be required by the workers processor. | ||||||
| 	commonProcessor := common.New(state, converter, federator, filter) | 	common := common.New(state, converter, federator, filter) | ||||||
| 	processor.account = account.New(&commonProcessor, state, converter, mediaManager, oauthServer, federator, filter, parseMentionFunc) | 	processor.account = account.New(&common, state, converter, mediaManager, oauthServer, federator, filter, parseMentionFunc) | ||||||
| 	processor.media = media.New(state, converter, mediaManager, federator.TransportController()) | 	processor.media = media.New(state, converter, mediaManager, federator.TransportController()) | ||||||
| 	processor.stream = stream.New(state, oauthServer) | 	processor.stream = stream.New(state, oauthServer) | ||||||
| 
 | 
 | ||||||
| 	// Instantiate the rest of the sub | 	// Instantiate the rest of the sub | ||||||
| 	// processors + pin them to this struct. | 	// processors + pin them to this struct. | ||||||
| 	processor.account = account.New(&commonProcessor, state, converter, mediaManager, oauthServer, federator, filter, parseMentionFunc) | 	processor.account = account.New(&common, state, converter, mediaManager, oauthServer, federator, filter, parseMentionFunc) | ||||||
| 	processor.admin = admin.New(state, cleaner, converter, mediaManager, federator.TransportController(), emailSender) | 	processor.admin = admin.New(state, cleaner, converter, mediaManager, federator.TransportController(), emailSender) | ||||||
| 	processor.fedi = fedi.New(state, converter, federator, filter) | 	processor.fedi = fedi.New(state, &common, converter, federator, filter) | ||||||
| 	processor.list = list.New(state, converter) | 	processor.list = list.New(state, converter) | ||||||
| 	processor.markers = markers.New(state, converter) | 	processor.markers = markers.New(state, converter) | ||||||
| 	processor.polls = polls.New(&commonProcessor, state, converter) | 	processor.polls = polls.New(&common, state, converter) | ||||||
| 	processor.report = report.New(state, converter) | 	processor.report = report.New(state, converter) | ||||||
| 	processor.timeline = timeline.New(state, converter, filter) | 	processor.timeline = timeline.New(state, converter, filter) | ||||||
| 	processor.search = search.New(state, federator, converter, filter) | 	processor.search = search.New(state, federator, converter, filter) | ||||||
| 	processor.status = status.New(state, &commonProcessor, &processor.polls, federator, converter, filter, parseMentionFunc) | 	processor.status = status.New(state, &common, &processor.polls, federator, converter, filter, parseMentionFunc) | ||||||
| 	processor.user = user.New(state, emailSender) | 	processor.user = user.New(state, emailSender) | ||||||
| 
 | 
 | ||||||
| 	// Workers processor handles asynchronous | 	// Workers processor handles asynchronous | ||||||
|  |  | ||||||
|  | @ -67,7 +67,7 @@ func (p *Processor) contextGet( | ||||||
| 		Descendants: []apimodel.Status{}, | 		Descendants: []apimodel.Status{}, | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	parents, err := p.state.DB.GetStatusParents(ctx, targetStatus, false) | 	parents, err := p.state.DB.GetStatusParents(ctx, targetStatus) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return nil, gtserror.NewErrorInternalError(err) | 		return nil, gtserror.NewErrorInternalError(err) | ||||||
| 	} | 	} | ||||||
|  | @ -85,7 +85,7 @@ func (p *Processor) contextGet( | ||||||
| 		return context.Ancestors[i].ID < context.Ancestors[j].ID | 		return context.Ancestors[i].ID < context.Ancestors[j].ID | ||||||
| 	}) | 	}) | ||||||
| 
 | 
 | ||||||
| 	children, err := p.state.DB.GetStatusChildren(ctx, targetStatus, false, "") | 	children, err := p.state.DB.GetStatusChildren(ctx, targetStatus.ID) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return nil, gtserror.NewErrorInternalError(err) | 		return nil, gtserror.NewErrorInternalError(err) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | @ -33,3 +33,11 @@ func EqualPtrs[T comparable](t1, t2 *T) bool { | ||||||
| func Ptr[T any](t T) *T { | func Ptr[T any](t T) *T { | ||||||
| 	return &t | 	return &t | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | // PtrValueOr returns either value of ptr, or default. | ||||||
|  | func PtrValueOr[T any](t *T, _default T) T { | ||||||
|  | 	if t != nil { | ||||||
|  | 		return *t | ||||||
|  | 	} | ||||||
|  | 	return _default | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -20,7 +20,6 @@ package visibility | ||||||
| import ( | import ( | ||||||
| 	"context" | 	"context" | ||||||
| 	"errors" | 	"errors" | ||||||
| 	"fmt" |  | ||||||
| 	"time" | 	"time" | ||||||
| 
 | 
 | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/cache" | 	"github.com/superseriousbusiness/gotosocial/internal/cache" | ||||||
|  | @ -219,7 +218,7 @@ func (f *Filter) isVisibleConversation(ctx context.Context, owner *gtsmodel.Acco | ||||||
| 			status.AccountID, | 			status.AccountID, | ||||||
| 		) | 		) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return false, fmt.Errorf("error checking follow %s->%s: %w", owner.ID, status.AccountID, err) | 			return false, gtserror.Newf("error checking follow %s->%s: %w", owner.ID, status.AccountID, err) | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		if !followAuthor { | 		if !followAuthor { | ||||||
|  | @ -236,7 +235,7 @@ func (f *Filter) isVisibleConversation(ctx context.Context, owner *gtsmodel.Acco | ||||||
| 			mention.TargetAccountID, | 			mention.TargetAccountID, | ||||||
| 		) | 		) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return false, fmt.Errorf("error checking mention follow %s->%s: %w", owner.ID, mention.TargetAccountID, err) | 			return false, gtserror.Newf("error checking mention follow %s->%s: %w", owner.ID, mention.TargetAccountID, err) | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		if follow { | 		if follow { | ||||||
|  |  | ||||||
|  | @ -19,11 +19,11 @@ package visibility | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"context" | 	"context" | ||||||
| 	"fmt" |  | ||||||
| 	"time" | 	"time" | ||||||
| 
 | 
 | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/cache" | 	"github.com/superseriousbusiness/gotosocial/internal/cache" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/gtscontext" | 	"github.com/superseriousbusiness/gotosocial/internal/gtscontext" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/gtserror" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/log" | 	"github.com/superseriousbusiness/gotosocial/internal/log" | ||||||
| ) | ) | ||||||
|  | @ -105,7 +105,7 @@ func (f *Filter) isStatusPublicTimelineable(ctx context.Context, requester *gtsm | ||||||
| 			parentID, | 			parentID, | ||||||
| 		) | 		) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return false, fmt.Errorf("isStatusPublicTimelineable: error getting status parent %s: %w", parentID, err) | 			return false, gtserror.Newf("error getting status parent %s: %w", parentID, err) | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		if parent.AccountID != status.AccountID { | 		if parent.AccountID != status.AccountID { | ||||||
|  |  | ||||||
|  | @ -19,32 +19,26 @@ package visibility | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"context" | 	"context" | ||||||
| 	"fmt" | 	"slices" | ||||||
| 
 | 
 | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/cache" | 	"github.com/superseriousbusiness/gotosocial/internal/cache" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/gtserror" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/log" | 	"github.com/superseriousbusiness/gotosocial/internal/log" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| // StatusesVisible calls StatusVisible for each status in the statuses slice, and returns a slice of only statuses which are visible to the requester. | // StatusesVisible calls StatusVisible for each status in the statuses slice, and returns a slice of only statuses which are visible to the requester. | ||||||
| func (f *Filter) StatusesVisible(ctx context.Context, requester *gtsmodel.Account, statuses []*gtsmodel.Status) ([]*gtsmodel.Status, error) { | func (f *Filter) StatusesVisible(ctx context.Context, requester *gtsmodel.Account, statuses []*gtsmodel.Status) ([]*gtsmodel.Status, error) { | ||||||
| 	// Preallocate slice of maximum possible length. | 	var errs gtserror.MultiError | ||||||
| 	filtered := make([]*gtsmodel.Status, 0, len(statuses)) | 	filtered := slices.DeleteFunc(statuses, func(status *gtsmodel.Status) bool { | ||||||
| 
 |  | ||||||
| 	for _, status := range statuses { |  | ||||||
| 		// Check whether status is visible to requester. |  | ||||||
| 		visible, err := f.StatusVisible(ctx, requester, status) | 		visible, err := f.StatusVisible(ctx, requester, status) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return nil, err | 			errs.Append(err) | ||||||
|  | 			return true | ||||||
| 		} | 		} | ||||||
| 
 | 		return !visible | ||||||
| 		if visible { | 	}) | ||||||
| 			// Add filtered status to ret slice. | 	return filtered, errs.Combine() | ||||||
| 			filtered = append(filtered, status) |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	return filtered, nil |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // StatusVisible will check if given status is visible to requester, accounting for requester with no auth (i.e is nil), suspensions, disabled local users, account blocks and status privacy. | // StatusVisible will check if given status is visible to requester, accounting for requester with no auth (i.e is nil), suspensions, disabled local users, account blocks and status privacy. | ||||||
|  | @ -85,13 +79,13 @@ func (f *Filter) StatusVisible(ctx context.Context, requester *gtsmodel.Account, | ||||||
| func (f *Filter) isStatusVisible(ctx context.Context, requester *gtsmodel.Account, status *gtsmodel.Status) (bool, error) { | func (f *Filter) isStatusVisible(ctx context.Context, requester *gtsmodel.Account, status *gtsmodel.Status) (bool, error) { | ||||||
| 	// Ensure that status is fully populated for further processing. | 	// Ensure that status is fully populated for further processing. | ||||||
| 	if err := f.state.DB.PopulateStatus(ctx, status); err != nil { | 	if err := f.state.DB.PopulateStatus(ctx, status); err != nil { | ||||||
| 		return false, fmt.Errorf("isStatusVisible: error populating status %s: %w", status.ID, err) | 		return false, gtserror.Newf("error populating status %s: %w", status.ID, err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Check whether status accounts are visible to the requester. | 	// Check whether status accounts are visible to the requester. | ||||||
| 	visible, err := f.areStatusAccountsVisible(ctx, requester, status) | 	visible, err := f.areStatusAccountsVisible(ctx, requester, status) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return false, fmt.Errorf("isStatusVisible: error checking status %s account visibility: %w", status.ID, err) | 		return false, gtserror.Newf("error checking status %s account visibility: %w", status.ID, err) | ||||||
| 	} else if !visible { | 	} else if !visible { | ||||||
| 		return false, nil | 		return false, nil | ||||||
| 	} | 	} | ||||||
|  | @ -127,7 +121,7 @@ func (f *Filter) isStatusVisible(ctx context.Context, requester *gtsmodel.Accoun | ||||||
| 			// Boosted status needs its mentions populating, fetch these from database. | 			// Boosted status needs its mentions populating, fetch these from database. | ||||||
| 			status.BoostOf.Mentions, err = f.state.DB.GetMentions(ctx, status.BoostOf.MentionIDs) | 			status.BoostOf.Mentions, err = f.state.DB.GetMentions(ctx, status.BoostOf.MentionIDs) | ||||||
| 			if err != nil { | 			if err != nil { | ||||||
| 				return false, fmt.Errorf("isStatusVisible: error populating boosted status %s mentions: %w", status.BoostOfID, err) | 				return false, gtserror.Newf("error populating boosted status %s mentions: %w", status.BoostOfID, err) | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
|  | @ -145,7 +139,7 @@ func (f *Filter) isStatusVisible(ctx context.Context, requester *gtsmodel.Accoun | ||||||
| 			status.AccountID, | 			status.AccountID, | ||||||
| 		) | 		) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return false, fmt.Errorf("isStatusVisible: error checking follow %s->%s: %w", requester.ID, status.AccountID, err) | 			return false, gtserror.Newf("error checking follow %s->%s: %w", requester.ID, status.AccountID, err) | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		if !follows { | 		if !follows { | ||||||
|  | @ -162,7 +156,7 @@ func (f *Filter) isStatusVisible(ctx context.Context, requester *gtsmodel.Accoun | ||||||
| 			status.AccountID, | 			status.AccountID, | ||||||
| 		) | 		) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return false, fmt.Errorf("isStatusVisible: error checking mutual follow %s<->%s: %w", requester.ID, status.AccountID, err) | 			return false, gtserror.Newf("error checking mutual follow %s<->%s: %w", requester.ID, status.AccountID, err) | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		if !mutuals { | 		if !mutuals { | ||||||
|  | @ -187,7 +181,7 @@ func (f *Filter) areStatusAccountsVisible(ctx context.Context, requester *gtsmod | ||||||
| 	// Check whether status author's account is visible to requester. | 	// Check whether status author's account is visible to requester. | ||||||
| 	visible, err := f.AccountVisible(ctx, requester, status.Account) | 	visible, err := f.AccountVisible(ctx, requester, status.Account) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return false, fmt.Errorf("error checking status author visibility: %w", err) | 		return false, gtserror.Newf("error checking status author visibility: %w", err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if !visible { | 	if !visible { | ||||||
|  | @ -206,7 +200,7 @@ func (f *Filter) areStatusAccountsVisible(ctx context.Context, requester *gtsmod | ||||||
| 		// Check whether boosted status author's account is visible to requester. | 		// Check whether boosted status author's account is visible to requester. | ||||||
| 		visible, err := f.AccountVisible(ctx, requester, status.BoostOfAccount) | 		visible, err := f.AccountVisible(ctx, requester, status.BoostOfAccount) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return false, fmt.Errorf("error checking boosted author visibility: %w", err) | 			return false, gtserror.Newf("error checking boosted author visibility: %w", err) | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		if !visible { | 		if !visible { | ||||||
|  |  | ||||||
|  | @ -3163,7 +3163,7 @@ func NewTestDereferenceRequests(accounts map[string]*gtsmodel.Account) map[strin | ||||||
| 		DateHeader:      date, | 		DateHeader:      date, | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	target = URLMustParse(statuses["local_account_1_status_1"].URI + "/replies") | 	target = URLMustParse(statuses["local_account_1_status_1"].URI + "/replies?only_other_accounts=false") | ||||||
| 	sig, digest, date = GetSignatureForDereference(accounts["remote_account_1"].PublicKeyURI, accounts["remote_account_1"].PrivateKey, target) | 	sig, digest, date = GetSignatureForDereference(accounts["remote_account_1"].PublicKeyURI, accounts["remote_account_1"].PrivateKey, target) | ||||||
| 	fossSatanDereferenceLocalAccount1Status1Replies := ActivityWithSignature{ | 	fossSatanDereferenceLocalAccount1Status1Replies := ActivityWithSignature{ | ||||||
| 		SignatureHeader: sig, | 		SignatureHeader: sig, | ||||||
|  | @ -3179,7 +3179,7 @@ func NewTestDereferenceRequests(accounts map[string]*gtsmodel.Account) map[strin | ||||||
| 		DateHeader:      date, | 		DateHeader:      date, | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	target = URLMustParse(statuses["local_account_1_status_1"].URI + "/replies?only_other_accounts=false&page=true&min_id=01FF25D5Q0DH7CHD57CTRS6WK0") | 	target = URLMustParse(statuses["local_account_1_status_1"].URI + "/replies?min_id=01FF25D5Q0DH7CHD57CTRS6WK0&only_other_accounts=false") | ||||||
| 	sig, digest, date = GetSignatureForDereference(accounts["remote_account_1"].PublicKeyURI, accounts["remote_account_1"].PrivateKey, target) | 	sig, digest, date = GetSignatureForDereference(accounts["remote_account_1"].PublicKeyURI, accounts["remote_account_1"].PrivateKey, target) | ||||||
| 	fossSatanDereferenceLocalAccount1Status1RepliesLast := ActivityWithSignature{ | 	fossSatanDereferenceLocalAccount1Status1RepliesLast := ActivityWithSignature{ | ||||||
| 		SignatureHeader: sig, | 		SignatureHeader: sig, | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue