[feature] Federate pinned posts (aka featuredCollection) in and out (#1560)

* start fiddling

* the ol' fiddle + update

* start working on fetching statuses

* poopy doopy doo where r u uwu

* further adventures in featuring statuses

* finishing up

* fmt

* simply status unpin loop

* move empty featured check back to caller function

* remove unnecessary log.WithContext calls

* remove unnecessary IsIRI() checks

* add explanatory comment about status URIs

* change log level to error

* better test names
This commit is contained in:
tobi 2023-03-01 18:52:44 +01:00 committed by GitHub
commit 24cec4e7aa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 783 additions and 278 deletions

View file

@ -55,18 +55,19 @@ const (
ActorPerson = "Person" // ActivityStreamsPerson https://www.w3.org/TR/activitystreams-vocabulary/#dfn-person
ActorService = "Service" // ActivityStreamsService https://www.w3.org/TR/activitystreams-vocabulary/#dfn-service
ObjectArticle = "Article" // ActivityStreamsArticle https://www.w3.org/TR/activitystreams-vocabulary/#dfn-article
ObjectAudio = "Audio" // ActivityStreamsAudio https://www.w3.org/TR/activitystreams-vocabulary/#dfn-audio
ObjectDocument = "Document" // ActivityStreamsDocument https://www.w3.org/TR/activitystreams-vocabulary/#dfn-document
ObjectEvent = "Event" // ActivityStreamsEvent https://www.w3.org/TR/activitystreams-vocabulary/#dfn-event
ObjectImage = "Image" // ActivityStreamsImage https://www.w3.org/TR/activitystreams-vocabulary/#dfn-image
ObjectNote = "Note" // ActivityStreamsNote https://www.w3.org/TR/activitystreams-vocabulary/#dfn-note
ObjectPage = "Page" // ActivityStreamsPage https://www.w3.org/TR/activitystreams-vocabulary/#dfn-page
ObjectPlace = "Place" // ActivityStreamsPlace https://www.w3.org/TR/activitystreams-vocabulary/#dfn-place
ObjectProfile = "Profile" // ActivityStreamsProfile https://www.w3.org/TR/activitystreams-vocabulary/#dfn-profile
ObjectRelationship = "Relationship" // ActivityStreamsRelationship https://www.w3.org/TR/activitystreams-vocabulary/#dfn-relationship
ObjectTombstone = "Tombstone" // ActivityStreamsTombstone https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tombstone
ObjectVideo = "Video" // ActivityStreamsVideo https://www.w3.org/TR/activitystreams-vocabulary/#dfn-video
ObjectCollection = "Collection" // ActivityStreamsCollection https://www.w3.org/TR/activitystreams-vocabulary/#dfn-collection
ObjectCollectionPage = "CollectionPage" // ActivityStreamsCollectionPage https://www.w3.org/TR/activitystreams-vocabulary/#dfn-collectionpage
ObjectArticle = "Article" // ActivityStreamsArticle https://www.w3.org/TR/activitystreams-vocabulary/#dfn-article
ObjectAudio = "Audio" // ActivityStreamsAudio https://www.w3.org/TR/activitystreams-vocabulary/#dfn-audio
ObjectDocument = "Document" // ActivityStreamsDocument https://www.w3.org/TR/activitystreams-vocabulary/#dfn-document
ObjectEvent = "Event" // ActivityStreamsEvent https://www.w3.org/TR/activitystreams-vocabulary/#dfn-event
ObjectImage = "Image" // ActivityStreamsImage https://www.w3.org/TR/activitystreams-vocabulary/#dfn-image
ObjectNote = "Note" // ActivityStreamsNote https://www.w3.org/TR/activitystreams-vocabulary/#dfn-note
ObjectPage = "Page" // ActivityStreamsPage https://www.w3.org/TR/activitystreams-vocabulary/#dfn-page
ObjectPlace = "Place" // ActivityStreamsPlace https://www.w3.org/TR/activitystreams-vocabulary/#dfn-place
ObjectProfile = "Profile" // ActivityStreamsProfile https://www.w3.org/TR/activitystreams-vocabulary/#dfn-profile
ObjectRelationship = "Relationship" // ActivityStreamsRelationship https://www.w3.org/TR/activitystreams-vocabulary/#dfn-relationship
ObjectTombstone = "Tombstone" // ActivityStreamsTombstone https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tombstone
ObjectVideo = "Video" // ActivityStreamsVideo https://www.w3.org/TR/activitystreams-vocabulary/#dfn-video
ObjectCollection = "Collection" // ActivityStreamsCollection https://www.w3.org/TR/activitystreams-vocabulary/#dfn-collection
ObjectCollectionPage = "CollectionPage" // ActivityStreamsCollectionPage https://www.w3.org/TR/activitystreams-vocabulary/#dfn-collectionpage
ObjectOrderedCollection = "OrderedCollection" // ActivityStreamsOrderedCollection https://www.w3.org/TR/activitystreams-vocabulary/#dfn-orderedcollection
)

View file

@ -43,7 +43,7 @@ func (m *Module) EmojiGetHandler(c *gin.Context) {
return
}
resp, errWithCode := m.processor.Fedi().EmojiGet(apiutil.TransferSignatureContext(c), requestedEmojiID, c.Request.URL)
resp, errWithCode := m.processor.Fedi().EmojiGet(apiutil.TransferSignatureContext(c), requestedEmojiID)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return

View file

@ -18,12 +18,14 @@
package users
// SwaggerCollection represents an activitypub collection.
// SwaggerCollection represents an ActivityPub Collection.
// swagger:model swaggerCollection
type SwaggerCollection struct {
// ActivityStreams context.
// ActivityStreams JSON-LD context.
// A string or an array of strings, or more
// complex nested items.
// example: https://www.w3.org/ns/activitystreams
Context string `json:"@context"`
Context interface{} `json:"@context"`
// ActivityStreams ID.
// example: https://example.org/users/some_user/statuses/106717595988259568/replies
ID string `json:"id"`
@ -55,3 +57,25 @@ type SwaggerCollectionPage struct {
// example: ["https://example.org/users/some_other_user/statuses/086417595981111564", "https://another.example.com/users/another_user/statuses/01FCN8XDV3YG7B4R42QA6YQZ9R"]
Items []string `json:"items"`
}
// SwaggerFeaturedCollection represents an ActivityPub OrderedCollection.
// swagger:model swaggerFeaturedCollection
type SwaggerFeaturedCollection struct {
// ActivityStreams JSON-LD context.
// A string or an array of strings, or more
// complex nested items.
// example: https://www.w3.org/ns/activitystreams
Context interface{} `json:"@context"`
// ActivityStreams ID.
// example: https://example.org/users/some_user/collections/featured
ID string `json:"id"`
// ActivityStreams type.
// example: OrderedCollection
Type string `json:"type"`
// List of status URIs.
// example: ['https://example.org/users/some_user/statuses/01GSZ0F7Q8SJKNRF777GJD271R', 'https://example.org/users/some_user/statuses/01GSZ0G012CBQ7TEKX689S3QRE']
Items []string `json:"items"`
// Number of items in this collection.
// example: 2
TotalItems int
}

View file

@ -0,0 +1,97 @@
/*
GoToSocial
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package users
import (
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
)
// FeaturedCollectionGETHandler swagger:operation GET /users/{username}/collections/featured s2sFeaturedCollectionGet
//
// Get the featured collection (pinned posts) for a user.
//
// The response will contain an ordered collection of Note URIs in the `items` property.
//
// It is up to the caller to dereference the provided Note URIs (or not, if they already have them cached).
//
// HTTP signature is required on the request.
//
// ---
// tags:
// - s2s/federation
//
// produces:
// - application/activity+json
//
// responses:
// '200':
// in: body
// schema:
// "$ref": "#/definitions/swaggerFeaturedCollection"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '403':
// description: forbidden
// '404':
// description: not found
func (m *Module) FeaturedCollectionGETHandler(c *gin.Context) {
// usernames on our instance are always lowercase
requestedUsername := strings.ToLower(c.Param(UsernameKey))
if requestedUsername == "" {
err := errors.New("no username specified in request")
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
format, err := apiutil.NegotiateAccept(c, apiutil.HTMLOrActivityPubHeaders...)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
if format == string(apiutil.TextHTML) {
// This isn't an ActivityPub request;
// redirect to the user's profile.
c.Redirect(http.StatusSeeOther, "/@"+requestedUsername)
return
}
resp, errWithCode := m.processor.Fedi().FeaturedCollectionGet(apiutil.TransferSignatureContext(c), requestedUsername)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
b, err := json.Marshal(resp)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGetV1)
return
}
c.Data(http.StatusOK, format, b)
}

View file

@ -46,12 +46,13 @@ func (m *Module) FollowersGETHandler(c *gin.Context) {
}
if format == string(apiutil.TextHTML) {
// redirect to the user's profile
// This isn't an ActivityPub request;
// redirect to the user's profile.
c.Redirect(http.StatusSeeOther, "/@"+requestedUsername)
return
}
resp, errWithCode := m.processor.Fedi().FollowersGet(apiutil.TransferSignatureContext(c), requestedUsername, c.Request.URL)
resp, errWithCode := m.processor.Fedi().FollowersGet(apiutil.TransferSignatureContext(c), requestedUsername)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return

View file

@ -46,12 +46,13 @@ func (m *Module) FollowingGETHandler(c *gin.Context) {
}
if format == string(apiutil.TextHTML) {
// redirect to the user's profile
// This isn't an ActivityPub request;
// redirect to the user's profile.
c.Redirect(http.StatusSeeOther, "/@"+requestedUsername)
return
}
resp, errWithCode := m.processor.Fedi().FollowingGet(apiutil.TransferSignatureContext(c), requestedUsername, c.Request.URL)
resp, errWithCode := m.processor.Fedi().FollowingGet(apiutil.TransferSignatureContext(c), requestedUsername)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return

View file

@ -101,7 +101,8 @@ func (m *Module) OutboxGETHandler(c *gin.Context) {
}
if format == string(apiutil.TextHTML) {
// redirect to the user's profile
// This isn't an ActivityPub request;
// redirect to the user's profile.
c.Redirect(http.StatusSeeOther, "/@"+requestedUsername)
return
}
@ -129,7 +130,7 @@ func (m *Module) OutboxGETHandler(c *gin.Context) {
maxID = maxIDString
}
resp, errWithCode := m.processor.Fedi().OutboxGet(apiutil.TransferSignatureContext(c), requestedUsername, page, maxID, minID, c.Request.URL)
resp, errWithCode := m.processor.Fedi().OutboxGet(apiutil.TransferSignatureContext(c), requestedUsername, page, maxID, minID)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return

View file

@ -150,7 +150,7 @@ func (m *Module) StatusRepliesGETHandler(c *gin.Context) {
minID = minIDString
}
resp, errWithCode := m.processor.Fedi().StatusRepliesGet(apiutil.TransferSignatureContext(c), requestedUsername, requestedStatusID, page, onlyOtherAccounts, minID, c.Request.URL)
resp, errWithCode := m.processor.Fedi().StatusRepliesGet(apiutil.TransferSignatureContext(c), requestedUsername, requestedStatusID, page, onlyOtherAccounts, c.Query("only_other_accounts") != "", minID)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return

View file

@ -59,7 +59,7 @@ func (m *Module) StatusGETHandler(c *gin.Context) {
return
}
resp, errWithCode := m.processor.Fedi().StatusGet(apiutil.TransferSignatureContext(c), requestedUsername, requestedStatusID, c.Request.URL)
resp, errWithCode := m.processor.Fedi().StatusGet(apiutil.TransferSignatureContext(c), requestedUsername, requestedStatusID)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return

View file

@ -50,6 +50,8 @@ const (
FollowersPath = BasePath + "/" + uris.FollowersPath
// FollowingPath is for serving GET request's to a user's following list, with the given username key.
FollowingPath = BasePath + "/" + uris.FollowingPath
// FeaturedCollectionPath is for serving GET requests to a user's list of featured (pinned) statuses.
FeaturedCollectionPath = BasePath + "/" + uris.CollectionsPath + "/" + uris.FeaturedPath
// StatusPath is for serving GET requests to a particular status by a user, with the given username key and status ID
StatusPath = BasePath + "/" + uris.StatusesPath + "/:" + StatusIDKey
// StatusRepliesPath is for serving the replies collection of a status.
@ -71,6 +73,7 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H
attachHandler(http.MethodPost, InboxPath, m.InboxPOSTHandler)
attachHandler(http.MethodGet, FollowersPath, m.FollowersGETHandler)
attachHandler(http.MethodGet, FollowingPath, m.FollowingGETHandler)
attachHandler(http.MethodGet, FeaturedCollectionPath, m.FeaturedCollectionGETHandler)
attachHandler(http.MethodGet, StatusPath, m.StatusGETHandler)
attachHandler(http.MethodGet, StatusRepliesPath, m.StatusRepliesGETHandler)
attachHandler(http.MethodGet, OutboxPath, m.OutboxGETHandler)

View file

@ -129,7 +129,7 @@ func (suite *StatusPinTestSuite) TestPinStatusTwiceError() {
*targetStatus = *suite.testStatuses["local_account_1_status_5"]
targetStatus.PinnedAt = time.Now()
if err := suite.db.UpdateStatus(context.Background(), targetStatus); err != nil {
if err := suite.db.UpdateStatus(context.Background(), targetStatus, "pinned_at"); err != nil {
suite.FailNow(err.Error())
}

View file

@ -135,7 +135,7 @@ func (a *adminDB) NewSignup(ctx context.Context, username string, reason string,
OutboxURI: accountURIs.OutboxURI,
FollowersURI: accountURIs.FollowersURI,
FollowingURI: accountURIs.FollowingURI,
FeaturedCollectionURI: accountURIs.CollectionURI,
FeaturedCollectionURI: accountURIs.FeaturedCollectionURI,
}
// insert the new account!
@ -237,7 +237,7 @@ func (a *adminDB) CreateInstanceAccount(ctx context.Context) db.Error {
OutboxURI: newAccountURIs.OutboxURI,
FollowersURI: newAccountURIs.FollowersURI,
FollowingURI: newAccountURIs.FollowingURI,
FeaturedCollectionURI: newAccountURIs.CollectionURI,
FeaturedCollectionURI: newAccountURIs.FeaturedCollectionURI,
}
// insert the new account!

View file

@ -246,7 +246,13 @@ func (s *statusDB) PutStatus(ctx context.Context, status *gtsmodel.Status) db.Er
})
}
func (s *statusDB) UpdateStatus(ctx context.Context, status *gtsmodel.Status) db.Error {
func (s *statusDB) UpdateStatus(ctx context.Context, status *gtsmodel.Status, columns ...string) db.Error {
status.UpdatedAt = time.Now()
if len(columns) > 0 {
// If we're updating by column, ensure "updated_at" is included.
columns = append(columns, "updated_at")
}
if err := s.conn.RunInTx(ctx, func(tx bun.Tx) error {
// create links between this status and any emojis it uses
for _, i := range status.EmojiIDs {
@ -298,6 +304,7 @@ func (s *statusDB) UpdateStatus(ctx context.Context, status *gtsmodel.Status) db
_, err := tx.
NewUpdate().
Model(status).
Column(columns...).
Where("? = ?", bun.Ident("status.id"), status.ID).
Exec(ctx)
return err

View file

@ -41,8 +41,8 @@ type Status interface {
// PutStatus stores one status in the database.
PutStatus(ctx context.Context, status *gtsmodel.Status) Error
// UpdateStatus updates one status in the database and returns it to the caller.
UpdateStatus(ctx context.Context, status *gtsmodel.Status) Error
// UpdateStatus updates one status in the database.
UpdateStatus(ctx context.Context, status *gtsmodel.Status, columns ...string) Error
// DeleteStatusByID deletes one status from the database.
DeleteStatusByID(ctx context.Context, id string) Error

View file

@ -281,8 +281,7 @@ func (d *deref) enrichAccount(ctx context.Context, requestUser string, uri *url.
}
// Fetch the latest remote account emoji IDs used in account display name/bio.
_, err = d.fetchRemoteAccountEmojis(ctx, latestAcc, requestUser)
if err != nil {
if _, err = d.fetchRemoteAccountEmojis(ctx, latestAcc, requestUser); err != nil {
log.Errorf(ctx, "error fetching remote emojis for account %s: %v", uri, err)
}
@ -312,6 +311,18 @@ func (d *deref) enrichAccount(ctx context.Context, requestUser string, uri *url.
}
}
if latestAcc.FeaturedCollectionURI != "" {
// Fetch this account's pinned statuses, now that the account is in the database.
//
// The order is important here: if we tried to fetch the pinned statuses before
// storing the account, the process might end up calling enrichAccount again,
// causing us to get stuck in a loop. By calling it now, we make sure this doesn't
// happen!
if err := d.fetchRemoteAccountFeatured(ctx, requestUser, latestAcc.FeaturedCollectionURI, latestAcc.ID); err != nil {
log.Errorf(ctx, "error fetching featured collection for account %s: %v", uri, err)
}
}
return latestAcc, nil
}
@ -569,3 +580,148 @@ func (d *deref) fetchRemoteAccountEmojis(ctx context.Context, targetAccount *gts
return changed, nil
}
// fetchRemoteAccountFeatured dereferences an account's featuredCollectionURI (if not empty).
// For each discovered status, this status will be dereferenced (if necessary) and marked as
// pinned (if necessary). Then, old pins will be removed if they're not included in new pins.
func (d *deref) fetchRemoteAccountFeatured(ctx context.Context, requestingUsername string, featuredCollectionURI string, accountID string) error {
uri, err := url.Parse(featuredCollectionURI)
if err != nil {
return err
}
tsport, err := d.transportController.NewTransportForUsername(ctx, requestingUsername)
if err != nil {
return err
}
b, err := tsport.Dereference(ctx, uri)
if err != nil {
return err
}
m := make(map[string]interface{})
if err := json.Unmarshal(b, &m); err != nil {
return fmt.Errorf("error unmarshalling bytes into json: %w", err)
}
t, err := streams.ToType(ctx, m)
if err != nil {
return fmt.Errorf("error resolving json into ap vocab type: %w", err)
}
if t.GetTypeName() != ap.ObjectOrderedCollection {
return fmt.Errorf("%s was not an OrderedCollection", featuredCollectionURI)
}
collection, ok := t.(vocab.ActivityStreamsOrderedCollection)
if !ok {
return errors.New("couldn't coerce OrderedCollection")
}
items := collection.GetActivityStreamsOrderedItems()
if items == nil {
return errors.New("nil orderedItems")
}
// Get previous pinned statuses (we'll need these later).
wasPinned, err := d.db.GetAccountPinnedStatuses(ctx, accountID)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
return fmt.Errorf("error getting account pinned statuses: %w", err)
}
statusURIs := make([]*url.URL, 0, items.Len())
for iter := items.Begin(); iter != items.End(); iter = iter.Next() {
var statusURI *url.URL
switch {
case iter.IsActivityStreamsNote():
// We got a whole Note. Extract the URI.
if note := iter.GetActivityStreamsNote(); note != nil {
if id := note.GetJSONLDId(); id != nil {
statusURI = id.GetIRI()
}
}
case iter.IsActivityStreamsArticle():
// We got a whole Article. Extract the URI.
if article := iter.GetActivityStreamsArticle(); article != nil {
if id := article.GetJSONLDId(); id != nil {
statusURI = id.GetIRI()
}
}
default:
// Try to get just the URI.
statusURI = iter.GetIRI()
}
if statusURI == nil {
continue
}
if statusURI.Host != uri.Host {
// If this status doesn't share a host with its featured
// collection URI, we shouldn't trust it. Just move on.
continue
}
// Already append this status URI to our slice.
// We do this here so that even if we can't get
// the status in the next part for some reason,
// we still know it was *meant* to be pinned.
statusURIs = append(statusURIs, statusURI)
status, _, err := d.GetStatus(ctx, requestingUsername, statusURI, false, false)
if err != nil {
// We couldn't get the status, bummer.
// Just log + move on, we can try later.
log.Errorf(ctx, "error getting status from featured collection %s: %s", featuredCollectionURI, err)
continue
}
// If the status was already pinned, we don't need to do anything.
if !status.PinnedAt.IsZero() {
continue
}
if status.AccountID != accountID {
// Someone's pinned a status that doesn't
// belong to them, this doesn't work for us.
continue
}
if status.BoostOfID != "" {
// Someone's pinned a boost. This also
// doesn't work for us.
continue
}
// All conditions are met for this status to
// be pinned, so we can finally update it.
status.PinnedAt = time.Now()
if err := d.db.UpdateStatus(ctx, status, "pinned_at"); err != nil {
log.Errorf(ctx, "error updating status in featured collection %s: %s", featuredCollectionURI, err)
}
}
// Now that we know which statuses are pinned, we should
// *unpin* previous pinned statuses that aren't included.
outerLoop:
for _, status := range wasPinned {
for _, statusURI := range statusURIs {
if status.URI == statusURI.String() {
// This status is included in most recent
// pinned uris. No need to keep checking.
continue outerLoop
}
}
// Status was pinned before, but is not included
// in most recent pinned uris, so unpin it now.
status.PinnedAt = time.Time{}
if err := d.db.UpdateStatus(ctx, status, "pinned_at"); err != nil {
return fmt.Errorf("error unpinning status: %w", err)
}
}
return nil
}

View file

@ -35,6 +35,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/media"
"github.com/superseriousbusiness/gotosocial/internal/transport"
)
// EnrichRemoteStatus takes a remote status that's already been inserted into the database in a minimal form,
@ -105,7 +106,12 @@ func (d *deref) GetStatus(ctx context.Context, username string, statusURI *url.U
// if we got here, either we didn't have the status
// in the db, or we had it but need to refetch it
statusable, derefErr := d.dereferenceStatusable(ctx, username, statusURI)
tsport, err := d.transportController.NewTransportForUsername(ctx, username)
if err != nil {
return nil, nil, newErrTransportError(fmt.Errorf("GetRemoteStatus: error creating transport for %s: %w", username, err))
}
statusable, derefErr := d.dereferenceStatusable(ctx, tsport, statusURI)
if derefErr != nil {
return nil, nil, wrapDerefError(derefErr, "GetRemoteStatus: error dereferencing statusable")
}
@ -149,17 +155,12 @@ func (d *deref) GetStatus(ctx context.Context, username string, statusURI *url.U
return status, statusable, nil
}
func (d *deref) dereferenceStatusable(ctx context.Context, username string, remoteStatusID *url.URL) (ap.Statusable, error) {
func (d *deref) dereferenceStatusable(ctx context.Context, tsport transport.Transport, remoteStatusID *url.URL) (ap.Statusable, error) {
if blocked, err := d.db.IsDomainBlocked(ctx, remoteStatusID.Host); blocked || err != nil {
return nil, fmt.Errorf("DereferenceStatusable: domain %s is blocked", remoteStatusID.Host)
}
transport, err := d.transportController.NewTransportForUsername(ctx, username)
if err != nil {
return nil, fmt.Errorf("DereferenceStatusable: transport err: %s", err)
}
b, err := transport.Dereference(ctx, remoteStatusID)
b, err := tsport.Dereference(ctx, remoteStatusID)
if err != nil {
return nil, fmt.Errorf("DereferenceStatusable: error deferencing %s: %s", remoteStatusID.String(), err)
}

View file

@ -20,6 +20,7 @@ package fedi
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
@ -27,141 +28,32 @@ import (
"github.com/superseriousbusiness/activity/streams"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/transport"
)
// FollowersGet handles the getting of a fedi/activitypub representation of a user/account's followers, performing appropriate
// authentication before returning a JSON serializable interface to the caller.
func (p *Processor) FollowersGet(ctx context.Context, requestedUsername string, requestURL *url.URL) (interface{}, gtserror.WithCode) {
// get the account the request is referring to
requestedAccount, err := p.db.GetAccountByUsernameDomain(ctx, requestedUsername, "")
if err != nil {
return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err))
}
// authenticate the request
requestingAccountURI, errWithCode := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername)
if errWithCode != nil {
return nil, errWithCode
}
requestingAccount, err := p.federator.GetAccountByURI(
transport.WithFastfail(ctx), requestedUsername, requestingAccountURI, false,
)
if err != nil {
return nil, gtserror.NewErrorUnauthorized(err)
}
blocked, err := p.db.IsBlocked(ctx, requestedAccount.ID, requestingAccount.ID, true)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
if blocked {
return nil, gtserror.NewErrorUnauthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID))
}
requestedAccountURI, err := url.Parse(requestedAccount.URI)
if err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error parsing url %s: %s", requestedAccount.URI, err))
}
requestedFollowers, err := p.federator.FederatingDB().Followers(ctx, requestedAccountURI)
if err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error fetching followers for uri %s: %s", requestedAccountURI.String(), err))
}
data, err := streams.Serialize(requestedFollowers)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
return data, nil
}
// FollowingGet handles the getting of a fedi/activitypub representation of a user/account's following, performing appropriate
// authentication before returning a JSON serializable interface to the caller.
func (p *Processor) FollowingGet(ctx context.Context, requestedUsername string, requestURL *url.URL) (interface{}, gtserror.WithCode) {
// get the account the request is referring to
requestedAccount, err := p.db.GetAccountByUsernameDomain(ctx, requestedUsername, "")
if err != nil {
return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err))
}
// authenticate the request
requestingAccountURI, errWithCode := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername)
if errWithCode != nil {
return nil, errWithCode
}
requestingAccount, err := p.federator.GetAccountByURI(
transport.WithFastfail(ctx), requestedUsername, requestingAccountURI, false,
)
if err != nil {
return nil, gtserror.NewErrorUnauthorized(err)
}
blocked, err := p.db.IsBlocked(ctx, requestedAccount.ID, requestingAccount.ID, true)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
if blocked {
return nil, gtserror.NewErrorUnauthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID))
}
requestedAccountURI, err := url.Parse(requestedAccount.URI)
if err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error parsing url %s: %s", requestedAccount.URI, err))
}
requestedFollowing, err := p.federator.FederatingDB().Following(ctx, requestedAccountURI)
if err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error fetching following for uri %s: %s", requestedAccountURI.String(), err))
}
data, err := streams.Serialize(requestedFollowing)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
return data, nil
// InboxPost handles POST requests to a user's inbox for new activitypub messages.
//
// InboxPost returns true if the request was handled as an ActivityPub POST to an actor's inbox.
// If false, the request was not an ActivityPub request and may still be handled by the caller in another way, such as serving a web page.
//
// If the error is nil, then the ResponseWriter's headers and response has already been written. If a non-nil error is returned, then no response has been written.
//
// If the Actor was constructed with the Federated Protocol enabled, side effects will occur.
//
// If the Federated Protocol is not enabled, writes the http.StatusMethodNotAllowed status code in the response. No side effects occur.
func (p *Processor) InboxPost(ctx context.Context, w http.ResponseWriter, r *http.Request) (bool, error) {
return p.federator.FederatingActor().PostInbox(ctx, w, r)
}
// OutboxGet returns the activitypub representation of a local user's outbox.
// This contains links to PUBLIC posts made by this user.
func (p *Processor) OutboxGet(ctx context.Context, requestedUsername string, page bool, maxID string, minID string, requestURL *url.URL) (interface{}, gtserror.WithCode) {
// get the account the request is referring to
requestedAccount, err := p.db.GetAccountByUsernameDomain(ctx, requestedUsername, "")
if err != nil {
return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err))
}
// authenticate the request
requestingAccountURI, errWithCode := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername)
func (p *Processor) OutboxGet(ctx context.Context, requestedUsername string, page bool, maxID string, minID string) (interface{}, gtserror.WithCode) {
requestedAccount, _, errWithCode := p.authenticate(ctx, requestedUsername)
if errWithCode != nil {
return nil, errWithCode
}
requestingAccount, err := p.federator.GetAccountByURI(
transport.WithFastfail(ctx), requestedUsername, requestingAccountURI, false,
)
if err != nil {
return nil, gtserror.NewErrorUnauthorized(err)
}
// authorize the request:
// 1. check if a block exists between the requester and the requestee
blocked, err := p.db.IsBlocked(ctx, requestedAccount.ID, requestingAccount.ID, true)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
if blocked {
return nil, gtserror.NewErrorUnauthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID))
}
var data map[string]interface{}
// now there are two scenarios:
// There are two scenarios:
// 1. we're asked for the whole collection and not a page -- we can just return the collection, with no items, but a link to 'first' page.
// 2. we're asked for a specific page; this can be either the first page or any other page
@ -209,16 +101,82 @@ func (p *Processor) OutboxGet(ctx context.Context, requestedUsername string, pag
return data, nil
}
// InboxPost handles POST requests to a user's inbox for new activitypub messages.
//
// InboxPost returns true if the request was handled as an ActivityPub POST to an actor's inbox.
// If false, the request was not an ActivityPub request and may still be handled by the caller in another way, such as serving a web page.
//
// If the error is nil, then the ResponseWriter's headers and response has already been written. If a non-nil error is returned, then no response has been written.
//
// If the Actor was constructed with the Federated Protocol enabled, side effects will occur.
//
// If the Federated Protocol is not enabled, writes the http.StatusMethodNotAllowed status code in the response. No side effects occur.
func (p *Processor) InboxPost(ctx context.Context, w http.ResponseWriter, r *http.Request) (bool, error) {
return p.federator.FederatingActor().PostInbox(ctx, w, r)
// FollowersGet handles the getting of a fedi/activitypub representation of a user/account's followers, performing appropriate
// authentication before returning a JSON serializable interface to the caller.
func (p *Processor) FollowersGet(ctx context.Context, requestedUsername string) (interface{}, gtserror.WithCode) {
requestedAccount, _, errWithCode := p.authenticate(ctx, requestedUsername)
if errWithCode != nil {
return nil, errWithCode
}
requestedAccountURI, err := url.Parse(requestedAccount.URI)
if err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error parsing url %s: %s", requestedAccount.URI, err))
}
requestedFollowers, err := p.federator.FederatingDB().Followers(ctx, requestedAccountURI)
if err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error fetching followers for uri %s: %s", requestedAccountURI.String(), err))
}
data, err := streams.Serialize(requestedFollowers)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
return data, nil
}
// FollowingGet handles the getting of a fedi/activitypub representation of a user/account's following, performing appropriate
// authentication before returning a JSON serializable interface to the caller.
func (p *Processor) FollowingGet(ctx context.Context, requestedUsername string) (interface{}, gtserror.WithCode) {
requestedAccount, _, errWithCode := p.authenticate(ctx, requestedUsername)
if errWithCode != nil {
return nil, errWithCode
}
requestedAccountURI, err := url.Parse(requestedAccount.URI)
if err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error parsing url %s: %s", requestedAccount.URI, err))
}
requestedFollowing, err := p.federator.FederatingDB().Following(ctx, requestedAccountURI)
if err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error fetching following for uri %s: %s", requestedAccountURI.String(), err))
}
data, err := streams.Serialize(requestedFollowing)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
return data, nil
}
// FeaturedCollectionGet returns an ordered collection of the requested username's Pinned posts.
// The returned collection have an `items` property which contains an ordered list of status URIs.
func (p *Processor) FeaturedCollectionGet(ctx context.Context, requestedUsername string) (interface{}, gtserror.WithCode) {
requestedAccount, _, errWithCode := p.authenticate(ctx, requestedUsername)
if errWithCode != nil {
return nil, errWithCode
}
statuses, err := p.db.GetAccountPinnedStatuses(ctx, requestedAccount.ID)
if err != nil {
if !errors.Is(err, db.ErrNoEntries) {
return nil, gtserror.NewErrorInternalError(err)
}
}
collection, err := p.tc.StatusesToASFeaturedCollection(ctx, requestedAccount.FeaturedCollectionURI, statuses)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
data, err := streams.Serialize(collection)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
return data, nil
}

View file

@ -0,0 +1,60 @@
/*
GoToSocial
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package fedi
import (
"context"
"fmt"
"net/url"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/transport"
)
func (p *Processor) authenticate(ctx context.Context, requestedUsername string) (requestedAccount, requestingAccount *gtsmodel.Account, errWithCode gtserror.WithCode) {
requestedAccount, err := p.db.GetAccountByUsernameDomain(ctx, requestedUsername, "")
if err != nil {
errWithCode = gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err))
return
}
var requestingAccountURI *url.URL
requestingAccountURI, errWithCode = p.federator.AuthenticateFederatedRequest(ctx, requestedUsername)
if errWithCode != nil {
return
}
if requestingAccount, err = p.federator.GetAccountByURI(transport.WithFastfail(ctx), requestedUsername, requestingAccountURI, false); err != nil {
errWithCode = gtserror.NewErrorUnauthorized(err)
return
}
blocked, err := p.db.IsBlocked(ctx, requestedAccount.ID, requestingAccount.ID, true)
if err != nil {
errWithCode = gtserror.NewErrorInternalError(err)
return
}
if blocked {
errWithCode = gtserror.NewErrorUnauthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID))
}
return
}

View file

@ -21,14 +21,13 @@ package fedi
import (
"context"
"fmt"
"net/url"
"github.com/superseriousbusiness/activity/streams"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
)
// EmojiGet handles the GET for a federated emoji originating from this instance.
func (p *Processor) EmojiGet(ctx context.Context, requestedEmojiID string, requestURL *url.URL) (interface{}, gtserror.WithCode) {
func (p *Processor) EmojiGet(ctx context.Context, requestedEmojiID string) (interface{}, gtserror.WithCode) {
if _, errWithCode := p.federator.AuthenticateFederatedRequest(ctx, ""); errWithCode != nil {
return nil, errWithCode
}

View file

@ -24,65 +24,36 @@ import (
"net/url"
"github.com/superseriousbusiness/activity/streams"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/transport"
)
// StatusGet handles the getting of a fedi/activitypub representation of a particular status, performing appropriate
// authentication before returning a JSON serializable interface to the caller.
func (p *Processor) StatusGet(ctx context.Context, requestedUsername string, requestedStatusID string, requestURL *url.URL) (interface{}, gtserror.WithCode) {
// get the account the request is referring to
requestedAccount, err := p.db.GetAccountByUsernameDomain(ctx, requestedUsername, "")
if err != nil {
return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err))
}
// authenticate the request
requestingAccountURI, errWithCode := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername)
func (p *Processor) StatusGet(ctx context.Context, requestedUsername string, requestedStatusID string) (interface{}, gtserror.WithCode) {
requestedAccount, requestingAccount, errWithCode := p.authenticate(ctx, requestedUsername)
if errWithCode != nil {
return nil, errWithCode
}
requestingAccount, err := p.federator.GetAccountByURI(
transport.WithFastfail(ctx), requestedUsername, requestingAccountURI, false,
)
status, err := p.db.GetStatusByID(ctx, requestedStatusID)
if err != nil {
return nil, gtserror.NewErrorUnauthorized(err)
return nil, gtserror.NewErrorNotFound(err)
}
// authorize the request:
// 1. check if a block exists between the requester and the requestee
blocked, err := p.db.IsBlocked(ctx, requestedAccount.ID, requestingAccount.ID, true)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
if status.AccountID != requestedAccount.ID {
return nil, gtserror.NewErrorNotFound(fmt.Errorf("status with id %s does not belong to account with id %s", status.ID, requestedAccount.ID))
}
if blocked {
return nil, gtserror.NewErrorUnauthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID))
}
// get the status out of the database here
s, err := p.db.GetStatusByID(ctx, requestedStatusID)
if err != nil {
return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting status with id %s and account id %s: %s", requestedStatusID, requestedAccount.ID, err))
}
if s.AccountID != requestedAccount.ID {
return nil, gtserror.NewErrorNotFound(fmt.Errorf("status with id %s does not belong to account with id %s", s.ID, requestedAccount.ID))
}
visible, err := p.filter.StatusVisible(ctx, s, requestingAccount)
visible, err := p.filter.StatusVisible(ctx, status, requestingAccount)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
if !visible {
return nil, gtserror.NewErrorNotFound(fmt.Errorf("status with id %s not visible to user with id %s", s.ID, requestingAccount.ID))
return nil, gtserror.NewErrorNotFound(fmt.Errorf("status with id %s not visible to user with id %s", status.ID, requestingAccount.ID))
}
// requester is authorized to view the status, so convert it to AP representation and serialize it
asStatus, err := p.tc.StatusToAS(ctx, s)
asStatus, err := p.tc.StatusToAS(ctx, status)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
@ -97,52 +68,27 @@ func (p *Processor) StatusGet(ctx context.Context, requestedUsername string, req
// GetStatus handles the getting of a fedi/activitypub representation of replies to a status, performing appropriate
// authentication before returning a JSON serializable interface to the caller.
func (p *Processor) StatusRepliesGet(ctx context.Context, requestedUsername string, requestedStatusID string, page bool, onlyOtherAccounts bool, minID string, requestURL *url.URL) (interface{}, gtserror.WithCode) {
// get the account the request is referring to
requestedAccount, err := p.db.GetAccountByUsernameDomain(ctx, requestedUsername, "")
if err != nil {
return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err))
}
// authenticate the request
requestingAccountURI, errWithCode := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername)
func (p *Processor) StatusRepliesGet(ctx context.Context, requestedUsername string, requestedStatusID string, page bool, onlyOtherAccounts bool, onlyOtherAccountsSet bool, minID string) (interface{}, gtserror.WithCode) {
requestedAccount, requestingAccount, errWithCode := p.authenticate(ctx, requestedUsername)
if errWithCode != nil {
return nil, errWithCode
}
requestingAccount, err := p.federator.GetAccountByURI(
transport.WithFastfail(ctx), requestedUsername, requestingAccountURI, false,
)
status, err := p.db.GetStatusByID(ctx, requestedStatusID)
if err != nil {
return nil, gtserror.NewErrorUnauthorized(err)
return nil, gtserror.NewErrorNotFound(err)
}
// authorize the request:
// 1. check if a block exists between the requester and the requestee
blocked, err := p.db.IsBlocked(ctx, requestedAccount.ID, requestingAccount.ID, true)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
if status.AccountID != requestedAccount.ID {
return nil, gtserror.NewErrorNotFound(fmt.Errorf("status with id %s does not belong to account with id %s", status.ID, requestedAccount.ID))
}
if blocked {
return nil, gtserror.NewErrorUnauthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID))
}
// get the status out of the database here
s := &gtsmodel.Status{}
if err := p.db.GetWhere(ctx, []db.Where{
{Key: "id", Value: requestedStatusID},
{Key: "account_id", Value: requestedAccount.ID},
}, s); err != nil {
return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting status with id %s and account id %s: %s", requestedStatusID, requestedAccount.ID, err))
}
visible, err := p.filter.StatusVisible(ctx, s, requestingAccount)
visible, err := p.filter.StatusVisible(ctx, status, requestingAccount)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
if !visible {
return nil, gtserror.NewErrorNotFound(fmt.Errorf("status with id %s not visible to user with id %s", s.ID, requestingAccount.ID))
return nil, gtserror.NewErrorNotFound(fmt.Errorf("status with id %s not visible to user with id %s", status.ID, requestingAccount.ID))
}
var data map[string]interface{}
@ -155,7 +101,7 @@ func (p *Processor) StatusRepliesGet(ctx context.Context, requestedUsername stri
case !page:
// scenario 1
// get the collection
collection, err := p.tc.StatusToASRepliesCollection(ctx, s, onlyOtherAccounts)
collection, err := p.tc.StatusToASRepliesCollection(ctx, status, onlyOtherAccounts)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
@ -164,10 +110,10 @@ func (p *Processor) StatusRepliesGet(ctx context.Context, requestedUsername stri
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
case page && requestURL.Query().Get("only_other_accounts") == "":
case page && !onlyOtherAccountsSet:
// scenario 2
// get the collection
collection, err := p.tc.StatusToASRepliesCollection(ctx, s, onlyOtherAccounts)
collection, err := p.tc.StatusToASRepliesCollection(ctx, status, onlyOtherAccounts)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
@ -179,7 +125,7 @@ func (p *Processor) StatusRepliesGet(ctx context.Context, requestedUsername stri
default:
// scenario 3
// get immediate children
replies, err := p.db.GetStatusChildren(ctx, s, true, minID)
replies, err := p.db.GetStatusChildren(ctx, status, true, minID)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
@ -217,7 +163,7 @@ func (p *Processor) StatusRepliesGet(ctx context.Context, requestedUsername stri
replyURIs[r.ID] = rURI
}
repliesPage, err := p.tc.StatusURIsToASRepliesPage(ctx, s, onlyOtherAccounts, minID, replyURIs)
repliesPage, err := p.tc.StatusURIsToASRepliesPage(ctx, status, onlyOtherAccounts, minID, replyURIs)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}

View file

@ -95,7 +95,7 @@ func (p *Processor) PinCreate(ctx context.Context, requestingAccount *gtsmodel.A
}
targetStatus.PinnedAt = time.Now()
if err := p.db.UpdateStatus(ctx, targetStatus); err != nil {
if err := p.db.UpdateStatus(ctx, targetStatus, "pinned_at"); err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("db error pinning status: %w", err))
}
@ -126,7 +126,7 @@ func (p *Processor) PinRemove(ctx context.Context, requestingAccount *gtsmodel.A
if targetStatus.PinnedAt.IsZero() {
targetStatus.PinnedAt = time.Time{}
if err := p.db.UpdateStatus(ctx, targetStatus); err != nil {
if err := p.db.UpdateStatus(ctx, targetStatus, "pinned_at"); err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("db error unpinning status: %w", err))
}
}

View file

@ -181,9 +181,14 @@ func (c *converter) ASRepresentationToAccount(ctx context.Context, accountable a
acct.FollowersURI = accountable.GetActivityStreamsFollowers().GetIRI().String()
}
// FeaturedURI
if accountable.GetTootFeatured() != nil && accountable.GetTootFeatured().GetIRI() != nil {
acct.FeaturedCollectionURI = accountable.GetTootFeatured().GetIRI().String()
// FeaturedURI aka pinned collection:
// Only trust featured URI if it has at least two domains,
// from the right, in common with the domain of the account
if featured := accountable.GetTootFeatured(); featured != nil && featured.IsIRI() {
if featuredURI := featured.GetIRI(); // nocollapse
featuredURI != nil && dns.CompareDomainName(acct.Domain, featuredURI.Host) >= 2 {
acct.FeaturedCollectionURI = featuredURI.String()
}
}
// TODO: FeaturedTagsURI

View file

@ -178,6 +178,9 @@ type TypeConverter interface {
//
// Appropriate 'next' and 'prev' fields will be created based on the highest and lowest IDs present in the statuses slice.
StatusesToASOutboxPage(ctx context.Context, outboxID string, maxID string, minID string, statuses []*gtsmodel.Status) (vocab.ActivityStreamsOrderedCollectionPage, error)
// StatusesToASFeaturedCollection converts a slice of statuses into an ordered collection
// of URIs, suitable for serializing and serving via the activitypub API.
StatusesToASFeaturedCollection(ctx context.Context, featuredCollectionID string, statuses []*gtsmodel.Status) (vocab.ActivityStreamsOrderedCollection, error)
// ReportToASFlag converts a gts model report into an activitystreams FLAG, suitable for federation.
ReportToASFlag(ctx context.Context, r *gtsmodel.Report) (vocab.ActivityStreamsFlag, error)

View file

@ -1296,6 +1296,34 @@ func (c *converter) OutboxToASCollection(ctx context.Context, outboxID string) (
return collection, nil
}
func (c *converter) StatusesToASFeaturedCollection(ctx context.Context, featuredCollectionID string, statuses []*gtsmodel.Status) (vocab.ActivityStreamsOrderedCollection, error) {
collection := streams.NewActivityStreamsOrderedCollection()
collectionIDProp := streams.NewJSONLDIdProperty()
featuredCollectionIDURI, err := url.Parse(featuredCollectionID)
if err != nil {
return nil, fmt.Errorf("error parsing url %s", featuredCollectionID)
}
collectionIDProp.SetIRI(featuredCollectionIDURI)
collection.SetJSONLDId(collectionIDProp)
itemsProp := streams.NewActivityStreamsOrderedItemsProperty()
for _, s := range statuses {
uri, err := url.Parse(s.URI)
if err != nil {
return nil, fmt.Errorf("error parsing url %s", s.URI)
}
itemsProp.AppendIRI(uri)
}
collection.SetActivityStreamsOrderedItems(itemsProp)
totalItemsProp := streams.NewActivityStreamsTotalItemsProperty()
totalItemsProp.Set(len(statuses))
collection.SetActivityStreamsTotalItems(totalItemsProp)
return collection, nil
}
func (c *converter) ReportToASFlag(ctx context.Context, r *gtsmodel.Report) (vocab.ActivityStreamsFlag, error) {
flag := streams.NewActivityStreamsFlag()

View file

@ -21,11 +21,13 @@ package typeutils_test
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/activity/streams"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/testrig"
)
@ -544,6 +546,96 @@ func (suite *InternalToASTestSuite) TestReportToAS() {
}`, string(bytes))
}
func (suite *InternalToASTestSuite) TestPinnedStatusesToASSomeItems() {
ctx := context.Background()
testAccount := suite.testAccounts["admin_account"]
statuses, err := suite.db.GetAccountPinnedStatuses(ctx, testAccount.ID)
if err != nil {
suite.FailNow(err.Error())
}
collection, err := suite.typeconverter.StatusesToASFeaturedCollection(ctx, testAccount.FeaturedCollectionURI, statuses)
if err != nil {
suite.FailNow(err.Error())
}
ser, err := streams.Serialize(collection)
suite.NoError(err)
bytes, err := json.MarshalIndent(ser, "", " ")
suite.NoError(err)
suite.Equal(`{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "http://localhost:8080/users/admin/collections/featured",
"orderedItems": [
"http://localhost:8080/users/admin/statuses/01F8MHAAY43M6RJ473VQFCVH37",
"http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R"
],
"totalItems": 2,
"type": "OrderedCollection"
}`, string(bytes))
}
func (suite *InternalToASTestSuite) TestPinnedStatusesToASNoItems() {
ctx := context.Background()
testAccount := suite.testAccounts["local_account_1"]
statuses, err := suite.db.GetAccountPinnedStatuses(ctx, testAccount.ID)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
suite.FailNow(err.Error())
}
collection, err := suite.typeconverter.StatusesToASFeaturedCollection(ctx, testAccount.FeaturedCollectionURI, statuses)
if err != nil {
suite.FailNow(err.Error())
}
ser, err := streams.Serialize(collection)
suite.NoError(err)
bytes, err := json.MarshalIndent(ser, "", " ")
suite.NoError(err)
suite.Equal(`{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "http://localhost:8080/users/the_mighty_zork/collections/featured",
"orderedItems": [],
"totalItems": 0,
"type": "OrderedCollection"
}`, string(bytes))
}
func (suite *InternalToASTestSuite) TestPinnedStatusesToASOneItem() {
ctx := context.Background()
testAccount := suite.testAccounts["local_account_2"]
statuses, err := suite.db.GetAccountPinnedStatuses(ctx, testAccount.ID)
if err != nil {
suite.FailNow(err.Error())
}
collection, err := suite.typeconverter.StatusesToASFeaturedCollection(ctx, testAccount.FeaturedCollectionURI, statuses)
if err != nil {
suite.FailNow(err.Error())
}
ser, err := streams.Serialize(collection)
suite.NoError(err)
bytes, err := json.MarshalIndent(ser, "", " ")
suite.NoError(err)
suite.Equal(`{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "http://localhost:8080/users/1happyturtle/collections/featured",
"orderedItems": "http://localhost:8080/users/1happyturtle/statuses/01G20ZM733MGN8J344T4ZDDFY1",
"totalItems": 1,
"type": "OrderedCollection"
}`, string(bytes))
}
func TestInternalToASTestSuite(t *testing.T) {
suite.Run(t, new(InternalToASTestSuite))
}

View file

@ -70,7 +70,7 @@ type UserURIs struct {
// The activitypub URI for this user's liked posts eg., https://example.org/users/example_user/liked
LikedURI string
// The activitypub URI for this user's featured collections, eg., https://example.org/users/example_user/collections/featured
CollectionURI string
FeaturedCollectionURI string
// The URI for this user's public key, eg., https://example.org/users/example_user/publickey
PublicKeyURI string
}
@ -152,15 +152,15 @@ func GenerateURIsForAccount(username string) *UserURIs {
UserURL: userURL,
StatusesURL: statusesURL,
UserURI: userURI,
StatusesURI: statusesURI,
InboxURI: inboxURI,
OutboxURI: outboxURI,
FollowersURI: followersURI,
FollowingURI: followingURI,
LikedURI: likedURI,
CollectionURI: collectionURI,
PublicKeyURI: publicKeyURI,
UserURI: userURI,
StatusesURI: statusesURI,
InboxURI: inboxURI,
OutboxURI: outboxURI,
FollowersURI: followersURI,
FollowingURI: followingURI,
LikedURI: likedURI,
FeaturedCollectionURI: collectionURI,
PublicKeyURI: publicKeyURI,
}
}

View file

@ -132,7 +132,7 @@ func (m *Module) returnAPStatus(ctx context.Context, c *gin.Context, username st
ctx = context.WithValue(ctx, ap.ContextRequestingPublicKeySignature, signature)
}
status, errWithCode := m.processor.Fedi().StatusGet(ctx, username, statusID, c.Request.URL)
status, errWithCode := m.processor.Fedi().StatusGet(ctx, username, statusID)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) //nolint:contextcheck
return