diff --git a/PROGRESS.md b/PROGRESS.md index f00d9b2..653f2df 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -17,12 +17,12 @@ * [x] /api/v1/accounts/:id GET (Get account information) * [x] /api/v1/accounts/:id/statuses GET (Get an account's statuses) * [x] /api/v1/accounts/:id/followers GET (Get an account's followers) - * [ ] /api/v1/accounts/:id/following GET (Get an account's following) + * [x] /api/v1/accounts/:id/following GET (Get an account's following) * [ ] /api/v1/accounts/:id/featured_tags GET (Get an account's featured tags) * [ ] /api/v1/accounts/:id/lists GET (Get lists containing this account) * [ ] /api/v1/accounts/:id/identity_proofs GET (Get identity proofs for this account) - * [ ] /api/v1/accounts/:id/follow POST (Follow this account) - * [ ] /api/v1/accounts/:id/unfollow POST (Unfollow this account) + * [x] /api/v1/accounts/:id/follow POST (Follow this account) + * [x] /api/v1/accounts/:id/unfollow POST (Unfollow this account) * [ ] /api/v1/accounts/:id/block POST (Block this account) * [ ] /api/v1/accounts/:id/unblock POST (Unblock this account) * [ ] /api/v1/accounts/:id/mute POST (Mute this account) @@ -30,7 +30,7 @@ * [ ] /api/v1/accounts/:id/pin POST (Feature this account on profile) * [ ] /api/v1/accounts/:id/unpin POST (Remove this account from profile) * [ ] /api/v1/accounts/:id/note POST (Make a personal note about this account) - * [ ] /api/v1/accounts/relationships GET (Check relationships with accounts) + * [x] /api/v1/accounts/relationships GET (Check relationships with accounts) * [ ] /api/v1/accounts/search GET (Search for an account) * [ ] Bookmarks * [ ] /api/v1/bookmarks GET (See bookmarked statuses) @@ -177,6 +177,7 @@ * [ ] 'Greedy' federation * [ ] No federation (insulate this instance from the Fediverse) * [ ] Allowlist + * [x] Secure HTTP signatures (creation and validation) * [ ] Storage * [x] Internal/statuses/preferences etc * [x] Postgres interface diff --git a/internal/api/client/account/account.go b/internal/api/client/account/account.go index 1e4b716..94f7538 100644 --- a/internal/api/client/account/account.go +++ b/internal/api/client/account/account.go @@ -57,6 +57,14 @@ const ( GetStatusesPath = BasePathWithID + "/statuses" // GetFollowersPath is for showing an account's followers GetFollowersPath = BasePathWithID + "/followers" + // GetFollowingPath is for showing account's that an account follows. + GetFollowingPath = BasePathWithID + "/following" + // 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" + // PostUnfollowPath is for POSTing an unfollow + PostUnfollowPath = BasePathWithID + "/unfollow" ) // Module implements the ClientAPIModule interface for account-related actions @@ -82,6 +90,10 @@ func (m *Module) Route(r router.Router) error { r.AttachHandler(http.MethodPatch, BasePathWithID, m.muxHandler) r.AttachHandler(http.MethodGet, GetStatusesPath, m.AccountStatusesGETHandler) r.AttachHandler(http.MethodGet, GetFollowersPath, m.AccountFollowersGETHandler) + r.AttachHandler(http.MethodGet, GetFollowingPath, m.AccountFollowingGETHandler) + r.AttachHandler(http.MethodGet, GetRelationshipsPath, m.AccountRelationshipsGETHandler) + r.AttachHandler(http.MethodPost, PostFollowPath, m.AccountFollowPOSTHandler) + r.AttachHandler(http.MethodPost, PostUnfollowPath, m.AccountUnfollowPOSTHandler) return nil } diff --git a/internal/api/client/account/follow.go b/internal/api/client/account/follow.go new file mode 100644 index 0000000..bee41c2 --- /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/following.go b/internal/api/client/account/following.go new file mode 100644 index 0000000..2a1373e --- /dev/null +++ b/internal/api/client/account/following.go @@ -0,0 +1,49 @@ +/* + 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/oauth" +) + +// AccountFollowersGETHandler serves the followers of the requested account, if they're visible to the requester. +func (m *Module) AccountFollowingGETHandler(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 + } + + following, errWithCode := m.processor.AccountFollowingGet(authed, targetAcctID) + if errWithCode != nil { + c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + return + } + + c.JSON(http.StatusOK, following) +} diff --git a/internal/api/client/account/relationships.go b/internal/api/client/account/relationships.go new file mode 100644 index 0000000..fd96867 --- /dev/null +++ b/internal/api/client/account/relationships.go @@ -0,0 +1,46 @@ +package account + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// AccountRelationshipsGETHandler serves the relationship of the requesting account with one or more requested account IDs. +func (m *Module) AccountRelationshipsGETHandler(c *gin.Context) { + l := m.log.WithField("func", "AccountRelationshipsGETHandler") + + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + l.Debugf("error authing: %s", err) + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + + targetAccountIDs := c.QueryArray("id[]") + if len(targetAccountIDs) == 0 { + // 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{} + + for _, targetAccountID := range targetAccountIDs { + r, errWithCode := m.processor.AccountRelationshipGet(authed, targetAccountID) + if err != nil { + c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + return + } + relationships = append(relationships, *r) + } + + c.JSON(http.StatusOK, relationships) +} diff --git a/internal/api/client/account/unfollow.go b/internal/api/client/account/unfollow.go new file mode 100644 index 0000000..69ed72b --- /dev/null +++ b/internal/api/client/account/unfollow.go @@ -0,0 +1,53 @@ +/* + 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/oauth" +) + +// AccountUnfollowPOSTHandler is the endpoint for removing a follow and/or follow request to the target account +func (m *Module) AccountUnfollowPOSTHandler(c *gin.Context) { + l := m.log.WithField("func", "AccountUnfollowPOSTHandler") + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + l.Debug(err) + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + + targetAcctID := c.Param(IDKey) + if targetAcctID == "" { + l.Debug(err) + c.JSON(http.StatusBadRequest, gin.H{"error": "no account id specified"}) + return + } + + relationship, errWithCode := m.processor.AccountFollowRemove(authed, targetAcctID) + if errWithCode != nil { + l.Debug(errWithCode.Error()) + c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + return + } + + c.JSON(http.StatusOK, relationship) +} diff --git a/internal/api/client/auth/authorize.go b/internal/api/client/auth/authorize.go index d5f8ee2..f473579 100644 --- a/internal/api/client/auth/authorize.go +++ b/internal/api/client/auth/authorize.go @@ -28,6 +28,7 @@ import ( "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" ) @@ -60,7 +61,7 @@ func (m *Module) AuthorizeGETHandler(c *gin.Context) { app := >smodel.Application{ ClientID: clientID, } - if err := m.db.GetWhere("client_id", app.ClientID, app); err != nil { + if err := m.db.GetWhere([]db.Where{{Key: "client_id", Value: app.ClientID}}, app); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("no application found for client id %s", clientID)}) return } diff --git a/internal/api/client/auth/middleware.go b/internal/api/client/auth/middleware.go index 2a63cbd..dba8e5a 100644 --- a/internal/api/client/auth/middleware.go +++ b/internal/api/client/auth/middleware.go @@ -20,6 +20,7 @@ package auth import ( "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -68,7 +69,7 @@ func (m *Module) OauthTokenMiddleware(c *gin.Context) { if cid := ti.GetClientID(); cid != "" { l.Tracef("authenticated client %s with bearer token, scope is %s", cid, ti.GetScope()) app := >smodel.Application{} - if err := m.db.GetWhere("client_id", cid, app); err != nil { + if err := m.db.GetWhere([]db.Where{{Key: "client_id",Value: cid}}, app); err != nil { l.Tracef("no app found for client %s", cid) } c.Set(oauth.SessionAuthorizedApplication, app) diff --git a/internal/api/client/auth/signin.go b/internal/api/client/auth/signin.go index 79d9b30..e9385e3 100644 --- a/internal/api/client/auth/signin.go +++ b/internal/api/client/auth/signin.go @@ -24,6 +24,7 @@ import ( "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "golang.org/x/crypto/bcrypt" ) @@ -87,7 +88,7 @@ func (m *Module) ValidatePassword(email string, password string) (userid string, // first we select the user from the database based on email address, bail if no user found for that email gtsUser := >smodel.User{} - if err := m.db.GetWhere("email", email, gtsUser); err != nil { + if err := m.db.GetWhere([]db.Where{{Key: "email", Value: email}}, gtsUser); err != nil { l.Debugf("user %s was not retrievable from db during oauth authorization attempt: %s", email, err) return incorrectPassword() } diff --git a/internal/api/client/followrequest/accept.go b/internal/api/client/followrequest/accept.go index 45dc1a2..bb2910c 100644 --- a/internal/api/client/followrequest/accept.go +++ b/internal/api/client/followrequest/accept.go @@ -48,10 +48,11 @@ func (m *Module) FollowRequestAcceptPOSTHandler(c *gin.Context) { return } - if errWithCode := m.processor.FollowRequestAccept(authed, originAccountID); errWithCode != nil { + r, errWithCode := m.processor.FollowRequestAccept(authed, originAccountID) + if errWithCode != nil { l.Debug(errWithCode.Error()) c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) return } - c.Status(http.StatusOK) + c.JSON(http.StatusOK, r) } diff --git a/internal/api/client/status/statuscreate_test.go b/internal/api/client/status/statuscreate_test.go index fb9b48f..a78374f 100644 --- a/internal/api/client/status/statuscreate_test.go +++ b/internal/api/client/status/statuscreate_test.go @@ -32,6 +32,7 @@ import ( "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/api/client/status" "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/testrig" @@ -118,7 +119,7 @@ func (suite *StatusCreateTestSuite) TestPostNewStatus() { }, statusReply.Tags[0]) gtsTag := >smodel.Tag{} - err = suite.db.GetWhere("name", "helloworld", gtsTag) + err = suite.db.GetWhere([]db.Where{{Key: "name", Value: "helloworld"}}, gtsTag) assert.NoError(suite.T(), err) assert.Equal(suite.T(), statusReply.Account.ID, gtsTag.FirstSeenFromAccountID) } diff --git a/internal/api/model/account.go b/internal/api/model/account.go index efb69d6..ba5ac56 100644 --- a/internal/api/model/account.go +++ b/internal/api/model/account.go @@ -77,18 +77,18 @@ type Account struct { // See https://docs.joinmastodon.org/methods/accounts/ type AccountCreateRequest struct { // Text that will be reviewed by moderators if registrations require manual approval. - Reason string `form:"reason"` + Reason string `form:"reason" json:"reason" xml:"reason"` // The desired username for the account - Username string `form:"username" binding:"required"` + Username string `form:"username" json:"username" xml:"username" binding:"required"` // The email address to be used for login - Email string `form:"email" binding:"required"` + Email string `form:"email" json:"email" xml:"email" binding:"required"` // The password to be used for login - Password string `form:"password" binding:"required"` + Password string `form:"password" json:"password" xml:"password" binding:"required"` // Whether the user agrees to the local rules, terms, and policies. // These should be presented to the user in order to allow them to consent before setting this parameter to TRUE. - Agreement bool `form:"agreement" binding:"required"` + Agreement bool `form:"agreement" json:"agreement" xml:"agreement" binding:"required"` // The language of the confirmation email that will be sent - Locale string `form:"locale" binding:"required"` + Locale string `form:"locale" json:"locale" xml:"locale" binding:"required"` // The IP of the sign up request, will not be parsed from the form but must be added manually IP net.IP `form:"-"` } @@ -97,40 +97,51 @@ type AccountCreateRequest struct { // See https://docs.joinmastodon.org/methods/accounts/ type UpdateCredentialsRequest struct { // Whether the account should be shown in the profile directory. - Discoverable *bool `form:"discoverable"` + Discoverable *bool `form:"discoverable" json:"discoverable" xml:"discoverable"` // Whether the account has a bot flag. - Bot *bool `form:"bot"` + Bot *bool `form:"bot" json:"bot" xml:"bot"` // The display name to use for the profile. - DisplayName *string `form:"display_name"` + DisplayName *string `form:"display_name" json:"display_name" xml:"display_name"` // The account bio. - Note *string `form:"note"` + Note *string `form:"note" json:"note" xml:"note"` // Avatar image encoded using multipart/form-data - Avatar *multipart.FileHeader `form:"avatar"` + Avatar *multipart.FileHeader `form:"avatar" json:"avatar" xml:"avatar"` // Header image encoded using multipart/form-data - Header *multipart.FileHeader `form:"header"` + Header *multipart.FileHeader `form:"header" json:"header" xml:"header"` // Whether manual approval of follow requests is required. - Locked *bool `form:"locked"` + Locked *bool `form:"locked" json:"locked" xml:"locked"` // New Source values for this account - Source *UpdateSource `form:"source"` + Source *UpdateSource `form:"source" json:"source" xml:"source"` // Profile metadata name and value - FieldsAttributes *[]UpdateField `form:"fields_attributes"` + FieldsAttributes *[]UpdateField `form:"fields_attributes" json:"fields_attributes" xml:"fields_attributes"` } // UpdateSource is to be used specifically in an UpdateCredentialsRequest. type UpdateSource struct { // Default post privacy for authored statuses. - Privacy *string `form:"privacy"` + Privacy *string `form:"privacy" json:"privacy" xml:"privacy"` // Whether to mark authored statuses as sensitive by default. - Sensitive *bool `form:"sensitive"` + Sensitive *bool `form:"sensitive" json:"sensitive" xml:"sensitive"` // Default language to use for authored statuses. (ISO 6391) - Language *string `form:"language"` + Language *string `form:"language" json:"language" xml:"language"` } // UpdateField is to be used specifically in an UpdateCredentialsRequest. // 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/api/s2s/user/followers.go b/internal/api/s2s/user/followers.go new file mode 100644 index 0000000..0b63361 --- /dev/null +++ b/internal/api/s2s/user/followers.go @@ -0,0 +1,58 @@ +/* + 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 user + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" +) + +func (m *Module) FollowersGETHandler(c *gin.Context) { + l := m.log.WithFields(logrus.Fields{ + "func": "FollowersGETHandler", + "url": c.Request.RequestURI, + }) + + requestedUsername := c.Param(UsernameKey) + if requestedUsername == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "no username specified in request"}) + return + } + + // make sure this actually an AP request + format := c.NegotiateFormat(ActivityPubAcceptHeaders...) + if format == "" { + c.JSON(http.StatusNotAcceptable, gin.H{"error": "could not negotiate format with given Accept header(s)"}) + return + } + l.Tracef("negotiated format: %s", format) + + // make a copy of the context to pass along so we don't break anything + cp := c.Copy() + user, err := m.processor.GetFediFollowers(requestedUsername, cp.Request) // GetFediUser handles auth as well + if err != nil { + l.Info(err.Error()) + c.JSON(err.Code(), gin.H{"error": err.Safe()}) + return + } + + c.JSON(http.StatusOK, user) +} diff --git a/internal/api/s2s/user/statusget.go b/internal/api/s2s/user/statusget.go new file mode 100644 index 0000000..60efd48 --- /dev/null +++ b/internal/api/s2s/user/statusget.go @@ -0,0 +1,46 @@ +package user + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" +) + +func (m *Module) StatusGETHandler(c *gin.Context) { + l := m.log.WithFields(logrus.Fields{ + "func": "StatusGETHandler", + "url": c.Request.RequestURI, + }) + + requestedUsername := c.Param(UsernameKey) + if requestedUsername == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "no username specified in request"}) + return + } + + requestedStatusID := c.Param(StatusIDKey) + if requestedStatusID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "no status id specified in request"}) + return + } + + // make sure this actually an AP request + format := c.NegotiateFormat(ActivityPubAcceptHeaders...) + if format == "" { + c.JSON(http.StatusNotAcceptable, gin.H{"error": "could not negotiate format with given Accept header(s)"}) + return + } + l.Tracef("negotiated format: %s", format) + + // make a copy of the context to pass along so we don't break anything + cp := c.Copy() + status, err := m.processor.GetFediStatus(requestedUsername, requestedStatusID, cp.Request) // handles auth as well + if err != nil { + l.Info(err.Error()) + c.JSON(err.Code(), gin.H{"error": err.Safe()}) + return + } + + c.JSON(http.StatusOK, status) +} diff --git a/internal/api/s2s/user/user.go b/internal/api/s2s/user/user.go index a611624..d866e47 100644 --- a/internal/api/s2s/user/user.go +++ b/internal/api/s2s/user/user.go @@ -32,6 +32,8 @@ import ( const ( // UsernameKey is for account usernames. UsernameKey = "username" + // StatusIDKey is for status IDs + StatusIDKey = "status" // UsersBasePath is the base path for serving information about Users eg https://example.org/users UsersBasePath = "/" + util.UsersPath // UsersBasePathWithUsername is just the users base path with the Username key in it. @@ -40,6 +42,10 @@ const ( UsersBasePathWithUsername = UsersBasePath + "/:" + UsernameKey // UsersInboxPath is for serving POST requests to a user's inbox with the given username key. UsersInboxPath = UsersBasePathWithUsername + "/" + util.InboxPath + // UsersFollowersPath is for serving GET request's to a user's followers list, with the given username key. + UsersFollowersPath = UsersBasePathWithUsername + "/" + util.FollowersPath + // UsersStatusPath is for serving GET requests to a particular status by a user, with the given username key and status ID + UsersStatusPath = UsersBasePathWithUsername + "/" + util.StatusesPath + "/:" + StatusIDKey ) // ActivityPubAcceptHeaders represents the Accept headers mentioned here: @@ -69,5 +75,7 @@ func New(config *config.Config, processor message.Processor, log *logrus.Logger) func (m *Module) Route(s router.Router) error { s.AttachHandler(http.MethodGet, UsersBasePathWithUsername, m.UsersGETHandler) s.AttachHandler(http.MethodPost, UsersInboxPath, m.InboxPOSTHandler) + s.AttachHandler(http.MethodGet, UsersFollowersPath, m.FollowersGETHandler) + s.AttachHandler(http.MethodGet, UsersStatusPath, m.StatusGETHandler) return nil } diff --git a/internal/api/security/security.go b/internal/api/security/security.go index eaae847..523b5dd 100644 --- a/internal/api/security/security.go +++ b/internal/api/security/security.go @@ -43,5 +43,6 @@ func New(config *config.Config, log *logrus.Logger) api.ClientModule { func (m *Module) Route(s router.Router) error { s.AttachMiddleware(m.FlocBlock) s.AttachMiddleware(m.ExtraHeaders) + s.AttachMiddleware(m.UserAgentBlock) return nil } diff --git a/internal/api/security/useragentblock.go b/internal/api/security/useragentblock.go new file mode 100644 index 0000000..f7d3a4f --- /dev/null +++ b/internal/api/security/useragentblock.go @@ -0,0 +1,43 @@ +/* + 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 security + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +// UserAgentBlock is a middleware that prevents google chrome cohort tracking by +// writing the Permissions-Policy header after all other parts of the request have been completed. +// See: https://plausible.io/blog/google-floc +func (m *Module) UserAgentBlock(c *gin.Context) { + + ua := c.Request.UserAgent() + if ua == "" { + c.AbortWithStatus(http.StatusTeapot) + return + } + + if strings.Contains(strings.ToLower(c.Request.UserAgent()), strings.ToLower("friendica")) { + c.AbortWithStatus(http.StatusTeapot) + return + } +} diff --git a/internal/db/db.go b/internal/db/db.go index cbcd698..5609b92 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -22,7 +22,6 @@ import ( "context" "net" - "github.com/go-fed/activity/pub" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" ) @@ -33,18 +32,28 @@ 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" +} + +type Where struct { + Key string + Value interface{} +} + // 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. type DB interface { // Federation returns an interface that's compatible with go-fed, for performing federation storage/retrieval functions. // See: https://pkg.go.dev/github.com/go-fed/activity@v1.0.0/pub?utm_source=gopls#Database - Federation() pub.Database + // Federation() federatingdb.FederatingDB /* BASIC DB FUNCTIONALITY @@ -75,7 +84,7 @@ type DB interface { // name of the key to select from. // The given interface i will be set to the result of the query, whatever it is. Use a pointer or a slice. // In case of no entries, a 'no entries' error will be returned - GetWhere(key string, value interface{}, i interface{}) error + GetWhere(where []Where, i interface{}) error // // GetWhereMany gets one entry where key = value for *ALL* parameters passed as "where". // // That is, if you pass 2 'where' entries, with 1 being Key username and Value test, and the second @@ -109,7 +118,7 @@ type DB interface { // DeleteWhere deletes i where key = value // If i didn't exist anyway, then no error should be returned. - DeleteWhere(key string, value interface{}, i interface{}) error + DeleteWhere(where []Where, i interface{}) error /* HANDY SHORTCUTS @@ -117,7 +126,9 @@ type DB interface { // AcceptFollowRequest moves a follow request in the database from the follow_requests table to the follows table. // In other words, it should create the follow, and delete the existing follow request. - AcceptFollowRequest(originAccountID string, targetAccountID string) error + // + // It will return the newly created follow for further processing. + AcceptFollowRequest(originAccountID string, targetAccountID string) (*gtsmodel.Follow, error) // CreateInstanceAccount creates an account in the database with the same username as the instance host value. // Ie., if the instance is hosted at 'example.org' the instance user will have a username of 'example.org'. @@ -204,6 +215,9 @@ type DB interface { // That is, it returns true if account1 blocks account2, OR if account2 blocks account1. Blocked(account1 string, account2 string) (bool, error) + // GetRelationship retrieves the relationship of the targetAccount to the requestingAccount. + GetRelationship(requestingAccount string, targetAccount string) (*gtsmodel.Relationship, error) + // StatusVisible returns true if targetStatus is visible to requestingAccount, based on the // privacy settings of the status, and any blocks/mutes that might exist between the two accounts // or account domains. @@ -222,6 +236,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 d3590a0..2a4b040 100644 --- a/internal/db/pg/pg.go +++ b/internal/db/pg/pg.go @@ -30,7 +30,6 @@ import ( "strings" "time" - "github.com/go-fed/activity/pub" "github.com/go-pg/pg/extra/pgdebug" "github.com/go-pg/pg/v10" "github.com/go-pg/pg/v10/orm" @@ -38,7 +37,6 @@ import ( "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/federation" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/util" "golang.org/x/crypto/bcrypt" @@ -46,11 +44,11 @@ import ( // postgresService satisfies the DB interface type postgresService struct { - config *config.Config - conn *pg.DB - log *logrus.Logger - cancel context.CancelFunc - federationDB pub.Database + config *config.Config + conn *pg.DB + log *logrus.Logger + cancel context.CancelFunc + // federationDB pub.Database } // NewPostgresService returns a postgresService derived from the provided config, which implements the go-fed DB interface. @@ -97,9 +95,6 @@ func NewPostgresService(ctx context.Context, c *config.Config, log *logrus.Logge cancel: cancel, } - federatingDB := federation.NewFederatingDB(ps, c, log) - ps.federationDB = federatingDB - // we can confidently return this useable postgres service now return ps, nil } @@ -159,14 +154,6 @@ func derivePGOptions(c *config.Config) (*pg.Options, error) { return options, nil } -/* - FEDERATION FUNCTIONALITY -*/ - -func (ps *postgresService) Federation() pub.Database { - return ps.federationDB -} - /* BASIC DB FUNCTIONALITY */ @@ -229,8 +216,17 @@ func (ps *postgresService) GetByID(id string, i interface{}) error { return nil } -func (ps *postgresService) GetWhere(key string, value interface{}, i interface{}) error { - if err := ps.conn.Model(i).Where("? = ?", pg.Safe(key), value).Select(); err != nil { +func (ps *postgresService) GetWhere(where []db.Where, i interface{}) error { + if len(where) == 0 { + return errors.New("no queries provided") + } + + q := ps.conn.Model(i) + for _, w := range where { + q = q.Where("? = ?", pg.Safe(w.Key), w.Value) + } + + if err := q.Select(); err != nil { if err == pg.ErrNoRows { return db.ErrNoEntries{} } @@ -255,6 +251,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 } @@ -285,20 +284,31 @@ func (ps *postgresService) UpdateOneByID(id string, key string, value interface{ func (ps *postgresService) DeleteByID(id string, i interface{}) error { if _, err := ps.conn.Model(i).Where("id = ?", id).Delete(); err != nil { - if err == pg.ErrNoRows { - return db.ErrNoEntries{} + // if there are no rows *anyway* then that's fine + // just return err if there's an actual error + if err != pg.ErrNoRows { + return err } - return err } return nil } -func (ps *postgresService) DeleteWhere(key string, value interface{}, i interface{}) error { - if _, err := ps.conn.Model(i).Where("? = ?", pg.Safe(key), value).Delete(); err != nil { - if err == pg.ErrNoRows { - return db.ErrNoEntries{} +func (ps *postgresService) DeleteWhere(where []db.Where, i interface{}) error { + if len(where) == 0 { + return errors.New("no queries provided") + } + + q := ps.conn.Model(i) + for _, w := range where { + q = q.Where("? = ?", pg.Safe(w.Key), w.Value) + } + + if _, err := q.Delete(); err != nil { + // if there are no rows *anyway* then that's fine + // just return err if there's an actual error + if err != pg.ErrNoRows { + return err } - return err } return nil } @@ -307,30 +317,34 @@ func (ps *postgresService) DeleteWhere(key string, value interface{}, i interfac HANDY SHORTCUTS */ -func (ps *postgresService) AcceptFollowRequest(originAccountID string, targetAccountID string) error { +func (ps *postgresService) AcceptFollowRequest(originAccountID string, targetAccountID string) (*gtsmodel.Follow, error) { + // make sure the original follow request exists fr := >smodel.FollowRequest{} if err := ps.conn.Model(fr).Where("account_id = ?", originAccountID).Where("target_account_id = ?", targetAccountID).Select(); err != nil { if err == pg.ErrMultiRows { - return db.ErrNoEntries{} + return nil, db.ErrNoEntries{} } - return err + return nil, err } + // create a new follow to 'replace' the request with follow := >smodel.Follow{ AccountID: originAccountID, TargetAccountID: targetAccountID, URI: fr.URI, } - if _, err := ps.conn.Model(follow).Insert(); err != nil { - return err + // if the follow already exists, just update the URI -- we don't need to do anything else + if _, err := ps.conn.Model(follow).OnConflict("ON CONSTRAINT follows_account_id_target_account_id_key DO UPDATE set uri = ?", follow.URI).Insert(); err != nil { + return nil, err } + // now remove the follow request if _, err := ps.conn.Model(>smodel.FollowRequest{}).Where("account_id = ?", originAccountID).Where("target_account_id = ?", targetAccountID).Delete(); err != nil { - return err + return nil, err } - return nil + return follow, nil } func (ps *postgresService) CreateInstanceAccount() error { @@ -681,6 +695,60 @@ func (ps *postgresService) Blocked(account1 string, account2 string) (bool, erro return blocked, nil } +func (ps *postgresService) GetRelationship(requestingAccount string, targetAccount string) (*gtsmodel.Relationship, error) { + r := >smodel.Relationship{ + ID: targetAccount, + } + + // check if the requesting account follows the target account + follow := >smodel.Follow{} + if err := ps.conn.Model(follow).Where("account_id = ?", requestingAccount).Where("target_account_id = ?", targetAccount).Select(); err != nil { + if err != pg.ErrNoRows { + // a proper error + return nil, fmt.Errorf("getrelationship: error checking follow existence: %s", err) + } + // no follow exists so these are all false + r.Following = false + r.ShowingReblogs = false + r.Notifying = false + } else { + // follow exists so we can fill these fields out... + r.Following = true + r.ShowingReblogs = follow.ShowReblogs + r.Notifying = follow.Notify + } + + // check if the target account follows the requesting account + followedBy, err := ps.conn.Model(>smodel.Follow{}).Where("account_id = ?", targetAccount).Where("target_account_id = ?", requestingAccount).Exists() + if err != nil { + return nil, fmt.Errorf("getrelationship: error checking followed_by existence: %s", err) + } + r.FollowedBy = followedBy + + // check if the requesting account blocks the target account + blocking, err := ps.conn.Model(>smodel.Block{}).Where("account_id = ?", requestingAccount).Where("target_account_id = ?", targetAccount).Exists() + if err != nil { + return nil, fmt.Errorf("getrelationship: error checking blocking existence: %s", err) + } + r.Blocking = blocking + + // check if the target account blocks the requesting account + blockedBy, err := ps.conn.Model(>smodel.Block{}).Where("account_id = ?", targetAccount).Where("target_account_id = ?", requestingAccount).Exists() + if err != nil { + return nil, fmt.Errorf("getrelationship: error checking blocked existence: %s", err) + } + r.BlockedBy = blockedBy + + // check if there's a pending following request from requesting account to target account + requested, err := ps.conn.Model(>smodel.FollowRequest{}).Where("account_id = ?", requestingAccount).Where("target_account_id = ?", targetAccount).Exists() + if err != nil { + return nil, fmt.Errorf("getrelationship: error checking blocked existence: %s", err) + } + r.Requested = requested + + return r, nil +} + func (ps *postgresService) StatusVisible(targetStatus *gtsmodel.Status, targetAccount *gtsmodel.Account, requestingAccount *gtsmodel.Account, relevantAccounts *gtsmodel.RelevantAccounts) (bool, error) { l := ps.log.WithField("func", "StatusVisible") @@ -853,6 +921,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() @@ -1036,6 +1108,11 @@ func (ps *postgresService) WhoFavedStatus(status *gtsmodel.Status) ([]*gtsmodel. */ func (ps *postgresService) MentionStringsToMentions(targetAccounts []string, originAccountID string, statusID string) ([]*gtsmodel.Mention, error) { + ogAccount := >smodel.Account{} + if err := ps.conn.Model(ogAccount).Where("id = ?", originAccountID).Select(); err != nil { + return nil, err + } + menchies := []*gtsmodel.Mention{} for _, a := range targetAccounts { // A mentioned account looks like "@test@example.org" or just "@test" for a local account @@ -1093,9 +1170,13 @@ func (ps *postgresService) MentionStringsToMentions(targetAccounts []string, ori // id, createdAt and updatedAt will be populated by the db, so we have everything we need! menchies = append(menchies, >smodel.Mention{ - StatusID: statusID, - OriginAccountID: originAccountID, - TargetAccountID: mentionedAccount.ID, + StatusID: statusID, + OriginAccountID: ogAccount.ID, + OriginAccountURI: ogAccount.URI, + TargetAccountID: mentionedAccount.ID, + NameString: a, + MentionedAccountURI: mentionedAccount.URI, + GTSAccount: mentionedAccount, }) } return menchies, nil diff --git a/internal/federation/federating_db.go b/internal/federation/federating_db.go index f72c5e6..8f203e1 100644 --- a/internal/federation/federating_db.go +++ b/internal/federation/federating_db.go @@ -20,6 +20,7 @@ package federation import ( "context" + "encoding/json" "errors" "fmt" "net/url" @@ -37,6 +38,12 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/util" ) +type FederatingDB interface { + pub.Database + 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. // It doesn't care what the underlying implementation of the DB interface is, as long as it works. type federatingDB struct { @@ -47,8 +54,8 @@ type federatingDB struct { typeConverter typeutils.TypeConverter } -// NewFederatingDB returns a pub.Database interface using the given database, config, and logger. -func NewFederatingDB(db db.DB, config *config.Config, log *logrus.Logger) pub.Database { +// NewFederatingDB returns a FederatingDB interface using the given database, config, and logger. +func NewFederatingDB(db db.DB, config *config.Config, log *logrus.Logger) FederatingDB { return &federatingDB{ locks: new(sync.Map), db: db, @@ -204,7 +211,7 @@ func (f *federatingDB) Owns(c context.Context, id *url.URL) (bool, error) { if err != nil { return false, fmt.Errorf("error parsing statuses path for url %s: %s", id.String(), err) } - if err := f.db.GetWhere("uri", uid, >smodel.Status{}); err != nil { + if err := f.db.GetWhere([]db.Where{{Key: "uri", Value: uid}}, >smodel.Status{}); err != nil { if _, ok := err.(db.ErrNoEntries); ok { // there are no entries for this status return false, nil @@ -253,7 +260,7 @@ func (f *federatingDB) ActorForOutbox(c context.Context, outboxIRI *url.URL) (ac return nil, fmt.Errorf("%s is not an outbox URI", outboxIRI.String()) } acct := >smodel.Account{} - if err := f.db.GetWhere("outbox_uri", outboxIRI.String(), acct); err != nil { + if err := f.db.GetWhere([]db.Where{{Key: "outbox_uri", Value: outboxIRI.String()}}, acct); err != nil { if _, ok := err.(db.ErrNoEntries); ok { return nil, fmt.Errorf("no actor found that corresponds to outbox %s", outboxIRI.String()) } @@ -278,7 +285,7 @@ func (f *federatingDB) ActorForInbox(c context.Context, inboxIRI *url.URL) (acto return nil, fmt.Errorf("%s is not an inbox URI", inboxIRI.String()) } acct := >smodel.Account{} - if err := f.db.GetWhere("inbox_uri", inboxIRI.String(), acct); err != nil { + if err := f.db.GetWhere([]db.Where{{Key: "inbox_uri", Value: inboxIRI.String()}}, acct); err != nil { if _, ok := err.(db.ErrNoEntries); ok { return nil, fmt.Errorf("no actor found that corresponds to inbox %s", inboxIRI.String()) } @@ -304,7 +311,7 @@ func (f *federatingDB) OutboxForInbox(c context.Context, inboxIRI *url.URL) (out return nil, fmt.Errorf("%s is not an inbox URI", inboxIRI.String()) } acct := >smodel.Account{} - if err := f.db.GetWhere("inbox_uri", inboxIRI.String(), acct); err != nil { + if err := f.db.GetWhere([]db.Where{{Key: "inbox_uri", Value: inboxIRI.String()}}, acct); err != nil { if _, ok := err.(db.ErrNoEntries); ok { return nil, fmt.Errorf("no actor found that corresponds to inbox %s", inboxIRI.String()) } @@ -343,9 +350,10 @@ func (f *federatingDB) Get(c context.Context, id *url.URL) (value vocab.Type, er if util.IsUserPath(id) { acct := >smodel.Account{} - if err := f.db.GetWhere("uri", id.String(), acct); err != nil { + if err := f.db.GetWhere([]db.Where{{Key: "uri", Value: id.String()}}, acct); err != nil { return nil, err } + l.Debug("is user path! returning account") return f.typeConverter.AccountToAS(acct) } @@ -371,27 +379,40 @@ func (f *federatingDB) Create(ctx context.Context, asType vocab.Type) error { "asType": asType.GetTypeName(), }, ) - l.Debugf("received CREATE asType %+v", asType) + m, err := streams.Serialize(asType) + if err != nil { + return err + } + b, err := json.Marshal(m) + if err != nil { + return err + } + + l.Debugf("received CREATE asType %s", string(b)) targetAcctI := ctx.Value(util.APAccount) if targetAcctI == nil { l.Error("target account wasn't set on context") + return nil } targetAcct, ok := targetAcctI.(*gtsmodel.Account) if !ok { l.Error("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 gtsmodel.ActivityStreamsActivity(asType.GetTypeName()) { + switch asType.GetTypeName() { case gtsmodel.ActivityStreamsCreate: create, ok := asType.(vocab.ActivityStreamsCreate) if !ok { @@ -399,7 +420,7 @@ func (f *federatingDB) Create(ctx context.Context, asType vocab.Type) error { } object := create.GetActivityStreamsObject() for objectIter := object.Begin(); objectIter != object.End(); objectIter = objectIter.Next() { - switch gtsmodel.ActivityStreamsObject(objectIter.GetType().GetTypeName()) { + switch objectIter.GetType().GetTypeName() { case gtsmodel.ActivityStreamsNote: note := objectIter.GetActivityStreamsNote() status, err := f.typeConverter.ASStatusToStatus(note) @@ -407,13 +428,17 @@ 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) } fromFederatorChan <- gtsmodel.FromFederator{ - APObjectType: gtsmodel.ActivityStreamsNote, - APActivityType: gtsmodel.ActivityStreamsCreate, - GTSModel: status, + APObjectType: gtsmodel.ActivityStreamsNote, + APActivityType: gtsmodel.ActivityStreamsCreate, + GTSModel: status, + ReceivingAccount: targetAcct, } } } @@ -433,7 +458,7 @@ func (f *federatingDB) Create(ctx context.Context, asType vocab.Type) error { } if !targetAcct.Locked { - if err := f.db.AcceptFollowRequest(followRequest.AccountID, followRequest.TargetAccountID); err != nil { + if _, err := f.db.AcceptFollowRequest(followRequest.AccountID, followRequest.TargetAccountID); err != nil { return fmt.Errorf("database error accepting follow request: %s", err) } } @@ -450,14 +475,87 @@ func (f *federatingDB) Create(ctx context.Context, asType vocab.Type) error { // the entire value. // // The library makes this call only after acquiring a lock first. -func (f *federatingDB) Update(c context.Context, asType vocab.Type) error { +func (f *federatingDB) Update(ctx context.Context, asType vocab.Type) error { l := f.log.WithFields( logrus.Fields{ "func": "Update", "asType": asType.GetTypeName(), }, ) - l.Debugf("received UPDATE asType %+v", asType) + m, err := streams.Serialize(asType) + if err != nil { + return err + } + b, err := json.Marshal(m) + if err != nil { + return err + } + + l.Debugf("received UPDATE asType %s", string(b)) + + receivingAcctI := ctx.Value(util.APAccount) + if receivingAcctI == nil { + l.Error("receiving account wasn't set on context") + } + receivingAcct, ok := receivingAcctI.(*gtsmodel.Account) + if !ok { + l.Error("receiving account was set on context but couldn't be parsed") + } + + fromFederatorChanI := ctx.Value(util.APFromFederatorChanKey) + if fromFederatorChanI == nil { + l.Error("from federator channel wasn't set on context") + } + fromFederatorChan, ok := fromFederatorChanI.(chan gtsmodel.FromFederator) + if !ok { + l.Error("from federator channel was set on context but couldn't be parsed") + } + + switch asType.GetTypeName() { + case gtsmodel.ActivityStreamsUpdate: + update, ok := asType.(vocab.ActivityStreamsCreate) + if !ok { + return errors.New("could not convert type to create") + } + object := update.GetActivityStreamsObject() + for objectIter := object.Begin(); objectIter != object.End(); objectIter = objectIter.Next() { + switch objectIter.GetType().GetTypeName() { + case string(gtsmodel.ActivityStreamsPerson): + person := objectIter.GetActivityStreamsPerson() + updatedAcct, err := f.typeConverter.ASRepresentationToAccount(person) + if err != nil { + return fmt.Errorf("error converting person to account: %s", err) + } + if err := f.db.Put(updatedAcct); err != nil { + return fmt.Errorf("database error inserting updated account: %s", err) + } + + fromFederatorChan <- gtsmodel.FromFederator{ + APObjectType: gtsmodel.ActivityStreamsProfile, + APActivityType: gtsmodel.ActivityStreamsUpdate, + GTSModel: updatedAcct, + ReceivingAccount: receivingAcct, + } + + case string(gtsmodel.ActivityStreamsApplication): + application := objectIter.GetActivityStreamsApplication() + updatedAcct, err := f.typeConverter.ASRepresentationToAccount(application) + if err != nil { + return fmt.Errorf("error converting person to account: %s", err) + } + if err := f.db.Put(updatedAcct); err != nil { + return fmt.Errorf("database error inserting updated account: %s", err) + } + + fromFederatorChan <- gtsmodel.FromFederator{ + APObjectType: gtsmodel.ActivityStreamsProfile, + APActivityType: gtsmodel.ActivityStreamsUpdate, + GTSModel: updatedAcct, + ReceivingAccount: receivingAcct, + } + } + } + } return nil } @@ -490,7 +588,7 @@ func (f *federatingDB) GetOutbox(c context.Context, outboxIRI *url.URL) (inbox v ) l.Debug("entering GETOUTBOX function") - return nil, nil + return streams.NewActivityStreamsOrderedCollectionPage(), nil } // SetOutbox saves the outbox value given from GetOutbox, with new items @@ -522,9 +620,60 @@ func (f *federatingDB) NewID(c context.Context, t vocab.Type) (id *url.URL, err "asType": t.GetTypeName(), }, ) - l.Debugf("received NEWID request for asType %+v", t) + m, err := streams.Serialize(t) + if err != nil { + return nil, err + } + b, err := json.Marshal(m) + if err != nil { + return nil, err + } + l.Debugf("received NEWID request for asType %s", string(b)) - return url.Parse(fmt.Sprintf("%s://%s/", f.config.Protocol, uuid.NewString())) + 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([]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)) + } + } + } + } + case gtsmodel.ActivityStreamsNote: + // NOTE aka STATUS + // 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") + } + idProp := note.GetJSONLDId() + if idProp != nil { + if idProp.IsIRI() { + return idProp.GetIRI(), nil + } + } + } + + // 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())) } // Followers obtains the Followers Collection for an actor with the @@ -543,7 +692,7 @@ func (f *federatingDB) Followers(c context.Context, actorIRI *url.URL) (follower l.Debugf("entering FOLLOWERS function with actorIRI %s", actorIRI.String()) acct := >smodel.Account{} - if err := f.db.GetWhere("uri", actorIRI.String(), acct); err != nil { + if err := f.db.GetWhere([]db.Where{{Key: "uri", Value: actorIRI.String()}}, acct); err != nil { return nil, fmt.Errorf("db error getting account with uri %s: %s", actorIRI.String(), err) } @@ -585,7 +734,7 @@ func (f *federatingDB) Following(c context.Context, actorIRI *url.URL) (followin l.Debugf("entering FOLLOWING function with actorIRI %s", actorIRI.String()) acct := >smodel.Account{} - if err := f.db.GetWhere("uri", actorIRI.String(), acct); err != nil { + if err := f.db.GetWhere([]db.Where{{Key: "uri", Value: actorIRI.String()}}, acct); err != nil { return nil, fmt.Errorf("db error getting account with uri %s: %s", actorIRI.String(), err) } @@ -627,3 +776,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([]db.Where{{Key: "uri", Value: 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([]db.Where{{Key: "uri", Value: 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 d8f6eb8..61fecb1 100644 --- a/internal/federation/federatingprotocol.go +++ b/internal/federation/federatingprotocol.go @@ -124,7 +124,7 @@ func (f *federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWr } requestingAccount := >smodel.Account{} - if err := f.db.GetWhere("uri", publicKeyOwnerURI.String(), requestingAccount); err != nil { + if err := f.db.GetWhere([]db.Where{{Key: "uri", Value: publicKeyOwnerURI.String()}}, requestingAccount); err != nil { // there's been a proper error so return it if _, ok := err.(db.ErrNoEntries); !ok { return ctx, false, fmt.Errorf("error getting requesting account with public key id %s: %s", publicKeyOwnerURI.String(), err) @@ -146,6 +146,22 @@ func (f *federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWr } requestingAccount = a + + // send the newly dereferenced account into the processor channel for further async processing + fromFederatorChanI := ctx.Value(util.APFromFederatorChanKey) + if fromFederatorChanI == nil { + l.Error("from federator channel wasn't set on context") + } + fromFederatorChan, ok := fromFederatorChanI.(chan gtsmodel.FromFederator) + if !ok { + l.Error("from federator channel was set on context but couldn't be parsed") + } + + fromFederatorChan <- gtsmodel.FromFederator{ + APObjectType: gtsmodel.ActivityStreamsProfile, + APActivityType: gtsmodel.ActivityStreamsCreate, + GTSModel: requestingAccount, + } } withRequester := context.WithValue(ctx, util.APRequestingAccount, requestingAccount) @@ -184,7 +200,7 @@ func (f *federator) Blocked(ctx context.Context, actorIRIs []*url.URL) (bool, er for _, uri := range actorIRIs { a := >smodel.Account{} - if err := f.db.GetWhere("uri", uri.String(), a); err != nil { + if err := f.db.GetWhere([]db.Where{{Key: "uri", Value: uri.String()}}, a); err != nil { _, ok := err.(db.ErrNoEntries) if ok { // we don't have an entry for this account so it's not blocked @@ -228,17 +244,19 @@ func (f *federator) FederatingCallbacks(ctx context.Context) (wrapped pub.Federa "func": "FederatingCallbacks", }) - targetAcctI := ctx.Value(util.APAccount) - if targetAcctI == nil { - l.Error("target account wasn't set on context") + receivingAcctI := ctx.Value(util.APAccount) + if receivingAcctI == nil { + l.Error("receiving account wasn't set on context") + return } - targetAcct, ok := targetAcctI.(*gtsmodel.Account) + receivingAcct, ok := receivingAcctI.(*gtsmodel.Account) if !ok { - l.Error("target account was set on context but couldn't be parsed") + l.Error("receiving account was set on context but couldn't be parsed") + return } var onFollow pub.OnFollowBehavior = pub.OnFollowAutomaticallyAccept - if targetAcct.Locked { + if receivingAcct.Locked { onFollow = pub.OnFollowDoNothing } @@ -248,6 +266,17 @@ func (f *federator) FederatingCallbacks(ctx context.Context) (wrapped pub.Federa OnFollow: onFollow, } + 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/federation/federator.go b/internal/federation/federator.go index a3b1386..f09a772 100644 --- a/internal/federation/federator.go +++ b/internal/federation/federator.go @@ -34,6 +34,8 @@ import ( type Federator interface { // FederatingActor returns the underlying pub.FederatingActor, which can be used to send activities, and serve actors at inboxes/outboxes. FederatingActor() pub.FederatingActor + // FederatingDB returns the underlying FederatingDB interface. + FederatingDB() FederatingDB // AuthenticateFederatedRequest can be used to check the authenticity of incoming http-signed requests for federating resources. // The given username will be used to create a transport for making outgoing requests. See the implementation for more detailed comments. AuthenticateFederatedRequest(username string, r *http.Request) (*url.URL, error) @@ -52,6 +54,7 @@ type Federator interface { type federator struct { config *config.Config db db.DB + federatingDB FederatingDB clock pub.Clock typeConverter typeutils.TypeConverter transportController transport.Controller @@ -60,18 +63,19 @@ type federator struct { } // NewFederator returns a new federator -func NewFederator(db db.DB, transportController transport.Controller, config *config.Config, log *logrus.Logger, typeConverter typeutils.TypeConverter) Federator { +func NewFederator(db db.DB, federatingDB FederatingDB, transportController transport.Controller, config *config.Config, log *logrus.Logger, typeConverter typeutils.TypeConverter) Federator { clock := &Clock{} f := &federator{ config: config, db: db, + federatingDB: federatingDB, clock: &Clock{}, typeConverter: typeConverter, transportController: transportController, log: log, } - actor := newFederatingActor(f, f, db.Federation(), clock) + actor := newFederatingActor(f, f, federatingDB, clock) f.actor = actor return f } @@ -79,3 +83,7 @@ func NewFederator(db db.DB, transportController transport.Controller, config *co func (f *federator) FederatingActor() pub.FederatingActor { return f.actor } + +func (f *federator) FederatingDB() FederatingDB { + return f.federatingDB +} diff --git a/internal/federation/federator_test.go b/internal/federation/federator_test.go index 2eab095..e5d42b5 100644 --- a/internal/federation/federator_test.go +++ b/internal/federation/federator_test.go @@ -89,7 +89,7 @@ func (suite *ProtocolTestSuite) TestPostInboxRequestBodyHook() { return nil, nil })) // setup module being tested - federator := federation.NewFederator(suite.db, tc, suite.config, suite.log, suite.typeConverter) + federator := federation.NewFederator(suite.db, testrig.NewTestFederatingDB(suite.db), tc, suite.config, suite.log, suite.typeConverter) // setup request ctx := context.Background() @@ -155,7 +155,7 @@ func (suite *ProtocolTestSuite) TestAuthenticatePostInbox() { })) // now setup module being tested, with the mock transport controller - federator := federation.NewFederator(suite.db, tc, suite.config, suite.log, suite.typeConverter) + federator := federation.NewFederator(suite.db, testrig.NewTestFederatingDB(suite.db), tc, suite.config, suite.log, suite.typeConverter) // setup request ctx := context.Background() diff --git a/internal/federation/util.go b/internal/federation/util.go index 14ceaeb..3f53ed6 100644 --- a/internal/federation/util.go +++ b/internal/federation/util.go @@ -27,11 +27,13 @@ import ( "fmt" "net/http" "net/url" + "strings" "github.com/go-fed/activity/pub" "github.com/go-fed/activity/streams" "github.com/go-fed/activity/streams/vocab" "github.com/go-fed/httpsig" + "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/transport" "github.com/superseriousbusiness/gotosocial/internal/typeutils" @@ -128,57 +130,73 @@ func (f *federator) AuthenticateFederatedRequest(username string, r *http.Reques return nil, fmt.Errorf("could not parse key id into a url: %s", err) } - transport, err := f.GetTransportForUser(username) - if err != nil { - return nil, fmt.Errorf("transport err: %s", err) - } + var publicKey interface{} + var pkOwnerURI *url.URL + if strings.EqualFold(requestingPublicKeyID.Host, f.config.Host) { + // the request is coming from INSIDE THE HOUSE so skip the remote dereferencing + requestingLocalAccount := >smodel.Account{} + if err := f.db.GetWhere([]db.Where{{Key: "public_key_uri", Value: requestingPublicKeyID.String()}}, requestingLocalAccount); err != nil { + return nil, fmt.Errorf("couldn't get local account with public key uri %s from the database: %s", requestingPublicKeyID.String(), err) + } + publicKey = requestingLocalAccount.PublicKey + pkOwnerURI, err = url.Parse(requestingLocalAccount.URI) + if err != nil { + return nil, fmt.Errorf("error parsing url %s: %s", requestingLocalAccount.URI, err) + } + } else { + // the request is remote, so we need to authenticate the request properly by dereferencing the remote key + transport, err := f.GetTransportForUser(username) + if err != nil { + return nil, fmt.Errorf("transport err: %s", err) + } - // The actual http call to the remote server is made right here in the Dereference function. - b, err := transport.Dereference(context.Background(), requestingPublicKeyID) - if err != nil { - return nil, fmt.Errorf("error deferencing key %s: %s", requestingPublicKeyID.String(), err) - } + // The actual http call to the remote server is made right here in the Dereference function. + b, err := transport.Dereference(context.Background(), requestingPublicKeyID) + if err != nil { + return nil, fmt.Errorf("error deferencing key %s: %s", requestingPublicKeyID.String(), err) + } - // if the key isn't in the response, we can't authenticate the request - requestingPublicKey, err := getPublicKeyFromResponse(context.Background(), b, requestingPublicKeyID) - if err != nil { - return nil, fmt.Errorf("error getting key %s from response %s: %s", requestingPublicKeyID.String(), string(b), err) - } + // if the key isn't in the response, we can't authenticate the request + requestingPublicKey, err := getPublicKeyFromResponse(context.Background(), b, requestingPublicKeyID) + if err != nil { + return nil, fmt.Errorf("error getting key %s from response %s: %s", requestingPublicKeyID.String(), string(b), err) + } - // we should be able to get the actual key embedded in the vocab.W3IDSecurityV1PublicKey - pkPemProp := requestingPublicKey.GetW3IDSecurityV1PublicKeyPem() - if pkPemProp == nil || !pkPemProp.IsXMLSchemaString() { - return nil, errors.New("publicKeyPem property is not provided or it is not embedded as a value") - } + // we should be able to get the actual key embedded in the vocab.W3IDSecurityV1PublicKey + pkPemProp := requestingPublicKey.GetW3IDSecurityV1PublicKeyPem() + if pkPemProp == nil || !pkPemProp.IsXMLSchemaString() { + return nil, errors.New("publicKeyPem property is not provided or it is not embedded as a value") + } - // and decode the PEM so that we can parse it as a golang public key - pubKeyPem := pkPemProp.Get() - block, _ := pem.Decode([]byte(pubKeyPem)) - if block == nil || block.Type != "PUBLIC KEY" { - return nil, errors.New("could not decode publicKeyPem to PUBLIC KEY pem block type") - } + // and decode the PEM so that we can parse it as a golang public key + pubKeyPem := pkPemProp.Get() + block, _ := pem.Decode([]byte(pubKeyPem)) + if block == nil || block.Type != "PUBLIC KEY" { + return nil, errors.New("could not decode publicKeyPem to PUBLIC KEY pem block type") + } - p, err := x509.ParsePKIXPublicKey(block.Bytes) - if err != nil { - return nil, fmt.Errorf("could not parse public key from block bytes: %s", err) + publicKey, err = x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("could not parse public key from block bytes: %s", err) + } + + // all good! we just need the URI of the key owner to return + pkOwnerProp := requestingPublicKey.GetW3IDSecurityV1Owner() + if pkOwnerProp == nil || !pkOwnerProp.IsIRI() { + return nil, errors.New("publicKeyOwner property is not provided or it is not embedded as a value") + } + pkOwnerURI = pkOwnerProp.GetIRI() } - if p == nil { + if publicKey == nil { return nil, errors.New("returned public key was empty") } // do the actual authentication here! algo := httpsig.RSA_SHA256 // TODO: make this more robust - if err := verifier.Verify(p, algo); err != nil { + if err := verifier.Verify(publicKey, algo); err != nil { return nil, fmt.Errorf("error verifying key %s: %s", requestingPublicKeyID.String(), err) } - // all good! we just need the URI of the key owner to return - pkOwnerProp := requestingPublicKey.GetW3IDSecurityV1Owner() - if pkOwnerProp == nil || !pkOwnerProp.IsIRI() { - return nil, errors.New("publicKeyOwner property is not provided or it is not embedded as a value") - } - pkOwnerURI := pkOwnerProp.GetIRI() - return pkOwnerURI, nil } @@ -217,6 +235,12 @@ func (f *federator) DereferenceRemoteAccount(username string, remoteAccountID *u return nil, errors.New("error resolving type as activitystreams application") } return p, nil + case string(gtsmodel.ActivityStreamsService): + p, ok := t.(vocab.ActivityStreamsService) + if !ok { + return nil, errors.New("error resolving type as activitystreams service") + } + return p, nil } return nil, fmt.Errorf("type name %s not supported", t.GetTypeName()) @@ -243,3 +267,23 @@ func (f *federator) GetTransportForUser(username string) (transport.Transport, e } return transport, nil } + +func sameActor(activityActor vocab.ActivityStreamsActorProperty, followActor vocab.ActivityStreamsActorProperty) bool { + if activityActor == nil || followActor == nil { + return false + } + for aIter := activityActor.Begin(); aIter != activityActor.End(); aIter = aIter.Next() { + for fIter := followActor.Begin(); fIter != followActor.End(); fIter = fIter.Next() { + if aIter.GetIRI() == nil { + return false + } + if fIter.GetIRI() == nil { + return false + } + if aIter.GetIRI().String() == fIter.GetIRI().String() { + return true + } + } + } + return false +} diff --git a/internal/gotosocial/actions.go b/internal/gotosocial/actions.go index 94b29b8..557a396 100644 --- a/internal/gotosocial/actions.go +++ b/internal/gotosocial/actions.go @@ -83,6 +83,8 @@ var Run action.GTSAction = func(ctx context.Context, c *config.Config, log *logr return fmt.Errorf("error creating dbservice: %s", err) } + federatingDB := federation.NewFederatingDB(dbService, c, log) + router, err := router.New(c, log) if err != nil { return fmt.Errorf("error creating router: %s", err) @@ -100,7 +102,7 @@ var Run action.GTSAction = func(ctx context.Context, c *config.Config, log *logr mediaHandler := media.New(c, dbService, storageBackend, log) oauthServer := oauth.New(dbService, log) transportController := transport.NewController(c, &federation.Clock{}, http.DefaultClient, log) - federator := federation.NewFederator(dbService, transportController, c, log, typeConverter) + federator := federation.NewFederator(dbService, federatingDB, transportController, c, log, typeConverter) processor := message.NewProcessor(c, typeConverter, federator, oauthServer, mediaHandler, storageBackend, dbService, log) if err := processor.Start(); err != nil { return fmt.Errorf("error starting processor: %s", err) diff --git a/internal/gtsmodel/account.go b/internal/gtsmodel/account.go index 56c401e..d6ce95c 100644 --- a/internal/gtsmodel/account.go +++ b/internal/gtsmodel/account.go @@ -76,13 +76,13 @@ type Account struct { */ // Does this account need an approval for new followers? - Locked bool `pg:",default:'true'"` + Locked bool // Should this account be shown in the instance's profile directory? Discoverable bool // Default post privacy for this account Privacy Visibility // Set posts from this account to sensitive by default? - Sensitive bool `pg:",default:'false'"` + Sensitive bool // What language does this account post in? Language string `pg:",default:'en'"` @@ -107,7 +107,7 @@ type Account struct { // URL for getting the featured collection list of this account FeaturedCollectionURI string `pg:",unique"` // What type of activitypub actor is this account? - ActorType ActivityStreamsActor + ActorType string // This account is associated with x account id AlsoKnownAs string diff --git a/internal/gtsmodel/activitystreams.go b/internal/gtsmodel/activitystreams.go index f852340..3fe6107 100644 --- a/internal/gtsmodel/activitystreams.go +++ b/internal/gtsmodel/activitystreams.go @@ -18,110 +18,101 @@ package gtsmodel -// ActivityStreamsObject refers to https://www.w3.org/TR/activitystreams-vocabulary/#object-types -type ActivityStreamsObject string - const ( // ActivityStreamsArticle https://www.w3.org/TR/activitystreams-vocabulary/#dfn-article - ActivityStreamsArticle ActivityStreamsObject = "Article" + ActivityStreamsArticle = "Article" // ActivityStreamsAudio https://www.w3.org/TR/activitystreams-vocabulary/#dfn-audio - ActivityStreamsAudio ActivityStreamsObject = "Audio" + ActivityStreamsAudio = "Audio" // ActivityStreamsDocument https://www.w3.org/TR/activitystreams-vocabulary/#dfn-document - ActivityStreamsDocument ActivityStreamsObject = "Event" + ActivityStreamsDocument = "Event" // ActivityStreamsEvent https://www.w3.org/TR/activitystreams-vocabulary/#dfn-event - ActivityStreamsEvent ActivityStreamsObject = "Event" + ActivityStreamsEvent = "Event" // ActivityStreamsImage https://www.w3.org/TR/activitystreams-vocabulary/#dfn-image - ActivityStreamsImage ActivityStreamsObject = "Image" + ActivityStreamsImage = "Image" // ActivityStreamsNote https://www.w3.org/TR/activitystreams-vocabulary/#dfn-note - ActivityStreamsNote ActivityStreamsObject = "Note" + ActivityStreamsNote = "Note" // ActivityStreamsPage https://www.w3.org/TR/activitystreams-vocabulary/#dfn-page - ActivityStreamsPage ActivityStreamsObject = "Page" + ActivityStreamsPage = "Page" // ActivityStreamsPlace https://www.w3.org/TR/activitystreams-vocabulary/#dfn-place - ActivityStreamsPlace ActivityStreamsObject = "Place" + ActivityStreamsPlace = "Place" // ActivityStreamsProfile https://www.w3.org/TR/activitystreams-vocabulary/#dfn-profile - ActivityStreamsProfile ActivityStreamsObject = "Profile" + ActivityStreamsProfile = "Profile" // ActivityStreamsRelationship https://www.w3.org/TR/activitystreams-vocabulary/#dfn-relationship - ActivityStreamsRelationship ActivityStreamsObject = "Relationship" + ActivityStreamsRelationship = "Relationship" // ActivityStreamsTombstone https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tombstone - ActivityStreamsTombstone ActivityStreamsObject = "Tombstone" + ActivityStreamsTombstone = "Tombstone" // ActivityStreamsVideo https://www.w3.org/TR/activitystreams-vocabulary/#dfn-video - ActivityStreamsVideo ActivityStreamsObject = "Video" + ActivityStreamsVideo = "Video" ) -// ActivityStreamsActor refers to https://www.w3.org/TR/activitystreams-vocabulary/#actor-types -type ActivityStreamsActor string - const ( // ActivityStreamsApplication https://www.w3.org/TR/activitystreams-vocabulary/#dfn-application - ActivityStreamsApplication ActivityStreamsActor = "Application" + ActivityStreamsApplication = "Application" // ActivityStreamsGroup https://www.w3.org/TR/activitystreams-vocabulary/#dfn-group - ActivityStreamsGroup ActivityStreamsActor = "Group" + ActivityStreamsGroup = "Group" // ActivityStreamsOrganization https://www.w3.org/TR/activitystreams-vocabulary/#dfn-organization - ActivityStreamsOrganization ActivityStreamsActor = "Organization" + ActivityStreamsOrganization = "Organization" // ActivityStreamsPerson https://www.w3.org/TR/activitystreams-vocabulary/#dfn-person - ActivityStreamsPerson ActivityStreamsActor = "Person" + ActivityStreamsPerson = "Person" // ActivityStreamsService https://www.w3.org/TR/activitystreams-vocabulary/#dfn-service - ActivityStreamsService ActivityStreamsActor = "Service" + ActivityStreamsService = "Service" ) -// ActivityStreamsActivity refers to https://www.w3.org/TR/activitystreams-vocabulary/#activity-types -type ActivityStreamsActivity string - const ( // ActivityStreamsAccept https://www.w3.org/TR/activitystreams-vocabulary/#dfn-accept - ActivityStreamsAccept ActivityStreamsActivity = "Accept" + ActivityStreamsAccept = "Accept" // ActivityStreamsAdd https://www.w3.org/TR/activitystreams-vocabulary/#dfn-add - ActivityStreamsAdd ActivityStreamsActivity = "Add" + ActivityStreamsAdd = "Add" // ActivityStreamsAnnounce https://www.w3.org/TR/activitystreams-vocabulary/#dfn-announce - ActivityStreamsAnnounce ActivityStreamsActivity = "Announce" + ActivityStreamsAnnounce = "Announce" // ActivityStreamsArrive https://www.w3.org/TR/activitystreams-vocabulary/#dfn-arrive - ActivityStreamsArrive ActivityStreamsActivity = "Arrive" + ActivityStreamsArrive = "Arrive" // ActivityStreamsBlock https://www.w3.org/TR/activitystreams-vocabulary/#dfn-block - ActivityStreamsBlock ActivityStreamsActivity = "Block" + ActivityStreamsBlock = "Block" // ActivityStreamsCreate https://www.w3.org/TR/activitystreams-vocabulary/#dfn-create - ActivityStreamsCreate ActivityStreamsActivity = "Create" + ActivityStreamsCreate = "Create" // ActivityStreamsDelete https://www.w3.org/TR/activitystreams-vocabulary/#dfn-delete - ActivityStreamsDelete ActivityStreamsActivity = "Delete" + ActivityStreamsDelete = "Delete" // ActivityStreamsDislike https://www.w3.org/TR/activitystreams-vocabulary/#dfn-dislike - ActivityStreamsDislike ActivityStreamsActivity = "Dislike" + ActivityStreamsDislike = "Dislike" // ActivityStreamsFlag https://www.w3.org/TR/activitystreams-vocabulary/#dfn-flag - ActivityStreamsFlag ActivityStreamsActivity = "Flag" + ActivityStreamsFlag = "Flag" // ActivityStreamsFollow https://www.w3.org/TR/activitystreams-vocabulary/#dfn-follow - ActivityStreamsFollow ActivityStreamsActivity = "Follow" + ActivityStreamsFollow = "Follow" // ActivityStreamsIgnore https://www.w3.org/TR/activitystreams-vocabulary/#dfn-ignore - ActivityStreamsIgnore ActivityStreamsActivity = "Ignore" + ActivityStreamsIgnore = "Ignore" // ActivityStreamsInvite https://www.w3.org/TR/activitystreams-vocabulary/#dfn-invite - ActivityStreamsInvite ActivityStreamsActivity = "Invite" + ActivityStreamsInvite = "Invite" // ActivityStreamsJoin https://www.w3.org/TR/activitystreams-vocabulary/#dfn-join - ActivityStreamsJoin ActivityStreamsActivity = "Join" + ActivityStreamsJoin = "Join" // ActivityStreamsLeave https://www.w3.org/TR/activitystreams-vocabulary/#dfn-leave - ActivityStreamsLeave ActivityStreamsActivity = "Leave" + ActivityStreamsLeave = "Leave" // ActivityStreamsLike https://www.w3.org/TR/activitystreams-vocabulary/#dfn-like - ActivityStreamsLike ActivityStreamsActivity = "Like" + ActivityStreamsLike = "Like" // ActivityStreamsListen https://www.w3.org/TR/activitystreams-vocabulary/#dfn-listen - ActivityStreamsListen ActivityStreamsActivity = "Listen" + ActivityStreamsListen = "Listen" // ActivityStreamsMove https://www.w3.org/TR/activitystreams-vocabulary/#dfn-move - ActivityStreamsMove ActivityStreamsActivity = "Move" + ActivityStreamsMove = "Move" // ActivityStreamsOffer https://www.w3.org/TR/activitystreams-vocabulary/#dfn-offer - ActivityStreamsOffer ActivityStreamsActivity = "Offer" + ActivityStreamsOffer = "Offer" // ActivityStreamsQuestion https://www.w3.org/TR/activitystreams-vocabulary/#dfn-question - ActivityStreamsQuestion ActivityStreamsActivity = "Question" + ActivityStreamsQuestion = "Question" // ActivityStreamsReject https://www.w3.org/TR/activitystreams-vocabulary/#dfn-reject - ActivityStreamsReject ActivityStreamsActivity = "Reject" + ActivityStreamsReject = "Reject" // ActivityStreamsRead https://www.w3.org/TR/activitystreams-vocabulary/#dfn-read - ActivityStreamsRead ActivityStreamsActivity = "Read" + ActivityStreamsRead = "Read" // ActivityStreamsRemove https://www.w3.org/TR/activitystreams-vocabulary/#dfn-remove - ActivityStreamsRemove ActivityStreamsActivity = "Remove" + ActivityStreamsRemove = "Remove" // ActivityStreamsTentativeReject https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tentativereject - ActivityStreamsTentativeReject ActivityStreamsActivity = "TentativeReject" + ActivityStreamsTentativeReject = "TentativeReject" // ActivityStreamsTentativeAccept https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tentativeaccept - ActivityStreamsTentativeAccept ActivityStreamsActivity = "TentativeAccept" + ActivityStreamsTentativeAccept = "TentativeAccept" // ActivityStreamsTravel https://www.w3.org/TR/activitystreams-vocabulary/#dfn-travel - ActivityStreamsTravel ActivityStreamsActivity = "Travel" + ActivityStreamsTravel = "Travel" // ActivityStreamsUndo https://www.w3.org/TR/activitystreams-vocabulary/#dfn-undo - ActivityStreamsUndo ActivityStreamsActivity = "Undo" + ActivityStreamsUndo = "Undo" // ActivityStreamsUpdate https://www.w3.org/TR/activitystreams-vocabulary/#dfn-update - ActivityStreamsUpdate ActivityStreamsActivity = "Update" + ActivityStreamsUpdate = "Update" // ActivityStreamsView https://www.w3.org/TR/activitystreams-vocabulary/#dfn-view - ActivityStreamsView ActivityStreamsActivity = "View" + ActivityStreamsView = "View" ) diff --git a/internal/gtsmodel/mention.go b/internal/gtsmodel/mention.go index 8e56a1b..3abe9c9 100644 --- a/internal/gtsmodel/mention.go +++ b/internal/gtsmodel/mention.go @@ -38,6 +38,14 @@ type Mention struct { TargetAccountID string `pg:",notnull"` // Prevent this mention from generating a notification? Silent bool + + /* + NON-DATABASE CONVENIENCE FIELDS + These fields are just for convenience while passing the mention + around internally, to make fewer database calls and whatnot. They're + not meant to be put in the database! + */ + // NameString is for putting in the namestring of the mentioned user // before the mention is dereferenced. Should be in a form along the lines of: // @whatever_username@example.org @@ -48,4 +56,6 @@ type Mention struct { // // This will not be put in the database, it's just for convenience. MentionedAccountURI string `pg:"-"` + // A pointer to the gtsmodel account of the mentioned account. + GTSAccount *Account `pg:"-"` } diff --git a/internal/gtsmodel/messages.go b/internal/gtsmodel/messages.go index 43f3063..910c748 100644 --- a/internal/gtsmodel/messages.go +++ b/internal/gtsmodel/messages.go @@ -9,9 +9,11 @@ package gtsmodel // FromClientAPI wraps a message that travels from client API into the processor type FromClientAPI struct { - APObjectType ActivityStreamsObject - APActivityType ActivityStreamsActivity + APObjectType string + APActivityType string GTSModel interface{} + OriginAccount *Account + TargetAccount *Account } // // ToFederator wraps a message that travels from the processor into the federator @@ -23,7 +25,8 @@ type FromClientAPI struct { // FromFederator wraps a message that travels from the federator into the processor type FromFederator struct { - APObjectType ActivityStreamsObject - APActivityType ActivityStreamsActivity - GTSModel interface{} + APObjectType string + APActivityType string + GTSModel interface{} + ReceivingAccount *Account } diff --git a/internal/gtsmodel/relationship.go b/internal/gtsmodel/relationship.go new file mode 100644 index 0000000..4e6cc03 --- /dev/null +++ b/internal/gtsmodel/relationship.go @@ -0,0 +1,49 @@ +/* + 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 gtsmodel + +// Relationship describes a requester's relationship with another account. +type Relationship struct { + // The account id. + ID string + // Are you following this user? + Following bool + // Are you receiving this user's boosts in your home timeline? + ShowingReblogs bool + // Have you enabled notifications for this user? + Notifying bool + // Are you followed by this user? + FollowedBy bool + // Are you blocking this user? + Blocking bool + // Is this user blocking you? + BlockedBy bool + // Are you muting this user? + Muting bool + // Are you muting notifications from this user? + MutingNotifications bool + // Do you have a pending follow request for this user? + Requested bool + // Are you blocking this user's domain? + DomainBlocking bool + // Are you featuring this user on your profile? + Endorsed bool + // Your note on this account. + Note string +} diff --git a/internal/gtsmodel/status.go b/internal/gtsmodel/status.go index d0d4795..b5ac8de 100644 --- a/internal/gtsmodel/status.go +++ b/internal/gtsmodel/status.go @@ -66,7 +66,7 @@ type Status struct { VisibilityAdvanced *VisibilityAdvanced // What is the activitystreams type of this status? See: https://www.w3.org/TR/activitystreams-vocabulary/#object-types // Will probably almost always be Note but who knows!. - ActivityStreamsType ActivityStreamsObject + ActivityStreamsType string // Original text of the status without formatting Text string // Has this status been pinned by its owner? diff --git a/internal/media/handler.go b/internal/media/handler.go index 8bbff9c..b59e836 100644 --- a/internal/media/handler.go +++ b/internal/media/handler.go @@ -67,7 +67,7 @@ type Handler interface { // ProcessHeaderOrAvatar takes a new header image for an account, checks it out, removes exif data from it, // puts it in whatever storage backend we're using, sets the relevant fields in the database for the new image, // and then returns information to the caller about the new header. - ProcessHeaderOrAvatar(img []byte, accountID string, mediaType Type) (*gtsmodel.MediaAttachment, error) + ProcessHeaderOrAvatar(attachment []byte, accountID string, mediaType Type, remoteURL string) (*gtsmodel.MediaAttachment, error) // ProcessLocalAttachment takes a new attachment and the requesting account, checks it out, removes exif data from it, // puts it in whatever storage backend we're using, sets the relevant fields in the database for the new media, @@ -86,6 +86,8 @@ type Handler interface { // information to the caller about the new attachment. It's the caller's responsibility to put the returned struct // in the database. ProcessRemoteAttachment(t transport.Transport, currentAttachment *gtsmodel.MediaAttachment, accountID string) (*gtsmodel.MediaAttachment, error) + + ProcessRemoteHeaderOrAvatar(t transport.Transport, currentAttachment *gtsmodel.MediaAttachment, accountID string) (*gtsmodel.MediaAttachment, error) } type mediaHandler struct { @@ -112,7 +114,7 @@ func New(config *config.Config, database db.DB, storage storage.Storage, log *lo // ProcessHeaderOrAvatar takes a new header image for an account, checks it out, removes exif data from it, // puts it in whatever storage backend we're using, sets the relevant fields in the database for the new image, // and then returns information to the caller about the new header. -func (mh *mediaHandler) ProcessHeaderOrAvatar(attachment []byte, accountID string, mediaType Type) (*gtsmodel.MediaAttachment, error) { +func (mh *mediaHandler) ProcessHeaderOrAvatar(attachment []byte, accountID string, mediaType Type, remoteURL string) (*gtsmodel.MediaAttachment, error) { l := mh.log.WithField("func", "SetHeaderForAccountID") if mediaType != Header && mediaType != Avatar { @@ -134,7 +136,7 @@ func (mh *mediaHandler) ProcessHeaderOrAvatar(attachment []byte, accountID strin l.Tracef("read %d bytes of file", len(attachment)) // process it - ma, err := mh.processHeaderOrAvi(attachment, contentType, mediaType, accountID) + ma, err := mh.processHeaderOrAvi(attachment, contentType, mediaType, accountID, remoteURL) if err != nil { return nil, fmt.Errorf("error processing %s: %s", mediaType, err) } @@ -315,3 +317,43 @@ func (mh *mediaHandler) ProcessRemoteAttachment(t transport.Transport, currentAt return mh.ProcessAttachment(attachmentBytes, accountID, currentAttachment.RemoteURL) } + +func (mh *mediaHandler) ProcessRemoteHeaderOrAvatar(t transport.Transport, currentAttachment *gtsmodel.MediaAttachment, accountID string) (*gtsmodel.MediaAttachment, error) { + + if !currentAttachment.Header && !currentAttachment.Avatar { + return nil, errors.New("provided attachment was set to neither header nor avatar") + } + + if currentAttachment.Header && currentAttachment.Avatar { + return nil, errors.New("provided attachment was set to both header and avatar") + } + + var headerOrAvi Type + if currentAttachment.Header { + headerOrAvi = Header + } else if currentAttachment.Avatar { + headerOrAvi = Avatar + } + + if currentAttachment.RemoteURL == "" { + return nil, errors.New("no remote URL on media attachment to dereference") + } + remoteIRI, err := url.Parse(currentAttachment.RemoteURL) + if err != nil { + return nil, fmt.Errorf("error parsing attachment url %s: %s", currentAttachment.RemoteURL, err) + } + + // for content type, we assume we don't know what to expect... + expectedContentType := "*/*" + if currentAttachment.File.ContentType != "" { + // ... and then narrow it down if we do + expectedContentType = currentAttachment.File.ContentType + } + + attachmentBytes, err := t.DereferenceMedia(context.Background(), remoteIRI, expectedContentType) + if err != nil { + return nil, fmt.Errorf("dereferencing remote media with url %s: %s", remoteIRI.String(), err) + } + + return mh.ProcessHeaderOrAvatar(attachmentBytes, accountID, headerOrAvi, currentAttachment.RemoteURL) +} diff --git a/internal/media/handler_test.go b/internal/media/handler_test.go index 03dcdc2..02bf334 100644 --- a/internal/media/handler_test.go +++ b/internal/media/handler_test.go @@ -147,7 +147,7 @@ func (suite *MediaTestSuite) TestSetHeaderOrAvatarForAccountID() { f, err := ioutil.ReadFile("./test/test-jpeg.jpg") assert.Nil(suite.T(), err) - ma, err := suite.mediaHandler.ProcessHeaderOrAvatar(f, "weeeeeee", "header") + ma, err := suite.mediaHandler.ProcessHeaderOrAvatar(f, "weeeeeee", "header", "") assert.Nil(suite.T(), err) suite.log.Debugf("%+v", ma) diff --git a/internal/media/processicon.go b/internal/media/processicon.go index 962d1c6..bc2c558 100644 --- a/internal/media/processicon.go +++ b/internal/media/processicon.go @@ -28,7 +28,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" ) -func (mh *mediaHandler) processHeaderOrAvi(imageBytes []byte, contentType string, mediaType Type, accountID string) (*gtsmodel.MediaAttachment, error) { +func (mh *mediaHandler) processHeaderOrAvi(imageBytes []byte, contentType string, mediaType Type, accountID string, remoteURL string) (*gtsmodel.MediaAttachment, error) { var isHeader bool var isAvatar bool @@ -96,7 +96,7 @@ func (mh *mediaHandler) processHeaderOrAvi(imageBytes []byte, contentType string ID: newMediaID, StatusID: "", URL: originalURL, - RemoteURL: "", + RemoteURL: remoteURL, CreatedAt: time.Now(), UpdatedAt: time.Now(), Type: gtsmodel.FileTypeImage, diff --git a/internal/message/accountprocess.go b/internal/message/accountprocess.go index a10f6d0..29fd550 100644 --- a/internal/message/accountprocess.go +++ b/internal/message/accountprocess.go @@ -78,6 +78,15 @@ func (p *processor) AccountGet(authed *oauth.Auth, targetAccountID string) (*api return nil, fmt.Errorf("db error: %s", err) } + // lazily dereference things on the account if it hasn't been done yet + var requestingUsername string + if authed.Account != nil { + requestingUsername = authed.Account.Username + } + if err := p.dereferenceAccountFields(targetAccount, requestingUsername); err != nil { + p.log.WithField("func", "AccountGet").Debugf("dereferencing account: %s", err) + } + var mastoAccount *apimodel.Account var err error if authed.Account != nil && targetAccount.ID == authed.Account.ID { @@ -285,6 +294,12 @@ func (p *processor) AccountFollowersGet(authed *oauth.Auth, targetAccountID stri return nil, NewErrorInternalError(err) } + // derefence account fields in case we haven't done it already + if err := p.dereferenceAccountFields(a, authed.Account.Username); err != nil { + // don't bail if we can't fetch them, we'll try another time + p.log.WithField("func", "AccountFollowersGet").Debugf("error dereferencing account fields: %s", err) + } + account, err := p.tc.AccountToMastoPublic(a) if err != nil { return nil, NewErrorInternalError(err) @@ -293,3 +308,238 @@ func (p *processor) AccountFollowersGet(authed *oauth.Auth, targetAccountID stri } return accounts, nil } + +func (p *processor) AccountFollowingGet(authed *oauth.Auth, targetAccountID string) ([]apimodel.Account, ErrorWithCode) { + blocked, err := p.db.Blocked(authed.Account.ID, targetAccountID) + if err != nil { + return nil, NewErrorInternalError(err) + } + + if blocked { + return nil, NewErrorNotFound(fmt.Errorf("block exists between accounts")) + } + + following := []gtsmodel.Follow{} + accounts := []apimodel.Account{} + if err := p.db.GetFollowingByAccountID(targetAccountID, &following); err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + return accounts, nil + } + return nil, NewErrorInternalError(err) + } + + for _, f := range following { + blocked, err := p.db.Blocked(authed.Account.ID, f.AccountID) + if err != nil { + return nil, NewErrorInternalError(err) + } + if blocked { + continue + } + + a := >smodel.Account{} + if err := p.db.GetByID(f.TargetAccountID, a); err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + continue + } + return nil, NewErrorInternalError(err) + } + + // derefence account fields in case we haven't done it already + if err := p.dereferenceAccountFields(a, authed.Account.Username); err != nil { + // don't bail if we can't fetch them, we'll try another time + p.log.WithField("func", "AccountFollowingGet").Debugf("error dereferencing account fields: %s", err) + } + + account, err := p.tc.AccountToMastoPublic(a) + if err != nil { + return nil, NewErrorInternalError(err) + } + accounts = append(accounts, *account) + } + return accounts, nil +} + +func (p *processor) AccountRelationshipGet(authed *oauth.Auth, targetAccountID string) (*apimodel.Relationship, ErrorWithCode) { + if authed == nil || authed.Account == nil { + return nil, NewErrorForbidden(errors.New("not authed")) + } + + gtsR, err := p.db.GetRelationship(authed.Account.ID, targetAccountID) + if err != nil { + return nil, NewErrorInternalError(fmt.Errorf("error getting relationship: %s", err)) + } + + r, err := p.tc.RelationshipToMasto(gtsR) + if err != nil { + return nil, NewErrorInternalError(fmt.Errorf("error converting relationship: %s", err)) + } + + 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) +} + +func (p *processor) AccountFollowRemove(authed *oauth.Auth, targetAccountID string) (*apimodel.Relationship, ErrorWithCode) { + // if there's a block between the accounts we shouldn't do anything + blocked, err := p.db.Blocked(authed.Account.ID, targetAccountID) + if err != nil { + return nil, NewErrorInternalError(err) + } + if blocked { + return nil, NewErrorNotFound(fmt.Errorf("AccountFollowRemove: block exists between accounts")) + } + + // make sure the target account actually exists in our db + targetAcct := >smodel.Account{} + if err := p.db.GetByID(targetAccountID, targetAcct); err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + return nil, NewErrorNotFound(fmt.Errorf("AccountFollowRemove: account %s not found in the db: %s", targetAccountID, err)) + } + } + + // check if a follow request exists, and remove it if it does (storing the URI for later) + var frChanged bool + var frURI string + fr := >smodel.FollowRequest{} + if err := p.db.GetWhere([]db.Where{ + {Key: "account_id", Value: authed.Account.ID}, + {Key: "target_account_id", Value: targetAccountID}, + }, fr); err == nil { + frURI = fr.URI + if err := p.db.DeleteByID(fr.ID, fr); err != nil { + return nil, NewErrorInternalError(fmt.Errorf("AccountFollowRemove: error removing follow request from db: %s", err)) + } + frChanged = true + } + + // now do the same thing for any existing follow + var fChanged bool + var fURI string + f := >smodel.Follow{} + if err := p.db.GetWhere([]db.Where{ + {Key: "account_id", Value: authed.Account.ID}, + {Key: "target_account_id", Value: targetAccountID}, + }, f); err == nil { + fURI = f.URI + if err := p.db.DeleteByID(f.ID, f); err != nil { + return nil, NewErrorInternalError(fmt.Errorf("AccountFollowRemove: error removing follow from db: %s", err)) + } + fChanged = true + } + + // follow request status changed so send the UNDO activity to the channel for async processing + if frChanged { + p.fromClientAPI <- gtsmodel.FromClientAPI{ + APObjectType: gtsmodel.ActivityStreamsFollow, + APActivityType: gtsmodel.ActivityStreamsUndo, + GTSModel: >smodel.Follow{ + AccountID: authed.Account.ID, + TargetAccountID: targetAccountID, + URI: frURI, + }, + OriginAccount: authed.Account, + TargetAccount: targetAcct, + } + } + + // follow status changed so send the UNDO activity to the channel for async processing + if fChanged { + p.fromClientAPI <- gtsmodel.FromClientAPI{ + APObjectType: gtsmodel.ActivityStreamsFollow, + APActivityType: gtsmodel.ActivityStreamsUndo, + GTSModel: >smodel.Follow{ + AccountID: authed.Account.ID, + TargetAccountID: targetAccountID, + URI: fURI, + }, + OriginAccount: authed.Account, + TargetAccount: targetAcct, + } + } + + // return whatever relationship results from all this + return p.AccountRelationshipGet(authed, targetAccountID) +} diff --git a/internal/message/fediprocess.go b/internal/message/fediprocess.go index 3c7c30e..eb6e8b6 100644 --- a/internal/message/fediprocess.go +++ b/internal/message/fediprocess.go @@ -22,6 +22,7 @@ import ( "context" "fmt" "net/http" + "net/url" "github.com/go-fed/activity/streams" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" @@ -46,7 +47,7 @@ func (p *processor) authenticateAndDereferenceFediRequest(username string, r *ht // we might already have an entry for this account so check that first requestingAccount := >smodel.Account{} - err = p.db.GetWhere("uri", requestingAccountURI.String(), requestingAccount) + err = p.db.GetWhere([]db.Where{{Key: "uri", Value: requestingAccountURI.String()}}, requestingAccount) if err == nil { // we do have it yay, return it return requestingAccount, nil @@ -122,6 +123,89 @@ func (p *processor) GetFediUser(requestedUsername string, request *http.Request) return data, nil } +func (p *processor) GetFediFollowers(requestedUsername string, request *http.Request) (interface{}, ErrorWithCode) { + // get the account the request is referring to + requestedAccount := >smodel.Account{} + if err := p.db.GetLocalAccountByUsername(requestedUsername, requestedAccount); err != nil { + return nil, NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err)) + } + + // authenticate the request + requestingAccount, err := p.authenticateAndDereferenceFediRequest(requestedUsername, request) + if err != nil { + return nil, NewErrorNotAuthorized(err) + } + + blocked, err := p.db.Blocked(requestedAccount.ID, requestingAccount.ID) + if err != nil { + return nil, NewErrorInternalError(err) + } + + if blocked { + return nil, NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID)) + } + + requestedAccountURI, err := url.Parse(requestedAccount.URI) + if err != nil { + return nil, NewErrorInternalError(fmt.Errorf("error parsing url %s: %s", requestedAccount.URI, err)) + } + + requestedFollowers, err := p.federator.FederatingDB().Followers(context.Background(), requestedAccountURI) + if err != nil { + return nil, NewErrorInternalError(fmt.Errorf("error fetching followers for uri %s: %s", requestedAccountURI.String(), err)) + } + + data, err := streams.Serialize(requestedFollowers) + if err != nil { + return nil, NewErrorInternalError(err) + } + + return data, nil +} + +func (p *processor) GetFediStatus(requestedUsername string, requestedStatusID string, request *http.Request) (interface{}, ErrorWithCode) { + // get the account the request is referring to + requestedAccount := >smodel.Account{} + if err := p.db.GetLocalAccountByUsername(requestedUsername, requestedAccount); err != nil { + return nil, NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err)) + } + + // authenticate the request + requestingAccount, err := p.authenticateAndDereferenceFediRequest(requestedUsername, request) + if err != nil { + return nil, NewErrorNotAuthorized(err) + } + + blocked, err := p.db.Blocked(requestedAccount.ID, requestingAccount.ID) + if err != nil { + return nil, NewErrorInternalError(err) + } + + if blocked { + return nil, NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID)) + } + + s := >smodel.Status{} + if err := p.db.GetWhere([]db.Where{ + {Key: "id", Value: requestedStatusID}, + {Key: "account_id", Value: requestedAccount.ID}, + }, s); err != nil { + return nil, NewErrorNotFound(fmt.Errorf("database error getting status with id %s and account id %s: %s", requestedStatusID, requestedAccount.ID, err)) + } + + asStatus, err := p.tc.StatusToAS(s) + if err != nil { + return nil, NewErrorInternalError(err) + } + + data, err := streams.Serialize(asStatus) + if err != nil { + return nil, NewErrorInternalError(err) + } + + return data, nil +} + func (p *processor) GetWebfingerAccount(requestedUsername string, request *http.Request) (*apimodel.WebfingerAccountResponse, ErrorWithCode) { // get the account the request is referring to requestedAccount := >smodel.Account{} diff --git a/internal/message/fromclientapiprocess.go b/internal/message/fromclientapiprocess.go index 1a12216..e91bd6c 100644 --- a/internal/message/fromclientapiprocess.go +++ b/internal/message/fromclientapiprocess.go @@ -19,55 +19,198 @@ package message import ( + "context" "errors" "fmt" + "net/url" + "github.com/go-fed/activity/streams" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" ) func (p *processor) processFromClientAPI(clientMsg gtsmodel.FromClientAPI) error { - switch clientMsg.APObjectType { - case gtsmodel.ActivityStreamsNote: - status, ok := clientMsg.GTSModel.(*gtsmodel.Status) - if !ok { - return errors.New("note was not parseable as *gtsmodel.Status") - } + switch clientMsg.APActivityType { + case gtsmodel.ActivityStreamsCreate: + // CREATE + switch clientMsg.APObjectType { + case gtsmodel.ActivityStreamsNote: + // CREATE NOTE + status, ok := clientMsg.GTSModel.(*gtsmodel.Status) + if !ok { + return errors.New("note was not parseable as *gtsmodel.Status") + } - if err := p.notifyStatus(status); err != nil { - return err - } + if err := p.notifyStatus(status); err != nil { + return err + } - if status.VisibilityAdvanced.Federated { - return p.federateStatus(status) + if status.VisibilityAdvanced.Federated { + 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 + case gtsmodel.ActivityStreamsAccept: + // ACCEPT + switch clientMsg.APObjectType { + case gtsmodel.ActivityStreamsFollow: + // ACCEPT FOLLOW + follow, ok := clientMsg.GTSModel.(*gtsmodel.Follow) + if !ok { + return errors.New("accept was not parseable as *gtsmodel.Follow") + } + return p.federateAcceptFollowRequest(follow, clientMsg.OriginAccount, clientMsg.TargetAccount) + } + case gtsmodel.ActivityStreamsUndo: + // UNDO + switch clientMsg.APObjectType { + case gtsmodel.ActivityStreamsFollow: + // UNDO FOLLOW + follow, ok := clientMsg.GTSModel.(*gtsmodel.Follow) + if !ok { + return errors.New("undo was not parseable as *gtsmodel.Follow") + } + return p.federateUnfollow(follow, clientMsg.OriginAccount, clientMsg.TargetAccount) } - return nil } - return fmt.Errorf("message type unprocessable: %+v", clientMsg) + return nil } func (p *processor) federateStatus(status *gtsmodel.Status) error { - // // derive the sending account -- it might be attached to the status already - // sendingAcct := >smodel.Account{} - // if status.GTSAccount != nil { - // sendingAcct = status.GTSAccount - // } else { - // // it wasn't attached so get it from the db instead - // if err := p.db.GetByID(status.AccountID, sendingAcct); err != nil { - // return err - // } - // } + asStatus, err := p.tc.StatusToAS(status) + if err != nil { + return fmt.Errorf("federateStatus: error converting status to as format: %s", err) + } - // outboxURI, err := url.Parse(sendingAcct.OutboxURI) - // if err != nil { - // return err - // } + outboxIRI, err := url.Parse(status.GTSAccount.OutboxURI) + if err != nil { + return fmt.Errorf("federateStatus: error parsing outboxURI %s: %s", status.GTSAccount.OutboxURI, err) + } - // // convert the status to AS format Note - // note, err := p.tc.StatusToAS(status) - // if err != nil { - // return err - // } - - // _, err = p.federator.FederatingActor().Send(context.Background(), outboxURI, note) - return nil + _, err = p.federator.FederatingActor().Send(context.Background(), outboxIRI, asStatus) + return err +} + +func (p *processor) federateFollow(follow *gtsmodel.Follow, 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 + } + + 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) federateUnfollow(follow *gtsmodel.Follow, 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 + } + + // recreate the follow + asFollow, err := p.tc.FollowToAS(follow, originAccount, targetAccount) + if err != nil { + return fmt.Errorf("federateUnfollow: error converting follow to as format: %s", err) + } + + targetAccountURI, err := url.Parse(targetAccount.URI) + if err != nil { + return fmt.Errorf("error parsing uri %s: %s", targetAccount.URI, err) + } + + // create an Undo and set the appropriate actor on it + undo := streams.NewActivityStreamsUndo() + undo.SetActivityStreamsActor(asFollow.GetActivityStreamsActor()) + + // Set the recreated follow as the 'object' property. + undoObject := streams.NewActivityStreamsObjectProperty() + undoObject.AppendActivityStreamsFollow(asFollow) + undo.SetActivityStreamsObject(undoObject) + + // Set the To of the undo as the target of the recreated follow + undoTo := streams.NewActivityStreamsToProperty() + undoTo.AppendIRI(targetAccountURI) + undo.SetActivityStreamsTo(undoTo) + + outboxIRI, err := url.Parse(originAccount.OutboxURI) + if err != nil { + return fmt.Errorf("federateUnfollow: error parsing outboxURI %s: %s", originAccount.OutboxURI, err) + } + + // send off the Undo + _, err = p.federator.FederatingActor().Send(context.Background(), outboxIRI, undo) + return err +} + +func (p *processor) federateAcceptFollowRequest(follow *gtsmodel.Follow, 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 + } + + // recreate the AS follow + asFollow, err := p.tc.FollowToAS(follow, originAccount, targetAccount) + if err != nil { + return fmt.Errorf("federateUnfollow: error converting follow to as format: %s", err) + } + + acceptingAccountURI, err := url.Parse(targetAccount.URI) + if err != nil { + return fmt.Errorf("error parsing uri %s: %s", targetAccount.URI, err) + } + + requestingAccountURI, err := url.Parse(originAccount.URI) + if err != nil { + return fmt.Errorf("error parsing uri %s: %s", targetAccount.URI, err) + } + + // create an Accept + accept := streams.NewActivityStreamsAccept() + + // set the accepting actor on it + acceptActorProp := streams.NewActivityStreamsActorProperty() + acceptActorProp.AppendIRI(acceptingAccountURI) + accept.SetActivityStreamsActor(acceptActorProp) + + // Set the recreated follow as the 'object' property. + acceptObject := streams.NewActivityStreamsObjectProperty() + acceptObject.AppendActivityStreamsFollow(asFollow) + accept.SetActivityStreamsObject(acceptObject) + + // Set the To of the accept as the originator of the follow + acceptTo := streams.NewActivityStreamsToProperty() + acceptTo.AppendIRI(requestingAccountURI) + accept.SetActivityStreamsTo(acceptTo) + + outboxIRI, err := url.Parse(targetAccount.OutboxURI) + if err != nil { + return fmt.Errorf("federateAcceptFollowRequest: error parsing outboxURI %s: %s", originAccount.OutboxURI, err) + } + + // send off the accept using the accepter's outbox + _, err = p.federator.FederatingActor().Send(context.Background(), outboxIRI, accept) + return err } diff --git a/internal/message/fromcommonprocess.go b/internal/message/fromcommonprocess.go index 14f145d..d557b79 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/fromfederatorprocess.go b/internal/message/fromfederatorprocess.go index 2dd8e9e..ffaa1b9 100644 --- a/internal/message/fromfederatorprocess.go +++ b/internal/message/fromfederatorprocess.go @@ -38,24 +38,60 @@ func (p *processor) processFromFederator(federatorMsg gtsmodel.FromFederator) er l.Debug("entering function PROCESS FROM FEDERATOR") - switch federatorMsg.APObjectType { - case gtsmodel.ActivityStreamsNote: + switch federatorMsg.APActivityType { + case gtsmodel.ActivityStreamsCreate: + // CREATE + switch federatorMsg.APObjectType { + case gtsmodel.ActivityStreamsNote: + // CREATE A STATUS + incomingStatus, ok := federatorMsg.GTSModel.(*gtsmodel.Status) + if !ok { + return errors.New("note was not parseable as *gtsmodel.Status") + } - incomingStatus, ok := federatorMsg.GTSModel.(*gtsmodel.Status) - if !ok { - return errors.New("note was not parseable as *gtsmodel.Status") - } + l.Debug("will now derefence incoming status") + if err := p.dereferenceStatusFields(incomingStatus); err != nil { + return fmt.Errorf("error dereferencing status from federator: %s", err) + } + if err := p.db.UpdateByID(incomingStatus.ID, incomingStatus); err != nil { + return fmt.Errorf("error updating dereferenced status in the db: %s", err) + } - l.Debug("will now derefence incoming status") - if err := p.dereferenceStatusFields(incomingStatus); err != nil { - return fmt.Errorf("error dereferencing status from federator: %s", err) - } - if err := p.db.UpdateByID(incomingStatus.ID, incomingStatus); err != nil { - return fmt.Errorf("error updating dereferenced status in the db: %s", err) - } + if err := p.notifyStatus(incomingStatus); err != nil { + return err + } + case gtsmodel.ActivityStreamsProfile: + // CREATE AN ACCOUNT + incomingAccount, ok := federatorMsg.GTSModel.(*gtsmodel.Account) + if !ok { + return errors.New("profile was not parseable as *gtsmodel.Account") + } - if err := p.notifyStatus(incomingStatus); err != nil { - return err + l.Debug("will now derefence incoming account") + if err := p.dereferenceAccountFields(incomingAccount, ""); err != nil { + return fmt.Errorf("error dereferencing account from federator: %s", err) + } + if err := p.db.UpdateByID(incomingAccount.ID, incomingAccount); err != nil { + return fmt.Errorf("error updating dereferenced account in the db: %s", err) + } + } + case gtsmodel.ActivityStreamsUpdate: + // UPDATE + switch federatorMsg.APObjectType { + case gtsmodel.ActivityStreamsProfile: + // UPDATE AN ACCOUNT + incomingAccount, ok := federatorMsg.GTSModel.(*gtsmodel.Account) + if !ok { + return errors.New("profile was not parseable as *gtsmodel.Account") + } + + l.Debug("will now derefence incoming account") + if err := p.dereferenceAccountFields(incomingAccount, ""); err != nil { + return fmt.Errorf("error dereferencing account from federator: %s", err) + } + if err := p.db.UpdateByID(incomingAccount.ID, incomingAccount); err != nil { + return fmt.Errorf("error updating dereferenced account in the db: %s", err) + } } } @@ -121,7 +157,7 @@ func (p *processor) dereferenceStatusFields(status *gtsmodel.Status) error { // it might have been processed elsewhere so check first if it's already in the database or not maybeAttachment := >smodel.MediaAttachment{} - err := p.db.GetWhere("remote_url", a.RemoteURL, maybeAttachment) + err := p.db.GetWhere([]db.Where{{Key: "remote_url", Value: a.RemoteURL}}, maybeAttachment) if err == nil { // we already have it in the db, dereferenced, no need to do it again l.Debugf("attachment already exists with id %s", maybeAttachment.ID) @@ -170,7 +206,7 @@ func (p *processor) dereferenceStatusFields(status *gtsmodel.Status) error { m.OriginAccountURI = status.GTSAccount.URI targetAccount := >smodel.Account{} - if err := p.db.GetWhere("uri", uri.String(), targetAccount); err != nil { + if err := p.db.GetWhere([]db.Where{{Key: "uri", Value: uri.String()}}, targetAccount); err != nil { // proper error if _, ok := err.(db.ErrNoEntries); !ok { return fmt.Errorf("db error checking for account with uri %s", uri.String()) @@ -206,3 +242,27 @@ func (p *processor) dereferenceStatusFields(status *gtsmodel.Status) error { return nil } + +func (p *processor) dereferenceAccountFields(account *gtsmodel.Account, requestingUsername string) error { + l := p.log.WithFields(logrus.Fields{ + "func": "dereferenceAccountFields", + "requestingUsername": requestingUsername, + }) + + t, err := p.federator.GetTransportForUser(requestingUsername) + if err != nil { + return fmt.Errorf("error getting transport for user: %s", err) + } + + // fetch the header and avatar + if err := p.fetchHeaderAndAviForAccount(account, t); err != nil { + // if this doesn't work, just skip it -- we can do it later + l.Debugf("error fetching header/avi for account: %s", err) + } + + if err := p.db.UpdateByID(account.ID, account); err != nil { + return fmt.Errorf("error updating account in database: %s", err) + } + + return nil +} diff --git a/internal/message/frprocess.go b/internal/message/frprocess.go index cc38385..fd64b4c 100644 --- a/internal/message/frprocess.go +++ b/internal/message/frprocess.go @@ -48,11 +48,28 @@ func (p *processor) FollowRequestsGet(auth *oauth.Auth) ([]apimodel.Account, Err return accts, nil } -func (p *processor) FollowRequestAccept(auth *oauth.Auth, accountID string) ErrorWithCode { - if err := p.db.AcceptFollowRequest(accountID, auth.Account.ID); err != nil { - return NewErrorNotFound(err) +func (p *processor) FollowRequestAccept(auth *oauth.Auth, accountID string) (*apimodel.Relationship, ErrorWithCode) { + follow, err := p.db.AcceptFollowRequest(accountID, auth.Account.ID) + if err != nil { + return nil, NewErrorNotFound(err) } - return nil + + p.fromClientAPI <- gtsmodel.FromClientAPI{ + APActivityType: gtsmodel.ActivityStreamsAccept, + GTSModel: follow, + } + + gtsR, err := p.db.GetRelationship(auth.Account.ID, accountID) + if err != nil { + return nil, NewErrorInternalError(err) + } + + r, err := p.tc.RelationshipToMasto(gtsR) + if err != nil { + return nil, NewErrorInternalError(err) + } + + return r, nil } func (p *processor) FollowRequestDeny(auth *oauth.Auth) ErrorWithCode { diff --git a/internal/message/instanceprocess.go b/internal/message/instanceprocess.go index 05ea103..f544fbf 100644 --- a/internal/message/instanceprocess.go +++ b/internal/message/instanceprocess.go @@ -22,12 +22,13 @@ import ( "fmt" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" ) func (p *processor) InstanceGet(domain string) (*apimodel.Instance, ErrorWithCode) { i := >smodel.Instance{} - if err := p.db.GetWhere("domain", domain, i); err != nil { + if err := p.db.GetWhere([]db.Where{{Key: "domain", Value: domain}}, i); err != nil { return nil, NewErrorInternalError(fmt.Errorf("db error fetching instance %s: %s", p.config.Host, err)) } diff --git a/internal/message/processor.go b/internal/message/processor.go index c9ba5f8..e9888d6 100644 --- a/internal/message/processor.go +++ b/internal/message/processor.go @@ -71,8 +71,16 @@ 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) + // AccountFollowingGet fetches a list of the accounts that target account is following. + AccountFollowingGet(authed *oauth.Auth, targetAccountID string) ([]apimodel.Account, ErrorWithCode) + // 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) + // AccountFollowRemove handles the removal of a follow/follow request to an account, either remote or local. + AccountFollowRemove(authed *oauth.Auth, targetAccountID string) (*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) @@ -86,7 +94,7 @@ type Processor interface { // FollowRequestsGet handles the getting of the authed account's incoming follow requests FollowRequestsGet(auth *oauth.Auth) ([]apimodel.Account, ErrorWithCode) // FollowRequestAccept handles the acceptance of a follow request from the given account ID - FollowRequestAccept(auth *oauth.Auth, accountID string) ErrorWithCode + FollowRequestAccept(auth *oauth.Auth, accountID string) (*apimodel.Relationship, ErrorWithCode) // InstanceGet retrieves instance information for serving at api/v1/instance InstanceGet(domain string) (*apimodel.Instance, ErrorWithCode) @@ -125,6 +133,14 @@ type Processor interface { // before returning a JSON serializable interface to the caller. GetFediUser(requestedUsername string, request *http.Request) (interface{}, ErrorWithCode) + // GetFediFollowers 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. + GetFediFollowers(requestedUsername string, request *http.Request) (interface{}, ErrorWithCode) + + // GetFediStatus handles the getting of a fedi/activitypub representation of a particular status, performing appropriate + // authentication before returning a JSON serializable interface to the caller. + GetFediStatus(requestedUsername string, requestedStatusID string, request *http.Request) (interface{}, ErrorWithCode) + // GetWebfingerAccount handles the GET for a webfinger resource. Most commonly, it will be used for returning account lookups. GetWebfingerAccount(requestedUsername string, request *http.Request) (*apimodel.WebfingerAccountResponse, ErrorWithCode) @@ -200,15 +216,11 @@ func (p *processor) Start() error { DistLoop: for { select { - // case clientMsg := <-p.toClientAPI: - // p.log.Infof("received message TO client API: %+v", clientMsg) case clientMsg := <-p.fromClientAPI: p.log.Infof("received message FROM client API: %+v", clientMsg) if err := p.processFromClientAPI(clientMsg); err != nil { p.log.Error(err) } - // case federatorMsg := <-p.toFederator: - // p.log.Infof("received message TO federator: %+v", federatorMsg) case federatorMsg := <-p.fromFederator: p.log.Infof("received message FROM federator: %+v", federatorMsg) if err := p.processFromFederator(federatorMsg); err != nil { diff --git a/internal/message/processorutil.go b/internal/message/processorutil.go index 676635a..67c96ab 100644 --- a/internal/message/processorutil.go +++ b/internal/message/processorutil.go @@ -29,6 +29,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/transport" "github.com/superseriousbusiness/gotosocial/internal/util" ) @@ -205,7 +206,7 @@ func (p *processor) processMentions(form *apimodel.AdvancedStatusCreateForm, acc if err := p.db.Put(menchie); err != nil { return fmt.Errorf("error putting mentions in db: %s", err) } - menchies = append(menchies, menchie.TargetAccountID) + menchies = append(menchies, menchie.ID) } // add full populated gts menchies to the status for passing them around conveniently status.GTSMentions = gtsMenchies @@ -280,7 +281,7 @@ func (p *processor) updateAccountAvatar(avatar *multipart.FileHeader, accountID } // do the setting - avatarInfo, err := p.mediaHandler.ProcessHeaderOrAvatar(buf.Bytes(), accountID, media.Avatar) + avatarInfo, err := p.mediaHandler.ProcessHeaderOrAvatar(buf.Bytes(), accountID, media.Avatar, "") if err != nil { return nil, fmt.Errorf("error processing avatar: %s", err) } @@ -313,10 +314,42 @@ func (p *processor) updateAccountHeader(header *multipart.FileHeader, accountID } // do the setting - headerInfo, err := p.mediaHandler.ProcessHeaderOrAvatar(buf.Bytes(), accountID, media.Header) + headerInfo, err := p.mediaHandler.ProcessHeaderOrAvatar(buf.Bytes(), accountID, media.Header, "") if err != nil { return nil, fmt.Errorf("error processing header: %s", err) } return headerInfo, f.Close() } + +// fetchHeaderAndAviForAccount fetches the header and avatar for a remote account, using a transport +// on behalf of requestingUsername. +// +// targetAccount's AvatarMediaAttachmentID and HeaderMediaAttachmentID will be updated as necessary. +// +// SIDE EFFECTS: remote header and avatar will be stored in local storage, and the database will be updated +// to reflect the creation of these new attachments. +func (p *processor) fetchHeaderAndAviForAccount(targetAccount *gtsmodel.Account, t transport.Transport) error { + if targetAccount.AvatarRemoteURL != "" && targetAccount.AvatarMediaAttachmentID == "" { + a, err := p.mediaHandler.ProcessRemoteHeaderOrAvatar(t, >smodel.MediaAttachment{ + RemoteURL: targetAccount.AvatarRemoteURL, + Avatar: true, + }, targetAccount.ID) + if err != nil { + return fmt.Errorf("error processing avatar for user: %s", err) + } + targetAccount.AvatarMediaAttachmentID = a.ID + } + + if targetAccount.HeaderRemoteURL != "" && targetAccount.HeaderMediaAttachmentID == "" { + a, err := p.mediaHandler.ProcessRemoteHeaderOrAvatar(t, >smodel.MediaAttachment{ + RemoteURL: targetAccount.HeaderRemoteURL, + Header: true, + }, targetAccount.ID) + if err != nil { + return fmt.Errorf("error processing header for user: %s", err) + } + targetAccount.HeaderMediaAttachmentID = a.ID + } + return nil +} diff --git a/internal/oauth/tokenstore.go b/internal/oauth/tokenstore.go index 195db83..04319ee 100644 --- a/internal/oauth/tokenstore.go +++ b/internal/oauth/tokenstore.go @@ -106,17 +106,17 @@ func (pts *tokenStore) Create(ctx context.Context, info oauth2.TokenInfo) error // RemoveByCode deletes a token from the DB based on the Code field func (pts *tokenStore) RemoveByCode(ctx context.Context, code string) error { - return pts.db.DeleteWhere("code", code, &Token{}) + return pts.db.DeleteWhere([]db.Where{{Key: "code", Value: code}}, &Token{}) } // RemoveByAccess deletes a token from the DB based on the Access field func (pts *tokenStore) RemoveByAccess(ctx context.Context, access string) error { - return pts.db.DeleteWhere("access", access, &Token{}) + return pts.db.DeleteWhere([]db.Where{{Key: "access", Value: access}}, &Token{}) } // RemoveByRefresh deletes a token from the DB based on the Refresh field func (pts *tokenStore) RemoveByRefresh(ctx context.Context, refresh string) error { - return pts.db.DeleteWhere("refresh", refresh, &Token{}) + return pts.db.DeleteWhere([]db.Where{{Key: "refresh", Value: refresh}}, &Token{}) } // GetByCode selects a token from the DB based on the Code field @@ -127,7 +127,7 @@ func (pts *tokenStore) GetByCode(ctx context.Context, code string) (oauth2.Token pgt := &Token{ Code: code, } - if err := pts.db.GetWhere("code", code, pgt); err != nil { + if err := pts.db.GetWhere([]db.Where{{Key: "code", Value: code}}, pgt); err != nil { return nil, err } return TokenToOauthToken(pgt), nil @@ -141,7 +141,7 @@ func (pts *tokenStore) GetByAccess(ctx context.Context, access string) (oauth2.T pgt := &Token{ Access: access, } - if err := pts.db.GetWhere("access", access, pgt); err != nil { + if err := pts.db.GetWhere([]db.Where{{Key: "access", Value: access}}, pgt); err != nil { return nil, err } return TokenToOauthToken(pgt), nil @@ -155,7 +155,7 @@ func (pts *tokenStore) GetByRefresh(ctx context.Context, refresh string) (oauth2 pgt := &Token{ Refresh: refresh, } - if err := pts.db.GetWhere("refresh", refresh, pgt); err != nil { + if err := pts.db.GetWhere([]db.Where{{Key: "refresh", Value: refresh}}, pgt); err != nil { return nil, err } return TokenToOauthToken(pgt), nil diff --git a/internal/transport/controller.go b/internal/transport/controller.go index ad75408..c01af09 100644 --- a/internal/transport/controller.go +++ b/internal/transport/controller.go @@ -39,6 +39,7 @@ type controller struct { clock pub.Clock client pub.HttpClient appAgent string + log *logrus.Logger } // NewController returns an implementation of the Controller interface for creating new transports @@ -48,6 +49,7 @@ func NewController(config *config.Config, clock pub.Clock, client pub.HttpClient clock: clock, client: client, appAgent: fmt.Sprintf("%s %s", config.ApplicationName, config.Host), + log: log, } } @@ -80,5 +82,6 @@ func (c *controller) NewTransport(pubKeyID string, privkey crypto.PrivateKey) (T sigTransport: sigTransport, getSigner: getSigner, getSignerMu: &sync.Mutex{}, + log: c.log, }, nil } diff --git a/internal/transport/transport.go b/internal/transport/transport.go index afd4085..4fba484 100644 --- a/internal/transport/transport.go +++ b/internal/transport/transport.go @@ -11,6 +11,7 @@ import ( "github.com/go-fed/activity/pub" "github.com/go-fed/httpsig" + "github.com/sirupsen/logrus" ) // Transport wraps the pub.Transport interface with some additional @@ -31,6 +32,7 @@ type transport struct { sigTransport *pub.HttpSigTransport getSigner httpsig.Signer getSignerMu *sync.Mutex + log *logrus.Logger } func (t *transport) BatchDeliver(c context.Context, b []byte, recipients []*url.URL) error { @@ -38,14 +40,20 @@ func (t *transport) BatchDeliver(c context.Context, b []byte, recipients []*url. } func (t *transport) Deliver(c context.Context, b []byte, to *url.URL) error { + l := t.log.WithField("func", "Deliver") + l.Debugf("performing POST to %s", to.String()) return t.sigTransport.Deliver(c, b, to) } func (t *transport) Dereference(c context.Context, iri *url.URL) ([]byte, error) { + l := t.log.WithField("func", "Dereference") + l.Debugf("performing GET to %s", iri.String()) return t.sigTransport.Dereference(c, iri) } func (t *transport) DereferenceMedia(c context.Context, iri *url.URL, expectedContentType string) ([]byte, error) { + l := t.log.WithField("func", "DereferenceMedia") + l.Debugf("performing GET to %s", iri.String()) req, err := http.NewRequest("GET", iri.String(), nil) if err != nil { return nil, err diff --git a/internal/typeutils/astointernal.go b/internal/typeutils/astointernal.go index 4aa6e2b..bf1ef7f 100644 --- a/internal/typeutils/astointernal.go +++ b/internal/typeutils/astointernal.go @@ -37,7 +37,7 @@ func (c *converter) ASRepresentationToAccount(accountable Accountable) (*gtsmode uri := uriProp.GetIRI() acct := >smodel.Account{} - err := c.db.GetWhere("uri", uri.String(), acct) + err := c.db.GetWhere([]db.Where{{Key: "uri", Value: uri.String()}}, acct) if err == nil { // we already know this account so we can skip generating it return acct, nil @@ -90,7 +90,7 @@ func (c *converter) ASRepresentationToAccount(accountable Accountable) (*gtsmode } // check for bot and actor type - switch gtsmodel.ActivityStreamsActor(accountable.GetTypeName()) { + switch accountable.GetTypeName() { case gtsmodel.ActivityStreamsPerson, gtsmodel.ActivityStreamsGroup, gtsmodel.ActivityStreamsOrganization: // people, groups, and organizations aren't bots acct.Bot = false @@ -101,7 +101,7 @@ func (c *converter) ASRepresentationToAccount(accountable Accountable) (*gtsmode // we don't know what this is! return nil, fmt.Errorf("type name %s not recognised or not convertible to gtsmodel.ActivityStreamsActor", accountable.GetTypeName()) } - acct.ActorType = gtsmodel.ActivityStreamsActor(accountable.GetTypeName()) + acct.ActorType = accountable.GetTypeName() // TODO: locked aka manuallyApprovesFollowers @@ -220,7 +220,7 @@ func (c *converter) ASStatusToStatus(statusable Statusable) (*gtsmodel.Status, e status.APStatusOwnerURI = attributedTo.String() statusOwner := >smodel.Account{} - if err := c.db.GetWhere("uri", attributedTo.String(), statusOwner); err != nil { + if err := c.db.GetWhere([]db.Where{{Key: "uri", Value: attributedTo.String()}}, statusOwner); err != nil { return nil, fmt.Errorf("couldn't get status owner from db: %s", err) } status.AccountID = statusOwner.ID @@ -235,7 +235,7 @@ func (c *converter) ASStatusToStatus(statusable Statusable) (*gtsmodel.Status, e // now we can check if we have the replied-to status in our db already inReplyToStatus := >smodel.Status{} - if err := c.db.GetWhere("uri", inReplyToURI.String(), inReplyToStatus); err == nil { + if err := c.db.GetWhere([]db.Where{{Key: "uri", Value: inReplyToURI.String()}}, inReplyToStatus); err == nil { // we have the status in our database already // so we can set these fields here and then... status.InReplyToID = inReplyToStatus.ID @@ -281,7 +281,10 @@ func (c *converter) ASStatusToStatus(statusable Statusable) (*gtsmodel.Status, e // if it's CC'ed to public, it's public or unlocked // mentioned SPECIFIC ACCOUNTS also get added to CC'es if it's not a direct message - if isPublic(cc) || isPublic(to) { + if isPublic(cc) { + visibility = gtsmodel.VisibilityUnlocked + } + if isPublic(to) { visibility = gtsmodel.VisibilityPublic } @@ -301,7 +304,7 @@ func (c *converter) ASStatusToStatus(statusable Statusable) (*gtsmodel.Status, e // we might be able to extract this from the contentMap field // ActivityStreamsType - status.ActivityStreamsType = gtsmodel.ActivityStreamsObject(statusable.GetTypeName()) + status.ActivityStreamsType = statusable.GetTypeName() return status, nil } @@ -319,7 +322,7 @@ func (c *converter) ASFollowToFollowRequest(followable Followable) (*gtsmodel.Fo return nil, errors.New("error extracting actor property from follow") } originAccount := >smodel.Account{} - if err := c.db.GetWhere("uri", origin.String(), originAccount); err != nil { + if err := c.db.GetWhere([]db.Where{{Key: "uri", Value: origin.String()}}, originAccount); err != nil { return nil, fmt.Errorf("error extracting account with uri %s from the database: %s", origin.String(), err) } @@ -328,7 +331,7 @@ func (c *converter) ASFollowToFollowRequest(followable Followable) (*gtsmodel.Fo return nil, errors.New("error extracting object property from follow") } targetAccount := >smodel.Account{} - if err := c.db.GetWhere("uri", target.String(), targetAccount); err != nil { + if err := c.db.GetWhere([]db.Where{{Key: "uri", Value: target.String()}}, targetAccount); err != nil { return nil, fmt.Errorf("error extracting account with uri %s from the database: %s", origin.String(), err) } @@ -341,6 +344,40 @@ func (c *converter) ASFollowToFollowRequest(followable Followable) (*gtsmodel.Fo return followRequest, nil } +func (c *converter) ASFollowToFollow(followable Followable) (*gtsmodel.Follow, error) { + idProp := followable.GetJSONLDId() + if idProp == nil || !idProp.IsIRI() { + return nil, errors.New("no id property set on follow, or was not an iri") + } + uri := idProp.GetIRI().String() + + origin, err := extractActor(followable) + if err != nil { + return nil, errors.New("error extracting actor property from follow") + } + originAccount := >smodel.Account{} + if err := c.db.GetWhere([]db.Where{{Key: "uri", Value: origin.String()}}, originAccount); err != nil { + return nil, fmt.Errorf("error extracting account with uri %s from the database: %s", origin.String(), err) + } + + target, err := extractObject(followable) + if err != nil { + return nil, errors.New("error extracting object property from follow") + } + targetAccount := >smodel.Account{} + if err := c.db.GetWhere([]db.Where{{Key: "uri", Value: target.String()}}, targetAccount); err != nil { + return nil, fmt.Errorf("error extracting account with uri %s from the database: %s", origin.String(), err) + } + + follow := >smodel.Follow{ + URI: uri, + AccountID: originAccount.ID, + TargetAccountID: targetAccount.ID, + } + + return follow, nil +} + func isPublic(tos []*url.URL) bool { for _, entry := range tos { if strings.EqualFold(entry.String(), "https://www.w3.org/ns/activitystreams#Public") { diff --git a/internal/typeutils/converter.go b/internal/typeutils/converter.go index 8f310c9..ac2ce43 100644 --- a/internal/typeutils/converter.go +++ b/internal/typeutils/converter.go @@ -26,6 +26,10 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" ) +const ( + asPublicURI = "https://www.w3.org/ns/activitystreams#Public" +) + // TypeConverter is an interface for the common action of converting between apimodule (frontend, serializable) models, // internal gts models used in the database, and activitypub models used in federation. // @@ -77,6 +81,9 @@ type TypeConverter interface { // InstanceToMasto converts a gts instance into its mastodon equivalent for serving at /api/v1/instance InstanceToMasto(i *gtsmodel.Instance) (*model.Instance, error) + // RelationshipToMasto converts a gts relationship into its mastodon equivalent for serving in various places + RelationshipToMasto(r *gtsmodel.Relationship) (*model.Relationship, error) + /* FRONTEND (mastodon) MODEL TO INTERNAL (gts) MODEL */ @@ -94,6 +101,8 @@ type TypeConverter interface { ASStatusToStatus(statusable Statusable) (*gtsmodel.Status, error) // ASFollowToFollowRequest converts a remote activitystreams `follow` representation into gts model follow request. 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 @@ -104,6 +113,15 @@ 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) + + // MentionToAS converts a gts model mention into an activity streams Mention, suitable for federation + MentionToAS(m *gtsmodel.Mention) (vocab.ActivityStreamsMention, error) + + // AttachmentToAS converts a gts model media attachment into an activity streams Attachment, suitable for federation + AttachmentToAS(a *gtsmodel.MediaAttachment) (vocab.ActivityStreamsDocument, error) } type converter struct { diff --git a/internal/typeutils/internaltoas.go b/internal/typeutils/internaltoas.go index 0216dea..072c4e6 100644 --- a/internal/typeutils/internaltoas.go +++ b/internal/typeutils/internaltoas.go @@ -21,10 +21,12 @@ package typeutils import ( "crypto/x509" "encoding/pem" + "fmt" "net/url" "github.com/go-fed/activity/streams" "github.com/go-fed/activity/streams/vocab" + "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" ) @@ -256,5 +258,304 @@ func (c *converter) AccountToAS(a *gtsmodel.Account) (vocab.ActivityStreamsPerso } func (c *converter) StatusToAS(s *gtsmodel.Status) (vocab.ActivityStreamsNote, error) { - return nil, nil + // ensure prerequisites here before we get stuck in + + // check if author account is already attached to status and attach it if not + // if we can't retrieve this, bail here already because we can't attribute the status to anyone + if s.GTSAccount == nil { + a := >smodel.Account{} + if err := c.db.GetByID(s.AccountID, a); err != nil { + return nil, fmt.Errorf("StatusToAS: error retrieving author account from db: %s", err) + } + s.GTSAccount = a + } + + // create the Note! + status := streams.NewActivityStreamsNote() + + // id + statusURI, err := url.Parse(s.URI) + if err != nil { + return nil, fmt.Errorf("StatusToAS: error parsing url %s: %s", s.URI, err) + } + statusIDProp := streams.NewJSONLDIdProperty() + statusIDProp.SetIRI(statusURI) + status.SetJSONLDId(statusIDProp) + + // type + // will be set automatically by go-fed + + // summary aka cw + statusSummaryProp := streams.NewActivityStreamsSummaryProperty() + statusSummaryProp.AppendXMLSchemaString(s.ContentWarning) + status.SetActivityStreamsSummary(statusSummaryProp) + + // inReplyTo + if s.InReplyToID != "" { + // fetch the replied status if we don't have it on hand already + if s.GTSReplyToStatus == nil { + rs := >smodel.Status{} + if err := c.db.GetByID(s.InReplyToID, rs); err != nil { + return nil, fmt.Errorf("StatusToAS: error retrieving replied-to status from db: %s", err) + } + s.GTSReplyToStatus = rs + } + rURI, err := url.Parse(s.GTSReplyToStatus.URI) + if err != nil { + return nil, fmt.Errorf("StatusToAS: error parsing url %s: %s", s.GTSReplyToStatus.URI, err) + } + + inReplyToProp := streams.NewActivityStreamsInReplyToProperty() + inReplyToProp.AppendIRI(rURI) + status.SetActivityStreamsInReplyTo(inReplyToProp) + } + + // published + publishedProp := streams.NewActivityStreamsPublishedProperty() + publishedProp.Set(s.CreatedAt) + status.SetActivityStreamsPublished(publishedProp) + + // url + if s.URL != "" { + sURL, err := url.Parse(s.URL) + if err != nil { + return nil, fmt.Errorf("StatusToAS: error parsing url %s: %s", s.URL, err) + } + + urlProp := streams.NewActivityStreamsUrlProperty() + urlProp.AppendIRI(sURL) + status.SetActivityStreamsUrl(urlProp) + } + + // attributedTo + authorAccountURI, err := url.Parse(s.GTSAccount.URI) + if err != nil { + return nil, fmt.Errorf("StatusToAS: error parsing url %s: %s", s.GTSAccount.URI, err) + } + attributedToProp := streams.NewActivityStreamsAttributedToProperty() + attributedToProp.AppendIRI(authorAccountURI) + status.SetActivityStreamsAttributedTo(attributedToProp) + + // tags + tagProp := streams.NewActivityStreamsTagProperty() + + // tag -- mentions + for _, m := range s.GTSMentions { + asMention, err := c.MentionToAS(m) + if err != nil { + return nil, fmt.Errorf("StatusToAS: error converting mention to AS mention: %s", err) + } + tagProp.AppendActivityStreamsMention(asMention) + } + + // tag -- emojis + // TODO + + // tag -- hashtags + // TODO + + status.SetActivityStreamsTag(tagProp) + + // parse out some URIs we need here + authorFollowersURI, err := url.Parse(s.GTSAccount.FollowersURI) + if err != nil { + return nil, fmt.Errorf("StatusToAS: error parsing url %s: %s", s.GTSAccount.FollowersURI, err) + } + + publicURI, err := url.Parse(asPublicURI) + if err != nil { + return nil, fmt.Errorf("StatusToAS: error parsing url %s: %s", asPublicURI, err) + } + + // to and cc + toProp := streams.NewActivityStreamsToProperty() + ccProp := streams.NewActivityStreamsCcProperty() + switch s.Visibility { + case gtsmodel.VisibilityDirect: + // if DIRECT, then only mentioned users should be added to TO, and nothing to CC + for _, m := range s.GTSMentions { + iri, err := url.Parse(m.GTSAccount.URI) + if err != nil { + return nil, fmt.Errorf("StatusToAS: error parsing uri %s: %s", m.GTSAccount.URI, err) + } + toProp.AppendIRI(iri) + } + case gtsmodel.VisibilityMutualsOnly: + // TODO + case gtsmodel.VisibilityFollowersOnly: + // if FOLLOWERS ONLY then we want to add followers to TO, and mentions to CC + toProp.AppendIRI(authorFollowersURI) + for _, m := range s.GTSMentions { + iri, err := url.Parse(m.GTSAccount.URI) + if err != nil { + return nil, fmt.Errorf("StatusToAS: error parsing uri %s: %s", m.GTSAccount.URI, err) + } + ccProp.AppendIRI(iri) + } + case gtsmodel.VisibilityUnlocked: + // if UNLOCKED, we want to add followers to TO, and public and mentions to CC + toProp.AppendIRI(authorFollowersURI) + ccProp.AppendIRI(publicURI) + for _, m := range s.GTSMentions { + iri, err := url.Parse(m.GTSAccount.URI) + if err != nil { + return nil, fmt.Errorf("StatusToAS: error parsing uri %s: %s", m.GTSAccount.URI, err) + } + ccProp.AppendIRI(iri) + } + case gtsmodel.VisibilityPublic: + // if PUBLIC, we want to add public to TO, and followers and mentions to CC + toProp.AppendIRI(publicURI) + ccProp.AppendIRI(authorFollowersURI) + for _, m := range s.GTSMentions { + iri, err := url.Parse(m.GTSAccount.URI) + if err != nil { + return nil, fmt.Errorf("StatusToAS: error parsing uri %s: %s", m.GTSAccount.URI, err) + } + ccProp.AppendIRI(iri) + } + } + status.SetActivityStreamsTo(toProp) + status.SetActivityStreamsCc(ccProp) + + // conversation + // TODO + + // content -- the actual post itself + contentProp := streams.NewActivityStreamsContentProperty() + contentProp.AppendXMLSchemaString(s.Content) + status.SetActivityStreamsContent(contentProp) + + // attachment + attachmentProp := streams.NewActivityStreamsAttachmentProperty() + for _, a := range s.GTSMediaAttachments { + doc, err := c.AttachmentToAS(a) + if err != nil { + return nil, fmt.Errorf("StatusToAS: error converting attachment: %s", err) + } + attachmentProp.AppendActivityStreamsDocument(doc) + } + status.SetActivityStreamsAttachment(attachmentProp) + + // replies + // TODO + + return status, 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 follow 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 +} + +func (c *converter) MentionToAS(m *gtsmodel.Mention) (vocab.ActivityStreamsMention, error) { + if m.GTSAccount == nil { + a := >smodel.Account{} + if err := c.db.GetWhere([]db.Where{{Key: "target_account_id", Value: m.TargetAccountID}}, a); err != nil { + return nil, fmt.Errorf("MentionToAS: error getting target account from db: %s", err) + } + m.GTSAccount = a + } + + // create the mention + mention := streams.NewActivityStreamsMention() + + // href -- this should be the URI of the mentioned user + hrefProp := streams.NewActivityStreamsHrefProperty() + hrefURI, err := url.Parse(m.GTSAccount.URI) + if err != nil { + return nil, fmt.Errorf("MentionToAS: error parsing uri %s: %s", m.GTSAccount.URI, err) + } + hrefProp.SetIRI(hrefURI) + mention.SetActivityStreamsHref(hrefProp) + + // name -- this should be the namestring of the mentioned user, something like @whatever@example.org + var domain string + if m.GTSAccount.Domain == "" { + domain = c.config.Host + } else { + domain = m.GTSAccount.Domain + } + username := m.GTSAccount.Username + nameString := fmt.Sprintf("@%s@%s", username, domain) + nameProp := streams.NewActivityStreamsNameProperty() + nameProp.AppendXMLSchemaString(nameString) + mention.SetActivityStreamsName(nameProp) + + return mention, nil +} + +func (c *converter) AttachmentToAS(a *gtsmodel.MediaAttachment) (vocab.ActivityStreamsDocument, error) { + // type -- Document + doc := streams.NewActivityStreamsDocument() + + // mediaType aka mime content type + mediaTypeProp := streams.NewActivityStreamsMediaTypeProperty() + mediaTypeProp.Set(a.File.ContentType) + doc.SetActivityStreamsMediaType(mediaTypeProp) + + // url -- for the original image not the thumbnail + urlProp := streams.NewActivityStreamsUrlProperty() + imageURL, err := url.Parse(a.URL) + if err != nil { + return nil, fmt.Errorf("AttachmentToAS: error parsing uri %s: %s", a.URL, err) + } + urlProp.AppendIRI(imageURL) + doc.SetActivityStreamsUrl(urlProp) + + // name -- aka image description + nameProp := streams.NewActivityStreamsNameProperty() + nameProp.AppendXMLSchemaString(a.Description) + doc.SetActivityStreamsName(nameProp) + + // blurhash + blurProp := streams.NewTootBlurhashProperty() + blurProp.Set(a.Blurhash) + doc.SetTootBlurhash(blurProp) + + // focalpoint + // TODO + + return doc, nil } diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index e4ccab9..70f8a8d 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -572,3 +572,21 @@ func (c *converter) InstanceToMasto(i *gtsmodel.Instance) (*model.Instance, erro return mi, nil } + +func (c *converter) RelationshipToMasto(r *gtsmodel.Relationship) (*model.Relationship, error) { + return &model.Relationship{ + ID: r.ID, + Following: r.Following, + ShowingReblogs: r.ShowingReblogs, + Notifying: r.Notifying, + FollowedBy: r.FollowedBy, + Blocking: r.Blocking, + BlockedBy: r.BlockedBy, + Muting: r.Muting, + MutingNotifications: r.MutingNotifications, + Requested: r.Requested, + DomainBlocking: r.DomainBlocking, + Endorsed: r.Endorsed, + Note: r.Note, + }, nil +} diff --git a/internal/util/uri.go b/internal/util/uri.go index edcfc5c..cee9dcb 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 diff --git a/testrig/actions.go b/testrig/actions.go index 7ed75b1..aa78799 100644 --- a/testrig/actions.go +++ b/testrig/actions.go @@ -48,6 +48,7 @@ import ( var Run action.GTSAction = func(ctx context.Context, _ *config.Config, log *logrus.Logger) error { c := NewTestConfig() dbService := NewTestDB() + federatingDB := NewTestFederatingDB(dbService) router := NewTestRouter() storageBackend := NewTestStorage() @@ -59,7 +60,7 @@ var Run action.GTSAction = func(ctx context.Context, _ *config.Config, log *logr Body: r, }, nil })) - federator := federation.NewFederator(dbService, transportController, c, log, typeConverter) + federator := federation.NewFederator(dbService, federatingDB, transportController, c, log, typeConverter) processor := NewTestProcessor(dbService, storageBackend, federator) if err := processor.Start(); err != nil { return fmt.Errorf("error starting processor: %s", err) diff --git a/testrig/federatingdb.go b/testrig/federatingdb.go new file mode 100644 index 0000000..5cce247 --- /dev/null +++ b/testrig/federatingdb.go @@ -0,0 +1,11 @@ +package testrig + +import ( + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/federation" +) + +// NewTestFederatingDB returns a federating DB with the underlying db +func NewTestFederatingDB(db db.DB) federation.FederatingDB { + return federation.NewFederatingDB(db, NewTestConfig(), NewTestLog()) +} diff --git a/testrig/federator.go b/testrig/federator.go index c2d86fd..e113c43 100644 --- a/testrig/federator.go +++ b/testrig/federator.go @@ -26,5 +26,5 @@ import ( // NewTestFederator returns a federator with the given database and (mock!!) transport controller. func NewTestFederator(db db.DB, tc transport.Controller) federation.Federator { - return federation.NewFederator(db, tc, NewTestConfig(), NewTestLog(), NewTestTypeConverter(db)) + return federation.NewFederator(db, NewTestFederatingDB(db), tc, NewTestConfig(), NewTestLog(), NewTestTypeConverter(db)) }