From d69786ef1742cf3a366d715bec9c7d117511aa0c Mon Sep 17 00:00:00 2001 From: tsmethurst Date: Wed, 19 May 2021 22:07:27 +0200 Subject: [PATCH] get remote follows/accepts working --- internal/api/client/account/account.go | 3 + internal/api/client/account/follow.go | 56 ++++ internal/api/client/account/relationships.go | 11 +- internal/api/model/account.go | 15 +- internal/db/db.go | 11 +- internal/db/pg/pg.go | 7 + internal/federation/federating_db.go | 266 ++++++++++++------- internal/federation/federatingprotocol.go | 6 +- internal/gtsmodel/messages.go | 2 + internal/message/accountprocess.go | 84 ++++++ internal/message/fromclientapiprocess.go | 30 +++ internal/message/fromcommonprocess.go | 4 + internal/message/processor.go | 6 +- internal/typeutils/converter.go | 5 +- internal/typeutils/internaltoas.go | 47 ++++ internal/util/uri.go | 10 + 16 files changed, 459 insertions(+), 104 deletions(-) create mode 100644 internal/api/client/account/follow.go diff --git a/internal/api/client/account/account.go b/internal/api/client/account/account.go index 583eb3e42..e9ed76397 100644 --- a/internal/api/client/account/account.go +++ b/internal/api/client/account/account.go @@ -59,6 +59,8 @@ const ( GetFollowersPath = BasePathWithID + "/followers" // GetRelationshipsPath is for showing an account's relationship with other accounts GetRelationshipsPath = BasePath + "/relationships" + // FollowPath is for POSTing new follows to, and updating existing follows + PostFollowPath = BasePathWithID + "/follow" ) // Module implements the ClientAPIModule interface for account-related actions @@ -85,6 +87,7 @@ func (m *Module) Route(r router.Router) error { r.AttachHandler(http.MethodGet, GetStatusesPath, m.AccountStatusesGETHandler) r.AttachHandler(http.MethodGet, GetFollowersPath, m.AccountFollowersGETHandler) r.AttachHandler(http.MethodGet, GetRelationshipsPath, m.AccountRelationshipsGETHandler) + r.AttachHandler(http.MethodPost, PostFollowPath, m.AccountFollowPOSTHandler) return nil } diff --git a/internal/api/client/account/follow.go b/internal/api/client/account/follow.go new file mode 100644 index 000000000..bee41c280 --- /dev/null +++ b/internal/api/client/account/follow.go @@ -0,0 +1,56 @@ +/* + GoToSocial + Copyright (C) 2021 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 . +*/ + +package account + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// AccountFollowPOSTHandler is the endpoint for creating a new follow request to the target account +func (m *Module) AccountFollowPOSTHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + + targetAcctID := c.Param(IDKey) + if targetAcctID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "no account id specified"}) + return + } + form := &model.AccountFollowRequest{} + if err := c.ShouldBind(form); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + form.TargetAccountID = targetAcctID + + relationship, errWithCode := m.processor.AccountFollowCreate(authed, form) + if errWithCode != nil { + c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + return + } + + c.JSON(http.StatusOK, relationship) +} diff --git a/internal/api/client/account/relationships.go b/internal/api/client/account/relationships.go index b5d0ca837..fd96867ac 100644 --- a/internal/api/client/account/relationships.go +++ b/internal/api/client/account/relationships.go @@ -21,9 +21,14 @@ func (m *Module) AccountRelationshipsGETHandler(c *gin.Context) { targetAccountIDs := c.QueryArray("id[]") if len(targetAccountIDs) == 0 { - l.Debug("no account id specified in query") - c.JSON(http.StatusBadRequest, gin.H{"error": "no account id specified"}) - return + // check fallback -- let's be generous and see if maybe it's just set as 'id'? + id := c.Query("id") + if id == "" { + l.Debug("no account id specified in query") + c.JSON(http.StatusBadRequest, gin.H{"error": "no account id specified"}) + return + } + targetAccountIDs = append(targetAccountIDs, id) } relationships := []model.Relationship{} diff --git a/internal/api/model/account.go b/internal/api/model/account.go index c8c3c3837..ba5ac567a 100644 --- a/internal/api/model/account.go +++ b/internal/api/model/account.go @@ -130,7 +130,18 @@ type UpdateSource struct { // By default, max 4 fields and 255 characters per property/value. type UpdateField struct { // Name of the field - Name *string `form:"name"` + Name *string `form:"name" json:"name" xml:"name"` // Value of the field - Value *string `form:"value"` + Value *string `form:"value" json:"value" xml:"value"` +} + +// AccountFollowRequest is for parsing requests at /api/v1/accounts/:id/follow +type AccountFollowRequest struct { + // ID of the account to follow request + // This should be a URL parameter not a form field + TargetAccountID string `form:"-"` + // Show reblogs for this account? + Reblogs *bool `form:"reblogs" json:"reblogs" xml:"reblogs"` + // Notify when this account posts? + Notify *bool `form:"notify" json:"notify" xml:"notify"` } diff --git a/internal/db/db.go b/internal/db/db.go index 509ff4e26..c1a252ada 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -32,11 +32,17 @@ const ( // ErrNoEntries is to be returned from the DB interface when no entries are found for a given query. type ErrNoEntries struct{} - func (e ErrNoEntries) Error() string { return "no entries" } +// ErrAlreadyExists is to be returned from the DB interface when an entry already exists for a given query or its constraints. +type ErrAlreadyExists struct{} +func (e ErrAlreadyExists) Error() string { + return "already exists" +} + + // DB provides methods for interacting with an underlying database or other storage mechanism (for now, just postgres). // Note that in all of the functions below, the passed interface should be a pointer or a slice, which will then be populated // by whatever is returned from the database. @@ -226,6 +232,9 @@ type DB interface { // Follows returns true if sourceAccount follows target account, or an error if something goes wrong while finding out. Follows(sourceAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) (bool, error) + // FollowRequested returns true if sourceAccount has requested to follow target account, or an error if something goes wrong while finding out. + FollowRequested(sourceAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) (bool, error) + // Mutuals returns true if account1 and account2 both follow each other, or an error if something goes wrong while finding out. Mutuals(account1 *gtsmodel.Account, account2 *gtsmodel.Account) (bool, error) diff --git a/internal/db/pg/pg.go b/internal/db/pg/pg.go index 8ebe662aa..aeb011592 100644 --- a/internal/db/pg/pg.go +++ b/internal/db/pg/pg.go @@ -242,6 +242,9 @@ func (ps *postgresService) GetAll(i interface{}) error { func (ps *postgresService) Put(i interface{}) error { _, err := ps.conn.Model(i).Insert(i) + if err != nil && strings.Contains(err.Error(), "duplicate key value violates unique constraint") { + return db.ErrAlreadyExists{} + } return err } @@ -900,6 +903,10 @@ func (ps *postgresService) Follows(sourceAccount *gtsmodel.Account, targetAccoun return ps.conn.Model(>smodel.Follow{}).Where("account_id = ?", sourceAccount.ID).Where("target_account_id = ?", targetAccount.ID).Exists() } +func (ps *postgresService) FollowRequested(sourceAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) (bool, error) { + return ps.conn.Model(>smodel.FollowRequest{}).Where("account_id = ?", sourceAccount.ID).Where("target_account_id = ?", targetAccount.ID).Exists() +} + func (ps *postgresService) Mutuals(account1 *gtsmodel.Account, account2 *gtsmodel.Account) (bool, error) { // make sure account 1 follows account 2 f1, err := ps.conn.Model(>smodel.Follow{}).Where("account_id = ?", account1.ID).Where("target_account_id = ?", account2.ID).Exists() diff --git a/internal/federation/federating_db.go b/internal/federation/federating_db.go index 0c0206008..8c5d327fd 100644 --- a/internal/federation/federating_db.go +++ b/internal/federation/federating_db.go @@ -40,7 +40,8 @@ import ( type FederatingDB interface { pub.Database - Undo(c context.Context, asType vocab.Type) error + Undo(ctx context.Context, undo vocab.ActivityStreamsUndo) error + Accept(ctx context.Context, accept vocab.ActivityStreamsAccept) error } // FederatingDB uses the underlying DB interface to implement the go-fed pub.Database interface. @@ -352,6 +353,7 @@ func (f *federatingDB) Get(c context.Context, id *url.URL) (value vocab.Type, er if err := f.db.GetWhere("uri", id.String(), acct); err != nil { return nil, err } + l.Debug("is user path! returning account") return f.typeConverter.AccountToAS(acct) } @@ -426,6 +428,9 @@ func (f *federatingDB) Create(ctx context.Context, asType vocab.Type) error { return fmt.Errorf("error converting note to status: %s", err) } if err := f.db.Put(status); err != nil { + if _, ok := err.(db.ErrAlreadyExists); ok { + return nil + } return fmt.Errorf("database error inserting status: %s", err) } @@ -461,98 +466,6 @@ func (f *federatingDB) Create(ctx context.Context, asType vocab.Type) error { return nil } -func (f *federatingDB) Undo(ctx context.Context, asType vocab.Type) error { - l := f.log.WithFields( - logrus.Fields{ - "func": "Undo", - "asType": asType.GetTypeName(), - }, - ) - m, err := streams.Serialize(asType) - if err != nil { - return err - } - b, err := json.Marshal(m) - if err != nil { - return err - } - l.Debugf("received UNDO asType %s", string(b)) - - targetAcctI := ctx.Value(util.APAccount) - if targetAcctI == nil { - l.Error("UNDO: target account wasn't set on context") - return nil - } - targetAcct, ok := targetAcctI.(*gtsmodel.Account) - if !ok { - l.Error("UNDO: target account was set on context but couldn't be parsed") - return nil - } - - // fromFederatorChanI := ctx.Value(util.APFromFederatorChanKey) - // if fromFederatorChanI == nil { - // l.Error("from federator channel wasn't set on context") - // return nil - // } - // fromFederatorChan, ok := fromFederatorChanI.(chan gtsmodel.FromFederator) - // if !ok { - // l.Error("from federator channel was set on context but couldn't be parsed") - // return nil - // } - - switch asType.GetTypeName() { - // UNDO - case gtsmodel.ActivityStreamsUndo: - undo, ok := asType.(vocab.ActivityStreamsUndo) - if !ok { - return errors.New("UNDO: couldn't parse UNDO into vocab.ActivityStreamsUndo") - } - undoObject := undo.GetActivityStreamsObject() - if undoObject == nil { - return errors.New("UNDO: no object set on vocab.ActivityStreamsUndo") - } - - for iter := undoObject.Begin(); iter != undoObject.End(); iter = iter.Next() { - switch iter.GetType().GetTypeName() { - case string(gtsmodel.ActivityStreamsFollow): - // UNDO FOLLOW - ASFollow, ok := iter.GetType().(vocab.ActivityStreamsFollow) - if !ok { - return errors.New("UNDO: couldn't parse follow into vocab.ActivityStreamsFollow") - } - // make sure the actor owns the follow - if !sameActor(undo.GetActivityStreamsActor(), ASFollow.GetActivityStreamsActor()) { - return errors.New("UNDO: follow actor and activity actor not the same") - } - // convert the follow to something we can understand - gtsFollow, err := f.typeConverter.ASFollowToFollow(ASFollow) - if err != nil { - return fmt.Errorf("UNDO: error converting asfollow to gtsfollow: %s", err) - } - // make sure the addressee of the original follow is the same as whatever inbox this landed in - if gtsFollow.TargetAccountID != targetAcct.ID { - return errors.New("UNDO: follow object account and inbox account were not the same") - } - // delete any existing FOLLOW - if err := f.db.DeleteWhere("uri", gtsFollow.URI, >smodel.Follow{}); err != nil { - return fmt.Errorf("UNDO: db error removing follow: %s", err) - } - // delete any existing FOLLOW REQUEST - if err := f.db.DeleteWhere("uri", gtsFollow.URI, >smodel.FollowRequest{}); err != nil { - return fmt.Errorf("UNDO: db error removing follow request: %s", err) - } - l.Debug("follow undone") - return nil - case string(gtsmodel.ActivityStreamsLike): - // UNDO LIKE - case string(gtsmodel.ActivityStreamsAnnounce): - // UNDO BOOST/REBLOG/ANNOUNCE - } - } - } - return nil -} - // Update sets an existing entry to the database based on the value's // id. // @@ -715,9 +628,38 @@ func (f *federatingDB) NewID(c context.Context, t vocab.Type) (id *url.URL, err if err != nil { return nil, err } - l.Debugf("received NEWID request for asType %s", string(b)) + switch t.GetTypeName() { + case gtsmodel.ActivityStreamsFollow: + // FOLLOW + // ID might already be set on a follow we've created, so check it here and return it if it is + follow, ok := t.(vocab.ActivityStreamsFollow) + if !ok { + return nil, errors.New("newid: follow couldn't be parsed into vocab.ActivityStreamsFollow") + } + idProp := follow.GetJSONLDId() + if idProp != nil { + if idProp.IsIRI() { + return idProp.GetIRI(), nil + } + } + // it's not set so create one based on the actor set on the follow (ie., the followER not the followEE) + actorProp := follow.GetActivityStreamsActor() + if actorProp != nil { + for iter := actorProp.Begin(); iter != actorProp.End(); iter = iter.Next() { + // take the IRI of the first actor we can find (there should only be one) + if iter.IsIRI() { + actorAccount := >smodel.Account{} + if err := f.db.GetWhere("uri", 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)) + } + } + } + } + } + + // fallback default behavior: just return a random UUID after our protocol and host return url.Parse(fmt.Sprintf("%s://%s/%s", f.config.Protocol, f.config.Host, uuid.NewString())) } @@ -821,3 +763,139 @@ func (f *federatingDB) Liked(c context.Context, actorIRI *url.URL) (liked vocab. l.Debugf("entering LIKED function with actorIRI %s", actorIRI.String()) return nil, nil } + +/* + CUSTOM FUNCTIONALITY FOR GTS +*/ + +func (f *federatingDB) Undo(ctx context.Context, undo vocab.ActivityStreamsUndo) error { + l := f.log.WithFields( + logrus.Fields{ + "func": "Undo", + "asType": undo.GetTypeName(), + }, + ) + m, err := streams.Serialize(undo) + if err != nil { + return err + } + b, err := json.Marshal(m) + if err != nil { + return err + } + l.Debugf("received UNDO asType %s", string(b)) + + targetAcctI := ctx.Value(util.APAccount) + if targetAcctI == nil { + l.Error("UNDO: target account wasn't set on context") + return nil + } + targetAcct, ok := targetAcctI.(*gtsmodel.Account) + if !ok { + l.Error("UNDO: target account was set on context but couldn't be parsed") + return nil + } + + undoObject := undo.GetActivityStreamsObject() + if undoObject == nil { + return errors.New("UNDO: no object set on vocab.ActivityStreamsUndo") + } + + for iter := undoObject.Begin(); iter != undoObject.End(); iter = iter.Next() { + switch iter.GetType().GetTypeName() { + case string(gtsmodel.ActivityStreamsFollow): + // UNDO FOLLOW + ASFollow, ok := iter.GetType().(vocab.ActivityStreamsFollow) + if !ok { + return errors.New("UNDO: couldn't parse follow into vocab.ActivityStreamsFollow") + } + // make sure the actor owns the follow + if !sameActor(undo.GetActivityStreamsActor(), ASFollow.GetActivityStreamsActor()) { + return errors.New("UNDO: follow actor and activity actor not the same") + } + // convert the follow to something we can understand + gtsFollow, err := f.typeConverter.ASFollowToFollow(ASFollow) + if err != nil { + return fmt.Errorf("UNDO: error converting asfollow to gtsfollow: %s", err) + } + // make sure the addressee of the original follow is the same as whatever inbox this landed in + if gtsFollow.TargetAccountID != targetAcct.ID { + return errors.New("UNDO: follow object account and inbox account were not the same") + } + // delete any existing FOLLOW + if err := f.db.DeleteWhere("uri", gtsFollow.URI, >smodel.Follow{}); err != nil { + return fmt.Errorf("UNDO: db error removing follow: %s", err) + } + // delete any existing FOLLOW REQUEST + if err := f.db.DeleteWhere("uri", gtsFollow.URI, >smodel.FollowRequest{}); err != nil { + return fmt.Errorf("UNDO: db error removing follow request: %s", err) + } + l.Debug("follow undone") + return nil + case string(gtsmodel.ActivityStreamsLike): + // UNDO LIKE + case string(gtsmodel.ActivityStreamsAnnounce): + // UNDO BOOST/REBLOG/ANNOUNCE + } + } + + return nil +} + +func (f *federatingDB) Accept(ctx context.Context, accept vocab.ActivityStreamsAccept) error { + l := f.log.WithFields( + logrus.Fields{ + "func": "Accept", + "asType": accept.GetTypeName(), + }, + ) + m, err := streams.Serialize(accept) + if err != nil { + return err + } + b, err := json.Marshal(m) + if err != nil { + return err + } + l.Debugf("received ACCEPT asType %s", string(b)) + + inboxAcctI := ctx.Value(util.APAccount) + if inboxAcctI == nil { + l.Error("ACCEPT: inbox account wasn't set on context") + return nil + } + inboxAcct, ok := inboxAcctI.(*gtsmodel.Account) + if !ok { + l.Error("ACCEPT: inbox account was set on context but couldn't be parsed") + return nil + } + + acceptObject := accept.GetActivityStreamsObject() + if acceptObject == nil { + return errors.New("ACCEPT: no object set on vocab.ActivityStreamsUndo") + } + + for iter := acceptObject.Begin(); iter != acceptObject.End(); iter = iter.Next() { + switch iter.GetType().GetTypeName() { + case string(gtsmodel.ActivityStreamsFollow): + // ACCEPT FOLLOW + asFollow, ok := iter.GetType().(vocab.ActivityStreamsFollow) + if !ok { + return errors.New("ACCEPT: couldn't parse follow into vocab.ActivityStreamsFollow") + } + // convert the follow to something we can understand + gtsFollow, err := f.typeConverter.ASFollowToFollow(asFollow) + if err != nil { + return fmt.Errorf("ACCEPT: error converting asfollow to gtsfollow: %s", err) + } + // make sure the addressee of the original follow is the same as whatever inbox this landed in + if gtsFollow.AccountID != inboxAcct.ID { + return errors.New("ACCEPT: follow object account and inbox account were not the same") + } + _, err = f.db.AcceptFollowRequest(gtsFollow.AccountID, gtsFollow.TargetAccountID) + return err + } + } + + return nil +} diff --git a/internal/federation/federatingprotocol.go b/internal/federation/federatingprotocol.go index ca475f9f9..b4db31a7e 100644 --- a/internal/federation/federatingprotocol.go +++ b/internal/federation/federatingprotocol.go @@ -266,11 +266,15 @@ func (f *federator) FederatingCallbacks(ctx context.Context) (wrapped pub.Federa OnFollow: onFollow, } - // override default undo behavior other = []interface{}{ + // override default undo behavior func(ctx context.Context, undo vocab.ActivityStreamsUndo) error { return f.FederatingDB().Undo(ctx, undo) }, + // override default accept behavior + func(ctx context.Context, accept vocab.ActivityStreamsAccept) error { + return f.FederatingDB().Accept(ctx, accept) + }, } return diff --git a/internal/gtsmodel/messages.go b/internal/gtsmodel/messages.go index 4dd2a0594..910c74898 100644 --- a/internal/gtsmodel/messages.go +++ b/internal/gtsmodel/messages.go @@ -12,6 +12,8 @@ type FromClientAPI struct { APObjectType string APActivityType string GTSModel interface{} + OriginAccount *Account + TargetAccount *Account } // // ToFederator wraps a message that travels from the processor into the federator diff --git a/internal/message/accountprocess.go b/internal/message/accountprocess.go index 9581c707c..38abff9d8 100644 --- a/internal/message/accountprocess.go +++ b/internal/message/accountprocess.go @@ -326,3 +326,87 @@ func (p *processor) AccountRelationshipGet(authed *oauth.Auth, targetAccountID s return r, nil } + +func (p *processor) AccountFollowCreate(authed *oauth.Auth, form *apimodel.AccountFollowRequest) (*apimodel.Relationship, ErrorWithCode) { + // if there's a block between the accounts we shouldn't create the request ofc + blocked, err := p.db.Blocked(authed.Account.ID, form.TargetAccountID) + if err != nil { + return nil, NewErrorInternalError(err) + } + if blocked { + return nil, NewErrorNotFound(fmt.Errorf("accountfollowcreate: block exists between accounts")) + } + + // make sure the target account actually exists in our db + targetAcct := >smodel.Account{} + if err := p.db.GetByID(form.TargetAccountID, targetAcct); err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + return nil, NewErrorNotFound(fmt.Errorf("accountfollowcreate: account %s not found in the db: %s", form.TargetAccountID, err)) + } + } + + // check if a follow exists already + follows, err := p.db.Follows(authed.Account, targetAcct) + if err != nil { + return nil, NewErrorInternalError(fmt.Errorf("accountfollowcreate: error checking follow in db: %s", err)) + } + if follows { + // already follows so just return the relationship + return p.AccountRelationshipGet(authed, form.TargetAccountID) + } + + // check if a follow exists already + followRequested, err := p.db.FollowRequested(authed.Account, targetAcct) + if err != nil { + return nil, NewErrorInternalError(fmt.Errorf("accountfollowcreate: error checking follow request in db: %s", err)) + } + if followRequested { + // already follow requested so just return the relationship + return p.AccountRelationshipGet(authed, form.TargetAccountID) + } + + // make the follow request + fr := >smodel.FollowRequest{ + AccountID: authed.Account.ID, + TargetAccountID: form.TargetAccountID, + ShowReblogs: true, + URI: util.GenerateURIForFollow(authed.Account.Username, p.config.Protocol, p.config.Host), + Notify: false, + } + if form.Reblogs != nil { + fr.ShowReblogs = *form.Reblogs + } + if form.Notify != nil { + fr.Notify = *form.Notify + } + + // whack it in the database + if err := p.db.Put(fr); err != nil { + return nil, NewErrorInternalError(fmt.Errorf("accountfollowcreate: error creating follow request in db: %s", err)) + } + + // if it's a local account that's not locked we can just straight up accept the follow request + if !targetAcct.Locked && targetAcct.Domain == "" { + if _, err := p.db.AcceptFollowRequest(authed.Account.ID, form.TargetAccountID); err != nil { + return nil, NewErrorInternalError(fmt.Errorf("accountfollowcreate: error accepting folow request for local unlocked account: %s", err)) + } + // return the new relationship + return p.AccountRelationshipGet(authed, form.TargetAccountID) + } + + // otherwise we leave the follow request as it is and we handle the rest of the process asynchronously + p.fromClientAPI <- gtsmodel.FromClientAPI{ + APObjectType: gtsmodel.ActivityStreamsFollow, + APActivityType: gtsmodel.ActivityStreamsCreate, + GTSModel: >smodel.Follow{ + AccountID: authed.Account.ID, + TargetAccountID: form.TargetAccountID, + URI: fr.URI, + }, + OriginAccount: authed.Account, + TargetAccount: targetAcct, + } + + // return whatever relationship results from this + return p.AccountRelationshipGet(authed, form.TargetAccountID) +} diff --git a/internal/message/fromclientapiprocess.go b/internal/message/fromclientapiprocess.go index f78b5ec8b..3602fbcc0 100644 --- a/internal/message/fromclientapiprocess.go +++ b/internal/message/fromclientapiprocess.go @@ -49,6 +49,18 @@ func (p *processor) processFromClientAPI(clientMsg gtsmodel.FromClientAPI) error return p.federateStatus(status) } return nil + case gtsmodel.ActivityStreamsFollow: + // CREATE FOLLOW (request) + follow, ok := clientMsg.GTSModel.(*gtsmodel.Follow) + if !ok { + return errors.New("follow was not parseable as *gtsmodel.Follow") + } + + if err := p.notifyFollow(follow); err != nil { + return err + } + + return p.federateFollow(follow, clientMsg.OriginAccount, clientMsg.TargetAccount) } case gtsmodel.ActivityStreamsUpdate: // UPDATE @@ -90,8 +102,26 @@ func (p *processor) federateStatus(status *gtsmodel.Status) error { return nil } +func (p *processor) federateFollow(follow *gtsmodel.Follow, originAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) error { + asFollow, err := p.tc.FollowToAS(follow, originAccount, targetAccount) + if err != nil { + return fmt.Errorf("federateFollow: error converting follow to as format: %s", err) + } + + outboxIRI, err := url.Parse(originAccount.OutboxURI) + if err != nil { + return fmt.Errorf("federateFollow: error parsing outboxURI %s: %s", originAccount.OutboxURI, err) + } + + _, err = p.federator.FederatingActor().Send(context.Background(), outboxIRI, asFollow) + return err +} + func (p *processor) federateAcceptFollowRequest(follow *gtsmodel.Follow) error { + // TODO: tidy up this whole function -- move most of the logic for the conversion to the type converter because this is just a mess! Shame on me! + + followAccepter := >smodel.Account{} if err := p.db.GetByID(follow.TargetAccountID, followAccepter); err != nil { return fmt.Errorf("error federating follow accept: %s", err) diff --git a/internal/message/fromcommonprocess.go b/internal/message/fromcommonprocess.go index 14f145df9..d557b7962 100644 --- a/internal/message/fromcommonprocess.go +++ b/internal/message/fromcommonprocess.go @@ -23,3 +23,7 @@ import "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" func (p *processor) notifyStatus(status *gtsmodel.Status) error { return nil } + +func (p *processor) notifyFollow(follow *gtsmodel.Follow) error { + return nil +} diff --git a/internal/message/processor.go b/internal/message/processor.go index a49707694..3d1995a26 100644 --- a/internal/message/processor.go +++ b/internal/message/processor.go @@ -71,10 +71,12 @@ type Processor interface { // AccountStatusesGet fetches a number of statuses (in time descending order) from the given account, filtered by visibility for // the account given in authed. AccountStatusesGet(authed *oauth.Auth, targetAccountID string, limit int, excludeReplies bool, maxID string, pinned bool, mediaOnly bool) ([]apimodel.Status, ErrorWithCode) - // AccountFollowersGet + // AccountFollowersGet fetches a list of the target account's followers. AccountFollowersGet(authed *oauth.Auth, targetAccountID string) ([]apimodel.Account, ErrorWithCode) - // AccountRelationshipGet + // AccountRelationshipGet returns a relationship model describing the relationship of the targetAccount to the Authed account. AccountRelationshipGet(authed *oauth.Auth, targetAccountID string) (*apimodel.Relationship, ErrorWithCode) + // AccountFollowCreate handles a follow request to an account, either remote or local. + AccountFollowCreate(authed *oauth.Auth, form *apimodel.AccountFollowRequest) (*apimodel.Relationship, ErrorWithCode) // AdminEmojiCreate handles the creation of a new instance emoji by an admin, using the given form. AdminEmojiCreate(authed *oauth.Auth, form *apimodel.EmojiCreateRequest) (*apimodel.Emoji, error) diff --git a/internal/typeutils/converter.go b/internal/typeutils/converter.go index 8ff2b1878..58814c55d 100644 --- a/internal/typeutils/converter.go +++ b/internal/typeutils/converter.go @@ -99,7 +99,7 @@ type TypeConverter interface { ASFollowToFollowRequest(followable Followable) (*gtsmodel.FollowRequest, error) // ASFollowToFollowRequest converts a remote activitystreams `follow` representation into gts model follow. ASFollowToFollow(followable Followable) (*gtsmodel.Follow, error) - + /* INTERNAL (gts) MODEL TO ACTIVITYSTREAMS MODEL */ @@ -109,6 +109,9 @@ type TypeConverter interface { // StatusToAS converts a gts model status into an activity streams note, suitable for federation StatusToAS(s *gtsmodel.Status) (vocab.ActivityStreamsNote, error) + + // FollowToASFollow converts a gts model Follow into an activity streams Follow, suitable for federation + FollowToAS(f *gtsmodel.Follow, originAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) (vocab.ActivityStreamsFollow, error) } type converter struct { diff --git a/internal/typeutils/internaltoas.go b/internal/typeutils/internaltoas.go index 0216dea5e..b3bf22fe8 100644 --- a/internal/typeutils/internaltoas.go +++ b/internal/typeutils/internaltoas.go @@ -21,6 +21,7 @@ package typeutils import ( "crypto/x509" "encoding/pem" + "fmt" "net/url" "github.com/go-fed/activity/streams" @@ -258,3 +259,49 @@ func (c *converter) AccountToAS(a *gtsmodel.Account) (vocab.ActivityStreamsPerso func (c *converter) StatusToAS(s *gtsmodel.Status) (vocab.ActivityStreamsNote, error) { return nil, nil } + +func (c *converter) FollowToAS(f *gtsmodel.Follow, originAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) (vocab.ActivityStreamsFollow, error) { + // parse out the various URIs we need for this + // origin account (who's doing the follow) + originAccountURI, err := url.Parse(originAccount.URI) + if err != nil { + return nil, fmt.Errorf("followtoasfollow: error parsing origin account uri: %s", err) + } + originActor := streams.NewActivityStreamsActorProperty() + originActor.AppendIRI(originAccountURI) + + // target account (who's being followed) + targetAccountURI, err := url.Parse(targetAccount.URI) + if err != nil { + return nil, fmt.Errorf("followtoasfollow: error parsing target account uri: %s", err) + } + + // uri of the folow activity itself + followURI, err := url.Parse(f.URI) + if err != nil { + return nil, fmt.Errorf("followtoasfollow: error parsing follow uri: %s", err) + } + + // start preparing the follow activity + follow := streams.NewActivityStreamsFollow() + + // set the actor + follow.SetActivityStreamsActor(originActor) + + // set the id + followIDProp := streams.NewJSONLDIdProperty() + followIDProp.SetIRI(followURI) + follow.SetJSONLDId(followIDProp) + + // set the object + followObjectProp := streams.NewActivityStreamsObjectProperty() + followObjectProp.AppendIRI(targetAccountURI) + follow.SetActivityStreamsObject(followObjectProp) + + // set the To property + followToProp := streams.NewActivityStreamsToProperty() + followToProp.AppendIRI(targetAccountURI) + follow.SetActivityStreamsTo(followToProp) + + return follow, nil +} diff --git a/internal/util/uri.go b/internal/util/uri.go index edcfc5c02..cee9dcbaa 100644 --- a/internal/util/uri.go +++ b/internal/util/uri.go @@ -22,6 +22,8 @@ import ( "fmt" "net/url" "strings" + + "github.com/google/uuid" ) const ( @@ -47,6 +49,8 @@ const ( FeaturedPath = "featured" // PublicKeyPath is for serving an account's public key PublicKeyPath = "main-key" + // FollowPath used to generate the URI for an individual follow or follow request + FollowPath = "follow" ) // APContextKey is a type used specifically for settings values on contexts within go-fed AP request chains @@ -103,6 +107,12 @@ type UserURIs struct { PublicKeyURI string } +// 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()) +} + // GenerateURIsForAccount throws together a bunch of URIs for the given username, with the given protocol and host. func GenerateURIsForAccount(username string, protocol string, host string) *UserURIs { // The below URLs are used for serving web requests