From e670c32a9147f632d06ee10c170201677ec1e12d Mon Sep 17 00:00:00 2001 From: Tobi Smethurst <31960611+tsmethurst@users.noreply.github.com> Date: Mon, 24 May 2021 18:49:48 +0200 Subject: [PATCH] Faves (#31) * start on federating faves * outbound federation of likes working --- internal/db/db.go | 2 +- internal/db/pg/pg.go | 46 +++++++------- internal/federation/federating_db.go | 17 ++++- internal/gtsmodel/statusfave.go | 10 ++- internal/message/accountprocess.go | 7 +- internal/message/fromclientapiprocess.go | 34 +++++++++- internal/message/fromcommonprocess.go | 4 ++ internal/message/statusprocess.go | 51 ++++++++++++--- internal/typeutils/converter.go | 3 + internal/typeutils/internaltoas.go | 81 ++++++++++++++++++++++++ internal/util/regexes.go | 13 ++-- internal/util/uri.go | 29 +++++++-- 12 files changed, 250 insertions(+), 47 deletions(-) diff --git a/internal/db/db.go b/internal/db/db.go index e43318c..9ad8115 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -270,7 +270,7 @@ type DB interface { // FaveStatus faves the given status, using accountID as the faver. // The returned fave will be nil if the status was already faved. - FaveStatus(status *gtsmodel.Status, accountID string) (*gtsmodel.StatusFave, error) + // FaveStatus(status *gtsmodel.Status, accountID string) (*gtsmodel.StatusFave, error) // UnfaveStatus unfaves the given status, using accountID as the unfaver (sure, that's a word). // The returned fave will be nil if the status was already not faved. diff --git a/internal/db/pg/pg.go b/internal/db/pg/pg.go index 01dc714..7f65055 100644 --- a/internal/db/pg/pg.go +++ b/internal/db/pg/pg.go @@ -1037,32 +1037,32 @@ func (ps *postgresService) StatusBookmarkedBy(status *gtsmodel.Status, accountID return ps.conn.Model(>smodel.StatusBookmark{}).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Exists() } -func (ps *postgresService) FaveStatus(status *gtsmodel.Status, accountID string) (*gtsmodel.StatusFave, error) { - // first check if a fave already exists, we can just return if so - existingFave := >smodel.StatusFave{} - err := ps.conn.Model(existingFave).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Select() - if err == nil { - // fave already exists so just return nothing at all - return nil, nil - } +// func (ps *postgresService) FaveStatus(status *gtsmodel.Status, accountID string) (*gtsmodel.StatusFave, error) { +// // first check if a fave already exists, we can just return if so +// existingFave := >smodel.StatusFave{} +// err := ps.conn.Model(existingFave).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Select() +// if err == nil { +// // fave already exists so just return nothing at all +// return nil, nil +// } - // an error occurred so it might exist or not, we don't know - if err != pg.ErrNoRows { - return nil, err - } +// // an error occurred so it might exist or not, we don't know +// if err != pg.ErrNoRows { +// return nil, err +// } - // it doesn't exist so create it - newFave := >smodel.StatusFave{ - AccountID: accountID, - TargetAccountID: status.AccountID, - StatusID: status.ID, - } - if _, err = ps.conn.Model(newFave).Insert(); err != nil { - return nil, err - } +// // it doesn't exist so create it +// newFave := >smodel.StatusFave{ +// AccountID: accountID, +// TargetAccountID: status.AccountID, +// StatusID: status.ID, +// } +// if _, err = ps.conn.Model(newFave).Insert(); err != nil { +// return nil, err +// } - return newFave, nil -} +// return newFave, nil +// } func (ps *postgresService) UnfaveStatus(status *gtsmodel.Status, accountID string) (*gtsmodel.StatusFave, error) { // if a fave doesn't exist, we don't need to do anything diff --git a/internal/federation/federating_db.go b/internal/federation/federating_db.go index af68590..6ae4dc0 100644 --- a/internal/federation/federating_db.go +++ b/internal/federation/federating_db.go @@ -777,7 +777,7 @@ func (f *federatingDB) NewID(c context.Context, t vocab.Type) (id *url.URL, err if iter.IsIRI() { actorAccount := >smodel.Account{} if err := f.db.GetWhere([]db.Where{{Key: "uri", Value: iter.GetIRI().String()}}, actorAccount); err == nil { // if there's an error here, just use the fallback behavior -- we don't need to return an error here - return url.Parse(util.GenerateURIForFollow(actorAccount.Username, f.config.Protocol, f.config.Host)) + return url.Parse(util.GenerateURIForFollow(actorAccount.Username, f.config.Protocol, f.config.Host, uuid.NewString())) } } } @@ -787,7 +787,7 @@ func (f *federatingDB) NewID(c context.Context, t vocab.Type) (id *url.URL, err // ID might already be set on a note we've created, so check it here and return it if it is note, ok := t.(vocab.ActivityStreamsNote) if !ok { - return nil, errors.New("newid: follow couldn't be parsed into vocab.ActivityStreamsNote") + return nil, errors.New("newid: note couldn't be parsed into vocab.ActivityStreamsNote") } idProp := note.GetJSONLDId() if idProp != nil { @@ -795,6 +795,19 @@ func (f *federatingDB) NewID(c context.Context, t vocab.Type) (id *url.URL, err return idProp.GetIRI(), nil } } + case gtsmodel.ActivityStreamsLike: + // LIKE aka FAVE + // ID might already be set on a fave we've created, so check it here and return it if it is + fave, ok := t.(vocab.ActivityStreamsLike) + if !ok { + return nil, errors.New("newid: fave couldn't be parsed into vocab.ActivityStreamsLike") + } + idProp := fave.GetJSONLDId() + if idProp != nil { + if idProp.IsIRI() { + return idProp.GetIRI(), nil + } + } } // fallback default behavior: just return a random UUID after our protocol and host diff --git a/internal/gtsmodel/statusfave.go b/internal/gtsmodel/statusfave.go index 9fb92b9..efbc37e 100644 --- a/internal/gtsmodel/statusfave.go +++ b/internal/gtsmodel/statusfave.go @@ -32,7 +32,13 @@ type StatusFave struct { TargetAccountID string `pg:",notnull"` // database id of the status that has been 'faved' StatusID string `pg:",notnull"` + // ActivityPub URI of this fave + URI string `pg:",notnull"` - // FavedStatus is the status being interacted with. It won't be put or retrieved from the db, it's just for conveniently passing a pointer around. - FavedStatus *Status `pg:"-"` + // GTSStatus is the status being interacted with. It won't be put or retrieved from the db, it's just for conveniently passing a pointer around. + GTSStatus *Status `pg:"-"` + // GTSTargetAccount is the account being interacted with. It won't be put or retrieved from the db, it's just for conveniently passing a pointer around. + GTSTargetAccount *Account `pg:"-"` + // GTSFavingAccount is the account doing the faving. It won't be put or retrieved from the db, it's just for conveniently passing a pointer around. + GTSFavingAccount *Account `pg:"-"` } diff --git a/internal/message/accountprocess.go b/internal/message/accountprocess.go index 424081c..22542f0 100644 --- a/internal/message/accountprocess.go +++ b/internal/message/accountprocess.go @@ -22,6 +22,7 @@ import ( "errors" "fmt" + "github.com/google/uuid" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" @@ -417,11 +418,15 @@ func (p *processor) AccountFollowCreate(authed *oauth.Auth, form *apimodel.Accou } // make the follow request + + newFollowID := uuid.NewString() + fr := >smodel.FollowRequest{ + ID: newFollowID, AccountID: authed.Account.ID, TargetAccountID: form.TargetAccountID, ShowReblogs: true, - URI: util.GenerateURIForFollow(authed.Account.Username, p.config.Protocol, p.config.Host), + URI: util.GenerateURIForFollow(authed.Account.Username, p.config.Protocol, p.config.Host, newFollowID), Notify: false, } if form.Reblogs != nil { diff --git a/internal/message/fromclientapiprocess.go b/internal/message/fromclientapiprocess.go index e91bd6c..b011215 100644 --- a/internal/message/fromclientapiprocess.go +++ b/internal/message/fromclientapiprocess.go @@ -44,7 +44,7 @@ func (p *processor) processFromClientAPI(clientMsg gtsmodel.FromClientAPI) error return err } - if status.VisibilityAdvanced.Federated { + if status.VisibilityAdvanced != nil && status.VisibilityAdvanced.Federated { return p.federateStatus(status) } return nil @@ -60,6 +60,18 @@ func (p *processor) processFromClientAPI(clientMsg gtsmodel.FromClientAPI) error } return p.federateFollow(follow, clientMsg.OriginAccount, clientMsg.TargetAccount) + case gtsmodel.ActivityStreamsLike: + // CREATE LIKE/FAVE + fave, ok := clientMsg.GTSModel.(*gtsmodel.StatusFave) + if !ok { + return errors.New("fave was not parseable as *gtsmodel.StatusFave") + } + + if err := p.notifyFave(fave); err != nil { + return err + } + + return p.federateFave(fave, clientMsg.OriginAccount, clientMsg.TargetAccount) } case gtsmodel.ActivityStreamsUpdate: // UPDATE @@ -214,3 +226,23 @@ func (p *processor) federateAcceptFollowRequest(follow *gtsmodel.Follow, originA _, err = p.federator.FederatingActor().Send(context.Background(), outboxIRI, accept) return err } + +func (p *processor) federateFave(fave *gtsmodel.StatusFave, originAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) error { + // if both accounts are local there's nothing to do here + if originAccount.Domain == "" && targetAccount.Domain == "" { + return nil + } + + // create the AS fave + asFave, err := p.tc.FaveToAS(fave) + if err != nil { + return fmt.Errorf("federateFave: error converting fave to as format: %s", err) + } + + outboxIRI, err := url.Parse(originAccount.OutboxURI) + if err != nil { + return fmt.Errorf("federateFave: error parsing outboxURI %s: %s", originAccount.OutboxURI, err) + } + _, err = p.federator.FederatingActor().Send(context.Background(), outboxIRI, asFave) + return err +} diff --git a/internal/message/fromcommonprocess.go b/internal/message/fromcommonprocess.go index 486da39..2403a8b 100644 --- a/internal/message/fromcommonprocess.go +++ b/internal/message/fromcommonprocess.go @@ -27,3 +27,7 @@ func (p *processor) notifyStatus(status *gtsmodel.Status) error { func (p *processor) notifyFollow(follow *gtsmodel.Follow) error { return nil } + +func (p *processor) notifyFave(fave *gtsmodel.StatusFave) error { + return nil +} diff --git a/internal/message/statusprocess.go b/internal/message/statusprocess.go index 86a07eb..6786b2d 100644 --- a/internal/message/statusprocess.go +++ b/internal/message/statusprocess.go @@ -25,6 +25,7 @@ import ( "github.com/google/uuid" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/util" @@ -168,6 +169,14 @@ func (p *processor) StatusFave(authed *oauth.Auth, targetStatusID string) (*apim return nil, fmt.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err) } + var boostOfStatus *gtsmodel.Status + if targetStatus.BoostOfID != "" { + boostOfStatus = >smodel.Status{} + if err := p.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { + return nil, fmt.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err) + } + } + l.Trace("going to see if status is visible") visible, err := p.db.StatusVisible(targetStatus, targetAccount, authed.Account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that if err != nil { @@ -185,20 +194,44 @@ func (p *processor) StatusFave(authed *oauth.Auth, targetStatusID string) (*apim } } - // it's visible! it's faveable! so let's fave the FUCK out of it - _, err = p.db.FaveStatus(targetStatus, authed.Account.ID) - if err != nil { - return nil, fmt.Errorf("error faveing status: %s", err) + // first check if the status is already faved, if so we don't need to do anything + newFave := true + gtsFave := >smodel.Status{} + if err := p.db.GetWhere([]db.Where{{Key: "status_id", Value: targetStatus.ID}, {Key: "account_id", Value: authed.Account.ID}}, gtsFave); err == nil { + // we already have a fave for this status + newFave = false } - var boostOfStatus *gtsmodel.Status - if targetStatus.BoostOfID != "" { - boostOfStatus = >smodel.Status{} - if err := p.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { - return nil, fmt.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err) + if newFave { + thisFaveID := uuid.NewString() + + // we need to create a new fave in the database + gtsFave := >smodel.StatusFave{ + ID: thisFaveID, + AccountID: authed.Account.ID, + TargetAccountID: targetAccount.ID, + StatusID: targetStatus.ID, + URI: util.GenerateURIForLike(authed.Account.Username, p.config.Protocol, p.config.Host, thisFaveID), + GTSStatus: targetStatus, + GTSTargetAccount: targetAccount, + GTSFavingAccount: authed.Account, + } + + if err := p.db.Put(gtsFave); err != nil { + return nil, err + } + + // send the new fave through the processor channel for federation etc + p.fromClientAPI <- gtsmodel.FromClientAPI{ + APObjectType: gtsmodel.ActivityStreamsLike, + APActivityType: gtsmodel.ActivityStreamsCreate, + GTSModel: gtsFave, + OriginAccount: authed.Account, + TargetAccount: targetAccount, } } + // return the mastodon representation of the target status mastoStatus, err := p.tc.StatusToMasto(targetStatus, targetAccount, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) if err != nil { return nil, fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err) diff --git a/internal/typeutils/converter.go b/internal/typeutils/converter.go index 63e201d..3ced209 100644 --- a/internal/typeutils/converter.go +++ b/internal/typeutils/converter.go @@ -126,6 +126,9 @@ type TypeConverter interface { // AttachmentToAS converts a gts model media attachment into an activity streams Attachment, suitable for federation AttachmentToAS(a *gtsmodel.MediaAttachment) (vocab.ActivityStreamsDocument, error) + + // FaveToAS converts a gts model status fave into an activityStreams LIKE, suitable for federation. + FaveToAS(f *gtsmodel.StatusFave) (vocab.ActivityStreamsLike, error) } type converter struct { diff --git a/internal/typeutils/internaltoas.go b/internal/typeutils/internaltoas.go index b7056cc..821720e 100644 --- a/internal/typeutils/internaltoas.go +++ b/internal/typeutils/internaltoas.go @@ -559,3 +559,84 @@ func (c *converter) AttachmentToAS(a *gtsmodel.MediaAttachment) (vocab.ActivityS return doc, nil } + +/* + We want to end up with something like this: + + { + "@context": "https://www.w3.org/ns/activitystreams", + "actor": "https://ondergrond.org/users/dumpsterqueer", + "id": "https://ondergrond.org/users/dumpsterqueer#likes/44584", + "object": "https://testingtesting123.xyz/users/gotosocial_test_account/statuses/771aea80-a33d-4d6d-8dfd-57d4d2bfcbd4", + "type": "Like" + } +*/ +func (c *converter) FaveToAS(f *gtsmodel.StatusFave) (vocab.ActivityStreamsLike, error) { + // check if targetStatus is already pinned to this fave, and fetch it if not + if f.GTSStatus == nil { + s := >smodel.Status{} + if err := c.db.GetByID(f.StatusID, s); err != nil { + return nil, fmt.Errorf("FaveToAS: error fetching target status from database: %s", err) + } + f.GTSStatus = s + } + + // check if the targetAccount is already pinned to this fave, and fetch it if not + if f.GTSTargetAccount == nil { + a := >smodel.Account{} + if err := c.db.GetByID(f.TargetAccountID, a); err != nil { + return nil, fmt.Errorf("FaveToAS: error fetching target account from database: %s", err) + } + f.GTSTargetAccount = a + } + + // check if the faving account is already pinned to this fave, and fetch it if not + if f.GTSFavingAccount == nil { + a := >smodel.Account{} + if err := c.db.GetByID(f.AccountID, a); err != nil { + return nil, fmt.Errorf("FaveToAS: error fetching faving account from database: %s", err) + } + f.GTSFavingAccount = a + } + + // create the like + like := streams.NewActivityStreamsLike() + + // set the actor property to the fave-ing account's URI + actorProp := streams.NewActivityStreamsActorProperty() + actorIRI, err := url.Parse(f.GTSFavingAccount.URI) + if err != nil { + return nil, fmt.Errorf("FaveToAS: error parsing uri %s: %s", f.GTSFavingAccount.URI, err) + } + actorProp.AppendIRI(actorIRI) + like.SetActivityStreamsActor(actorProp) + + // set the ID property to the fave's URI + idProp := streams.NewJSONLDIdProperty() + idIRI, err := url.Parse(f.URI) + if err != nil { + return nil, fmt.Errorf("FaveToAS: error parsing uri %s: %s", f.URI, err) + } + idProp.Set(idIRI) + like.SetJSONLDId(idProp) + + // set the object property to the target status's URI + objectProp := streams.NewActivityStreamsObjectProperty() + statusIRI, err := url.Parse(f.GTSStatus.URI) + if err != nil { + return nil, fmt.Errorf("FaveToAS: error parsing uri %s: %s", f.GTSStatus.URI, err) + } + objectProp.AppendIRI(statusIRI) + like.SetActivityStreamsObject(objectProp) + + // set the TO property to the target account's IRI + toProp := streams.NewActivityStreamsToProperty() + toIRI, err := url.Parse(f.GTSTargetAccount.URI) + if err != nil { + return nil, fmt.Errorf("FaveToAS: error parsing uri %s: %s", f.GTSTargetAccount.URI, err) + } + toProp.AppendIRI(toIRI) + like.SetActivityStreamsTo(toProp) + + return like, nil +} diff --git a/internal/util/regexes.go b/internal/util/regexes.go index adab8c8..55773c3 100644 --- a/internal/util/regexes.go +++ b/internal/util/regexes.go @@ -85,13 +85,18 @@ var ( // followingPathRegex parses a path that validates and captures the username part from eg /users/example_username/following followingPathRegex = regexp.MustCompile(followingPathRegexString) - likedPathRegexString = fmt.Sprintf(`^/?%s/%s/%s$`, UsersPath, usernameRegexString, LikedPath) - // followingPathRegex parses a path that validates and captures the username part from eg /users/example_username/liked - likedPathRegex = regexp.MustCompile(likedPathRegexString) - // see https://ihateregex.io/expr/uuid/ uuidRegexString = `[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}` + likedPathRegexString = fmt.Sprintf(`^/?%s/(%s)/%s$`, UsersPath, usernameRegexString, LikedPath) + // likedPathRegex parses a path that validates and captures the username part from eg /users/example_username/liked + likedPathRegex = regexp.MustCompile(likedPathRegexString) + + likePathRegexString = fmt.Sprintf(`^/?%s/(%s)/%s/(%s)$`, UsersPath, usernameRegexString, LikedPath, uuidRegexString) + // likePathRegex parses a path that validates and captures the username part and the uuid part + // from eg /users/example_username/liked/123e4567-e89b-12d3-a456-426655440000. + likePathRegex = regexp.MustCompile(likePathRegexString) + statusesPathRegexString = fmt.Sprintf(`^/?%s/(%s)/%s/(%s)$`, UsersPath, usernameRegexString, StatusesPath, uuidRegexString) // statusesPathRegex parses a path that validates and captures the username part and the uuid part // from eg /users/example_username/statuses/123e4567-e89b-12d3-a456-426655440000. diff --git a/internal/util/uri.go b/internal/util/uri.go index 0ee4a51..8d64bdd 100644 --- a/internal/util/uri.go +++ b/internal/util/uri.go @@ -22,8 +22,6 @@ import ( "fmt" "net/url" "strings" - - "github.com/google/uuid" ) const ( @@ -109,8 +107,14 @@ type UserURIs struct { // GenerateURIForFollow returns the AP URI for a new follow -- something like: // https://example.org/users/whatever_user/follow/41c7f33f-1060-48d9-84df-38dcb13cf0d8 -func GenerateURIForFollow(username string, protocol string, host string) string { - return fmt.Sprintf("%s://%s/%s/%s/%s", protocol, host, UsersPath, FollowPath, uuid.NewString()) +func GenerateURIForFollow(username string, protocol string, host string, thisFollowID string) string { + return fmt.Sprintf("%s://%s/%s/%s/%s", protocol, host, UsersPath, FollowPath, thisFollowID) +} + +// GenerateURIForFollow returns the AP URI for a new like/fave -- something like: +// https://example.org/users/whatever_user/liked/41c7f33f-1060-48d9-84df-38dcb13cf0d8 +func GenerateURIForLike(username string, protocol string, host string, thisFavedID string) string { + return fmt.Sprintf("%s://%s/%s/%s/%s", protocol, host, UsersPath, LikedPath, thisFavedID) } // GenerateURIsForAccount throws together a bunch of URIs for the given username, with the given protocol and host. @@ -183,6 +187,11 @@ func IsLikedPath(id *url.URL) bool { return likedPathRegex.MatchString(strings.ToLower(id.Path)) } +// IsLikedPath returns true if the given URL path corresponds to eg /users/example_username/liked/SOME_UUID_OF_A_STATUS +func IsLikePath(id *url.URL) bool { + return likePathRegex.MatchString(strings.ToLower(id.Path)) +} + // IsStatusesPath returns true if the given URL path corresponds to eg /users/example_username/statuses/SOME_UUID_OF_A_STATUS func IsStatusesPath(id *url.URL) bool { return statusesPathRegex.MatchString(strings.ToLower(id.Path)) @@ -254,3 +263,15 @@ func ParseFollowingPath(id *url.URL) (username string, err error) { username = matches[1] return } + +// ParseLikedPath returns the username and uuid from a path such as /users/example_username/liked/SOME_UUID_OF_A_STATUS +func ParseLikedPath(id *url.URL) (username string, uuid string, err error) { + matches := likePathRegex.FindStringSubmatch(id.Path) + if len(matches) != 3 { + err = fmt.Errorf("expected 3 matches but matches length was %d", len(matches)) + return + } + username = matches[1] + uuid = matches[2] + return +}