From cc48294c31a76e94fa879ad0d8d5dbd7e94c651b Mon Sep 17 00:00:00 2001 From: Tobi Smethurst <31960611+tsmethurst@users.noreply.github.com> Date: Sat, 15 May 2021 11:58:11 +0200 Subject: Inbox post (#22) Inbox POST from federated servers now working for statuses and follow requests. Follow request client API added. Start work on federating outgoing messages. Other fixes and changes/tidying up. --- internal/api/client/auth/auth_test.go | 3 +- internal/api/client/auth/middleware.go | 3 +- internal/api/client/auth/token.go | 30 + internal/api/client/followrequest/accept.go | 57 + internal/api/client/followrequest/deny.go | 27 + internal/api/client/followrequest/followrequest.go | 68 ++ internal/api/client/followrequest/get.go | 51 + internal/api/client/instance/instance.go | 2 +- internal/api/client/instance/instanceget.go | 1 + internal/api/client/media/media.go | 2 + internal/api/client/media/mediacreate.go | 20 +- internal/api/client/media/mediaget.go | 2 +- internal/api/model/application.go | 8 +- internal/api/model/attachment.go | 12 +- internal/api/model/status.go | 4 + internal/api/s2s/user/inboxpost.go | 58 + internal/api/s2s/user/user.go | 3 + internal/api/s2s/webfinger/webfinger.go | 2 +- internal/api/security/extraheaders.go | 8 + internal/api/security/security.go | 1 + internal/db/db.go | 14 +- internal/db/federating_db.go | 359 ------- internal/db/federating_db_test.go | 21 - internal/db/pg.go | 1089 ------------------- internal/db/pg/pg.go | 1127 ++++++++++++++++++++ internal/db/pg_test.go | 21 - internal/federation/clock.go | 1 + internal/federation/commonbehavior.go | 6 +- internal/federation/federating_db.go | 599 +++++++++++ internal/federation/federating_db_test.go | 21 + internal/federation/federatingactor.go | 14 + internal/federation/federatingprotocol.go | 154 ++- internal/gotosocial/actions.go | 7 +- internal/gtsmodel/account.go | 6 +- internal/gtsmodel/mention.go | 16 +- internal/gtsmodel/status.go | 18 +- internal/gtsmodel/tag.go | 2 + internal/media/media_test.go | 3 +- internal/message/fediprocess.go | 8 + internal/message/frprocess.go | 42 + internal/message/processor.go | 75 ++ internal/message/processorutil.go | 6 +- internal/message/statusprocess.go | 7 + internal/oauth/clientstore_test.go | 3 +- internal/router/router.go | 15 +- internal/transport/controller.go | 8 +- internal/typeutils/accountable.go | 101 -- internal/typeutils/asextractionutil.go | 367 ++++++- internal/typeutils/asinterfaces.go | 237 ++++ internal/typeutils/astointernal.go | 201 ++++ internal/typeutils/astointernal_test.go | 233 ++++ internal/typeutils/converter.go | 4 + internal/typeutils/internaltoas.go | 16 +- internal/util/regexes.go | 5 + internal/util/statustools.go | 28 +- internal/util/statustools_test.go | 8 +- internal/util/uri.go | 6 + 57 files changed, 3516 insertions(+), 1694 deletions(-) create mode 100644 internal/api/client/followrequest/accept.go create mode 100644 internal/api/client/followrequest/deny.go create mode 100644 internal/api/client/followrequest/followrequest.go create mode 100644 internal/api/client/followrequest/get.go create mode 100644 internal/api/s2s/user/inboxpost.go create mode 100644 internal/api/security/extraheaders.go delete mode 100644 internal/db/federating_db.go delete mode 100644 internal/db/federating_db_test.go delete mode 100644 internal/db/pg.go create mode 100644 internal/db/pg/pg.go delete mode 100644 internal/db/pg_test.go create mode 100644 internal/federation/federating_db.go create mode 100644 internal/federation/federating_db_test.go create mode 100644 internal/message/frprocess.go delete mode 100644 internal/typeutils/accountable.go create mode 100644 internal/typeutils/asinterfaces.go (limited to 'internal') diff --git a/internal/api/client/auth/auth_test.go b/internal/api/client/auth/auth_test.go index 7ec788a0e..48d2a2508 100644 --- a/internal/api/client/auth/auth_test.go +++ b/internal/api/client/auth/auth_test.go @@ -28,6 +28,7 @@ import ( "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/db/pg" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/oauth" "golang.org/x/crypto/bcrypt" @@ -103,7 +104,7 @@ func (suite *AuthTestSuite) SetupTest() { log := logrus.New() log.SetLevel(logrus.TraceLevel) - db, err := db.NewPostgresService(context.Background(), suite.config, log) + db, err := pg.NewPostgresService(context.Background(), suite.config, log) if err != nil { logrus.Panicf("error creating database connection: %s", err) } diff --git a/internal/api/client/auth/middleware.go b/internal/api/client/auth/middleware.go index 8313b7c28..2a63cbdb6 100644 --- a/internal/api/client/auth/middleware.go +++ b/internal/api/client/auth/middleware.go @@ -33,7 +33,7 @@ func (m *Module) OauthTokenMiddleware(c *gin.Context) { l := m.log.WithField("func", "OauthTokenMiddleware") l.Trace("entering OauthTokenMiddleware") - ti, err := m.server.ValidationBearerToken(c.Request) + ti, err := m.server.ValidationBearerToken(c.Copy().Request) if err != nil { l.Tracef("could not validate token: %s", err) return @@ -74,4 +74,5 @@ func (m *Module) OauthTokenMiddleware(c *gin.Context) { c.Set(oauth.SessionAuthorizedApplication, app) l.Tracef("set gin context %s to %+v", oauth.SessionAuthorizedApplication, app) } + c.Next() } diff --git a/internal/api/client/auth/token.go b/internal/api/client/auth/token.go index c531a3009..798a88d19 100644 --- a/internal/api/client/auth/token.go +++ b/internal/api/client/auth/token.go @@ -20,16 +20,46 @@ package auth import ( "net/http" + "net/url" "github.com/gin-gonic/gin" ) +type tokenBody struct { + ClientID *string `form:"client_id" json:"client_id" xml:"client_id"` + ClientSecret *string `form:"client_secret" json:"client_secret" xml:"client_secret"` + Code *string `form:"code" json:"code" xml:"code"` + GrantType *string `form:"grant_type" json:"grant_type" xml:"grant_type"` + RedirectURI *string `form:"redirect_uri" json:"redirect_uri" xml:"redirect_uri"` +} + // TokenPOSTHandler should be served as a POST at https://example.org/oauth/token // The idea here is to serve an oauth access token to a user, which can be used for authorizing against non-public APIs. // See https://docs.joinmastodon.org/methods/apps/oauth/#obtain-a-token func (m *Module) TokenPOSTHandler(c *gin.Context) { l := m.log.WithField("func", "TokenPOSTHandler") l.Trace("entered TokenPOSTHandler") + + form := &tokenBody{} + if err := c.ShouldBind(form); err == nil { + c.Request.Form = url.Values{} + if form.ClientID != nil { + c.Request.Form.Set("client_id", *form.ClientID) + } + if form.ClientSecret != nil { + c.Request.Form.Set("client_secret", *form.ClientSecret) + } + if form.Code != nil { + c.Request.Form.Set("code", *form.Code) + } + if form.GrantType != nil { + c.Request.Form.Set("grant_type", *form.GrantType) + } + if form.RedirectURI != nil { + c.Request.Form.Set("redirect_uri", *form.RedirectURI) + } + } + if err := m.server.HandleTokenRequest(c.Writer, c.Request); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } diff --git a/internal/api/client/followrequest/accept.go b/internal/api/client/followrequest/accept.go new file mode 100644 index 000000000..45dc1a2af --- /dev/null +++ b/internal/api/client/followrequest/accept.go @@ -0,0 +1,57 @@ +/* + 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 followrequest + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// FollowRequestAcceptPOSTHandler deals with follow request accepting. It should be served at +// /api/v1/follow_requests/:id/authorize +func (m *Module) FollowRequestAcceptPOSTHandler(c *gin.Context) { + l := m.log.WithField("func", "statusCreatePOSTHandler") + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + l.Debugf("couldn't auth: %s", err) + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + + if authed.User.Disabled || !authed.User.Approved || !authed.Account.SuspendedAt.IsZero() { + l.Debugf("couldn't auth: %s", err) + c.JSON(http.StatusForbidden, gin.H{"error": "account is disabled, not yet approved, or suspended"}) + return + } + + originAccountID := c.Param(IDKey) + if originAccountID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "no follow request origin account id provided"}) + return + } + + if errWithCode := m.processor.FollowRequestAccept(authed, originAccountID); errWithCode != nil { + l.Debug(errWithCode.Error()) + c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + return + } + c.Status(http.StatusOK) +} diff --git a/internal/api/client/followrequest/deny.go b/internal/api/client/followrequest/deny.go new file mode 100644 index 000000000..c1a9e4dbf --- /dev/null +++ b/internal/api/client/followrequest/deny.go @@ -0,0 +1,27 @@ +/* + 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 followrequest + +import "github.com/gin-gonic/gin" + +// FollowRequestDenyPOSTHandler deals with follow request rejection. It should be served at +// /api/v1/follow_requests/:id/reject +func (m *Module) FollowRequestDenyPOSTHandler(c *gin.Context) { + +} diff --git a/internal/api/client/followrequest/followrequest.go b/internal/api/client/followrequest/followrequest.go new file mode 100644 index 000000000..8be957009 --- /dev/null +++ b/internal/api/client/followrequest/followrequest.go @@ -0,0 +1,68 @@ +/* + 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 followrequest + +import ( + "net/http" + + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/message" + "github.com/superseriousbusiness/gotosocial/internal/router" +) + +const ( + // IDKey is for status UUIDs + IDKey = "id" + // BasePath is the base path for serving the follow request API + BasePath = "/api/v1/follow_requests" + // BasePathWithID is just the base path with the ID key in it. + // Use this anywhere you need to know the ID of the follow request being queried. + BasePathWithID = BasePath + "/:" + IDKey + + // AcceptPath is used for accepting follow requests + AcceptPath = BasePathWithID + "/authorize" + // DenyPath is used for denying follow requests + DenyPath = BasePathWithID + "/reject" +) + +// Module implements the ClientAPIModule interface for every related to interacting with follow requests +type Module struct { + config *config.Config + processor message.Processor + log *logrus.Logger +} + +// New returns a new follow request module +func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule { + return &Module{ + config: config, + processor: processor, + log: log, + } +} + +// Route attaches all routes from this module to the given router +func (m *Module) Route(r router.Router) error { + r.AttachHandler(http.MethodGet, BasePath, m.FollowRequestGETHandler) + r.AttachHandler(http.MethodPost, AcceptPath, m.FollowRequestAcceptPOSTHandler) + r.AttachHandler(http.MethodPost, DenyPath, m.FollowRequestDenyPOSTHandler) + return nil +} diff --git a/internal/api/client/followrequest/get.go b/internal/api/client/followrequest/get.go new file mode 100644 index 000000000..3f02ee02a --- /dev/null +++ b/internal/api/client/followrequest/get.go @@ -0,0 +1,51 @@ +/* + 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 followrequest + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// FollowRequestGETHandler allows clients to get a list of their incoming follow requests. +func (m *Module) FollowRequestGETHandler(c *gin.Context) { + l := m.log.WithField("func", "statusCreatePOSTHandler") + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + l.Debugf("couldn't auth: %s", err) + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + + if authed.User.Disabled || !authed.User.Approved || !authed.Account.SuspendedAt.IsZero() { + l.Debugf("couldn't auth: %s", err) + c.JSON(http.StatusForbidden, gin.H{"error": "account is disabled, not yet approved, or suspended"}) + return + } + + accts, errWithCode := m.processor.FollowRequestsGet(authed) + if errWithCode != nil { + c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + return + } + + c.JSON(http.StatusOK, accts) +} diff --git a/internal/api/client/instance/instance.go b/internal/api/client/instance/instance.go index ed7c18718..ba54480a5 100644 --- a/internal/api/client/instance/instance.go +++ b/internal/api/client/instance/instance.go @@ -11,7 +11,7 @@ import ( ) const ( - // InstanceInformationPath + // InstanceInformationPath is for serving instance info requests InstanceInformationPath = "api/v1/instance" ) diff --git a/internal/api/client/instance/instanceget.go b/internal/api/client/instance/instanceget.go index f8e82c096..6ae419a83 100644 --- a/internal/api/client/instance/instanceget.go +++ b/internal/api/client/instance/instanceget.go @@ -6,6 +6,7 @@ import ( "github.com/gin-gonic/gin" ) +// InstanceInformationGETHandler is for serving instance information at /api/v1/instance func (m *Module) InstanceInformationGETHandler(c *gin.Context) { l := m.log.WithField("func", "InstanceInformationGETHandler") diff --git a/internal/api/client/media/media.go b/internal/api/client/media/media.go index e45eec7ea..f68a73c2c 100644 --- a/internal/api/client/media/media.go +++ b/internal/api/client/media/media.go @@ -33,8 +33,10 @@ import ( // BasePath is the base API path for making media requests const BasePath = "/api/v1/media" + // IDKey is the key for media attachment IDs const IDKey = "id" + // BasePathWithID corresponds to a media attachment with the given ID const BasePathWithID = BasePath + "/:" + IDKey diff --git a/internal/api/client/media/mediacreate.go b/internal/api/client/media/mediacreate.go index c0b4a80d7..9f4702b6b 100644 --- a/internal/api/client/media/mediacreate.go +++ b/internal/api/client/media/mediacreate.go @@ -35,30 +35,32 @@ func (m *Module) MediaCreatePOSTHandler(c *gin.Context) { authed, err := oauth.Authed(c, true, true, true, true) // posting new media is serious business so we want *everything* if err != nil { l.Debugf("couldn't auth: %s", err) - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) return } // extract the media create form from the request context l.Tracef("parsing request form: %s", c.Request.Form) - var form model.AttachmentRequest + form := &model.AttachmentRequest{} if err := c.ShouldBind(&form); err != nil { - l.Debugf("could not parse form from request: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"}) + l.Debugf("error parsing form: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Errorf("could not parse form: %s", err)}) return } // Give the fields on the request form a first pass to make sure the request is superficially valid. l.Tracef("validating form %+v", form) - if err := validateCreateMedia(&form, m.config.MediaConfig); err != nil { + if err := validateCreateMedia(form, m.config.MediaConfig); err != nil { l.Debugf("error validating form: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) return } - mastoAttachment, err := m.processor.MediaCreate(authed, &form) + l.Debug("calling processor media create func") + mastoAttachment, err := m.processor.MediaCreate(authed, form) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + l.Debugf("error creating attachment: %s", err) + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) return } @@ -67,7 +69,7 @@ func (m *Module) MediaCreatePOSTHandler(c *gin.Context) { func validateCreateMedia(form *model.AttachmentRequest, config *config.MediaConfig) error { // check there actually is a file attached and it's not size 0 - if form.File == nil || form.File.Size == 0 { + if form.File == nil { return errors.New("no attachment given") } diff --git a/internal/api/client/media/mediaget.go b/internal/api/client/media/mediaget.go index 31c40a5aa..7acb475ce 100644 --- a/internal/api/client/media/mediaget.go +++ b/internal/api/client/media/mediaget.go @@ -43,7 +43,7 @@ func (m *Module) MediaGETHandler(c *gin.Context) { attachment, errWithCode := m.processor.MediaGet(authed, attachmentID) if errWithCode != nil { - c.JSON(errWithCode.Code(),gin.H{"error": errWithCode.Safe()}) + c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) return } diff --git a/internal/api/model/application.go b/internal/api/model/application.go index a796c88ea..fe9fada03 100644 --- a/internal/api/model/application.go +++ b/internal/api/model/application.go @@ -43,13 +43,13 @@ type Application struct { // And here: https://docs.joinmastodon.org/client/token/ type ApplicationCreateRequest struct { // A name for your application - ClientName string `form:"client_name" binding:"required"` + ClientName string `form:"client_name" json:"client_name" xml:"client_name" binding:"required"` // Where the user should be redirected after authorization. // To display the authorization code to the user instead of redirecting // to a web page, use urn:ietf:wg:oauth:2.0:oob in this parameter. - RedirectURIs string `form:"redirect_uris" binding:"required"` + RedirectURIs string `form:"redirect_uris" json:"redirect_uris" xml:"redirect_uris" binding:"required"` // Space separated list of scopes. If none is provided, defaults to read. - Scopes string `form:"scopes"` + Scopes string `form:"scopes" json:"scopes" xml:"scopes"` // A URL to the homepage of your app - Website string `form:"website"` + Website string `form:"website" json:"website" xml:"website"` } diff --git a/internal/api/model/attachment.go b/internal/api/model/attachment.go index c5dbb0cba..ed53757eb 100644 --- a/internal/api/model/attachment.go +++ b/internal/api/model/attachment.go @@ -24,15 +24,15 @@ import "mime/multipart" // See: https://docs.joinmastodon.org/methods/statuses/media/ type AttachmentRequest struct { File *multipart.FileHeader `form:"file" binding:"required"` - Description string `form:"description" json:"description" xml:"description"` - Focus string `form:"focus" json:"focus" xml:"focus"` + Description string `form:"description"` + Focus string `form:"focus"` } -// AttachmentRequest represents the form data parameters submitted by a client during a media update/PUT request. +// AttachmentUpdateRequest represents the form data parameters submitted by a client during a media update/PUT request. // See: https://docs.joinmastodon.org/methods/statuses/media/ type AttachmentUpdateRequest struct { - Description *string `form:"description" json:"description" xml:"description"` - Focus *string `form:"focus" json:"focus" xml:"focus"` + Description *string `form:"description" json:"description" xml:"description"` + Focus *string `form:"focus" json:"focus" xml:"focus"` } // Attachment represents the object returned to a client after a successful media upload request. @@ -63,7 +63,7 @@ type Attachment struct { // See https://docs.joinmastodon.org/methods/statuses/media/#focal-points points for more. Meta MediaMeta `json:"meta,omitempty"` // Alternate text that describes what is in the media attachment, to be used for the visually impaired or when media attachments do not load. - Description string `json:"description"` + Description string `json:"description,omitempty"` // A hash computed by the BlurHash algorithm, for generating colorful preview thumbnails when media has not been downloaded yet. // See https://github.com/woltapp/blurhash Blurhash string `json:"blurhash,omitempty"` diff --git a/internal/api/model/status.go b/internal/api/model/status.go index 54d021e29..2cb22aa0d 100644 --- a/internal/api/model/status.go +++ b/internal/api/model/status.go @@ -119,11 +119,15 @@ const ( VisibilityDirect Visibility = "direct" ) +// AdvancedStatusCreateForm wraps the mastodon status create form along with the GTS advanced +// visibility settings. type AdvancedStatusCreateForm struct { StatusCreateRequest AdvancedVisibilityFlagsForm } +// AdvancedVisibilityFlagsForm allows a few more advanced flags to be set on new statuses, in addition +// to the standard mastodon-compatible ones. type AdvancedVisibilityFlagsForm struct { // The gotosocial visibility model VisibilityAdvanced *string `form:"visibility_advanced"` diff --git a/internal/api/s2s/user/inboxpost.go b/internal/api/s2s/user/inboxpost.go new file mode 100644 index 000000000..60b74ab70 --- /dev/null +++ b/internal/api/s2s/user/inboxpost.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" + "github.com/superseriousbusiness/gotosocial/internal/message" +) + +// InboxPOSTHandler deals with incoming POST requests to an actor's inbox. +// Eg., POST to https://example.org/users/whatever/inbox. +func (m *Module) InboxPOSTHandler(c *gin.Context) { + l := m.log.WithFields(logrus.Fields{ + "func": "InboxPOSTHandler", + "url": c.Request.RequestURI, + }) + + requestedUsername := c.Param(UsernameKey) + if requestedUsername == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "no username specified in request"}) + return + } + + posted, err := m.processor.InboxPost(c.Request.Context(), c.Writer, c.Request) + if err != nil { + if withCode, ok := err.(message.ErrorWithCode); ok { + l.Debug(withCode.Error()) + c.JSON(withCode.Code(), withCode.Safe()) + return + } + l.Debug(err) + c.JSON(http.StatusBadRequest, gin.H{"error": "unable to process request"}) + return + } + + if !posted { + c.JSON(http.StatusBadRequest, gin.H{"error": "unable to process request"}) + } +} diff --git a/internal/api/s2s/user/user.go b/internal/api/s2s/user/user.go index 693fac7c3..a6116247d 100644 --- a/internal/api/s2s/user/user.go +++ b/internal/api/s2s/user/user.go @@ -38,6 +38,8 @@ const ( // Use this anywhere you need to know the username of the user being queried. // Eg https://example.org/users/:username UsersBasePathWithUsername = UsersBasePath + "/:" + UsernameKey + // UsersInboxPath is for serving POST requests to a user's inbox with the given username key. + UsersInboxPath = UsersBasePathWithUsername + "/" + util.InboxPath ) // ActivityPubAcceptHeaders represents the Accept headers mentioned here: @@ -66,5 +68,6 @@ func New(config *config.Config, processor message.Processor, log *logrus.Logger) // Route satisfies the RESTAPIModule interface func (m *Module) Route(s router.Router) error { s.AttachHandler(http.MethodGet, UsersBasePathWithUsername, m.UsersGETHandler) + s.AttachHandler(http.MethodPost, UsersInboxPath, m.InboxPOSTHandler) return nil } diff --git a/internal/api/s2s/webfinger/webfinger.go b/internal/api/s2s/webfinger/webfinger.go index c11d3fb61..168fe1e76 100644 --- a/internal/api/s2s/webfinger/webfinger.go +++ b/internal/api/s2s/webfinger/webfinger.go @@ -29,7 +29,7 @@ import ( ) const ( - // The base path for serving webfinger lookup requests + // WebfingerBasePath is the base path for serving webfinger lookup requests WebfingerBasePath = ".well-known/webfinger" ) diff --git a/internal/api/security/extraheaders.go b/internal/api/security/extraheaders.go new file mode 100644 index 000000000..dfcddfbe1 --- /dev/null +++ b/internal/api/security/extraheaders.go @@ -0,0 +1,8 @@ +package security + +import "github.com/gin-gonic/gin" + +// ExtraHeaders adds any additional required headers to the response +func (m *Module) ExtraHeaders(c *gin.Context) { + c.Header("Server", "Mastodon") +} diff --git a/internal/api/security/security.go b/internal/api/security/security.go index c80b568b3..eaae8471e 100644 --- a/internal/api/security/security.go +++ b/internal/api/security/security.go @@ -42,5 +42,6 @@ func New(config *config.Config, log *logrus.Logger) api.ClientModule { // Route attaches security middleware to the given router func (m *Module) Route(s router.Router) error { s.AttachMiddleware(m.FlocBlock) + s.AttachMiddleware(m.ExtraHeaders) return nil } diff --git a/internal/db/db.go b/internal/db/db.go index b281dd8d7..a354ddee8 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -26,7 +26,10 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" ) -const DBTypePostgres string = "POSTGRES" +const ( + // DBTypePostgres represents an underlying POSTGRES database type. + DBTypePostgres string = "POSTGRES" +) // ErrNoEntries is to be returned from the DB interface when no entries are found for a given query. type ErrNoEntries struct{} @@ -112,6 +115,10 @@ type DB interface { HANDY SHORTCUTS */ + // 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 + // 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'. // This is needed for things like serving files that belong to the instance and not an individual user/account. @@ -148,6 +155,11 @@ type DB interface { // In case of no entries, a 'no entries' error will be returned GetFollowersByAccountID(accountID string, followers *[]gtsmodel.Follow) error + // GetFavesByAccountID is a shortcut for the common action of fetching a list of faves made by the given accountID. + // The given slice 'faves' will be set to the result of the query, whatever it is. + // In case of no entries, a 'no entries' error will be returned + GetFavesByAccountID(accountID string, faves *[]gtsmodel.StatusFave) error + // GetStatusesByAccountID is a shortcut for the common action of fetching a list of statuses produced by accountID. // The given slice 'statuses' will be set to the result of the query, whatever it is. // In case of no entries, a 'no entries' error will be returned diff --git a/internal/db/federating_db.go b/internal/db/federating_db.go deleted file mode 100644 index ab66b19de..000000000 --- a/internal/db/federating_db.go +++ /dev/null @@ -1,359 +0,0 @@ -/* - 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 db - -import ( - "context" - "errors" - "fmt" - "net/url" - "sync" - - "github.com/go-fed/activity/pub" - "github.com/go-fed/activity/streams/vocab" - "github.com/sirupsen/logrus" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/util" -) - -// 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 { - locks *sync.Map - db DB - config *config.Config - log *logrus.Logger -} - -func NewFederatingDB(db DB, config *config.Config, log *logrus.Logger) pub.Database { - return &federatingDB{ - locks: new(sync.Map), - db: db, - config: config, - log: log, - } -} - -/* - GO-FED DB INTERFACE-IMPLEMENTING FUNCTIONS -*/ - -// Lock takes a lock for the object at the specified id. If an error -// is returned, the lock must not have been taken. -// -// The lock must be able to succeed for an id that does not exist in -// the database. This means acquiring the lock does not guarantee the -// entry exists in the database. -// -// Locks are encouraged to be lightweight and in the Go layer, as some -// processes require tight loops acquiring and releasing locks. -// -// Used to ensure race conditions in multiple requests do not occur. -func (f *federatingDB) Lock(c context.Context, id *url.URL) error { - // Before any other Database methods are called, the relevant `id` - // entries are locked to allow for fine-grained concurrency. - - // Strategy: create a new lock, if stored, continue. Otherwise, lock the - // existing mutex. - mu := &sync.Mutex{} - mu.Lock() // Optimistically lock if we do store it. - i, loaded := f.locks.LoadOrStore(id.String(), mu) - if loaded { - mu = i.(*sync.Mutex) - mu.Lock() - } - return nil -} - -// Unlock makes the lock for the object at the specified id available. -// If an error is returned, the lock must have still been freed. -// -// Used to ensure race conditions in multiple requests do not occur. -func (f *federatingDB) Unlock(c context.Context, id *url.URL) error { - // Once Go-Fed is done calling Database methods, the relevant `id` - // entries are unlocked. - - i, ok := f.locks.Load(id.String()) - if !ok { - return errors.New("missing an id in unlock") - } - mu := i.(*sync.Mutex) - mu.Unlock() - return nil -} - -// InboxContains returns true if the OrderedCollection at 'inbox' -// contains the specified 'id'. -// -// The library makes this call only after acquiring a lock first. -func (f *federatingDB) InboxContains(c context.Context, inbox, id *url.URL) (contains bool, err error) { - - if !util.IsInboxPath(inbox) { - return false, fmt.Errorf("%s is not an inbox URI", inbox.String()) - } - - if !util.IsStatusesPath(id) { - return false, fmt.Errorf("%s is not a status URI", id.String()) - } - _, statusID, err := util.ParseStatusesPath(inbox) - if err != nil { - return false, fmt.Errorf("status URI %s was not parseable: %s", id.String(), err) - } - - if err := f.db.GetByID(statusID, >smodel.Status{}); err != nil { - if _, ok := err.(ErrNoEntries); ok { - // we don't have it - return false, nil - } - // actual error - return false, fmt.Errorf("error getting status from db: %s", err) - } - - // we must have it - return true, nil -} - -// GetInbox returns the first ordered collection page of the outbox at -// the specified IRI, for prepending new items. -// -// The library makes this call only after acquiring a lock first. -func (f *federatingDB) GetInbox(c context.Context, inboxIRI *url.URL) (inbox vocab.ActivityStreamsOrderedCollectionPage, err error) { - return nil, nil -} - -// SetInbox saves the inbox value given from GetInbox, with new items -// prepended. Note that the new items must not be added as independent -// database entries. Separate calls to Create will do that. -// -// The library makes this call only after acquiring a lock first. -func (f *federatingDB) SetInbox(c context.Context, inbox vocab.ActivityStreamsOrderedCollectionPage) error { - return nil -} - -// Owns returns true if the IRI belongs to this instance, and if -// the database has an entry for the IRI. -// The library makes this call only after acquiring a lock first. -func (f *federatingDB) Owns(c context.Context, id *url.URL) (bool, error) { - // if the id host isn't this instance host, we don't own this IRI - if id.Host != f.config.Host { - return false, nil - } - - // apparently we own it, so what *is* it? - - // check if it's a status, eg /users/example_username/statuses/SOME_UUID_OF_A_STATUS - if util.IsStatusesPath(id) { - _, uid, err := util.ParseStatusesPath(id) - 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 _, ok := err.(ErrNoEntries); ok { - // there are no entries for this status - return false, nil - } - // an actual error happened - return false, fmt.Errorf("database error fetching status with id %s: %s", uid, err) - } - return true, nil - } - - // check if it's a user, eg /users/example_username - if util.IsUserPath(id) { - username, err := util.ParseUserPath(id) - if err != nil { - return false, fmt.Errorf("error parsing statuses path for url %s: %s", id.String(), err) - } - if err := f.db.GetLocalAccountByUsername(username, >smodel.Account{}); err != nil { - if _, ok := err.(ErrNoEntries); ok { - // there are no entries for this username - return false, nil - } - // an actual error happened - return false, fmt.Errorf("database error fetching account with username %s: %s", username, err) - } - return true, nil - } - - return false, fmt.Errorf("could not match activityID: %s", id.String()) -} - -// ActorForOutbox fetches the actor's IRI for the given outbox IRI. -// -// The library makes this call only after acquiring a lock first. -func (f *federatingDB) ActorForOutbox(c context.Context, outboxIRI *url.URL) (actorIRI *url.URL, err error) { - if !util.IsOutboxPath(outboxIRI) { - 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 _, ok := err.(ErrNoEntries); ok { - return nil, fmt.Errorf("no actor found that corresponds to outbox %s", outboxIRI.String()) - } - return nil, fmt.Errorf("db error searching for actor with outbox %s", outboxIRI.String()) - } - return url.Parse(acct.URI) -} - -// ActorForInbox fetches the actor's IRI for the given outbox IRI. -// -// The library makes this call only after acquiring a lock first. -func (f *federatingDB) ActorForInbox(c context.Context, inboxIRI *url.URL) (actorIRI *url.URL, err error) { - if !util.IsInboxPath(inboxIRI) { - 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 _, ok := err.(ErrNoEntries); ok { - return nil, fmt.Errorf("no actor found that corresponds to inbox %s", inboxIRI.String()) - } - return nil, fmt.Errorf("db error searching for actor with inbox %s", inboxIRI.String()) - } - return url.Parse(acct.URI) -} - -// OutboxForInbox fetches the corresponding actor's outbox IRI for the -// actor's inbox IRI. -// -// The library makes this call only after acquiring a lock first. -func (f *federatingDB) OutboxForInbox(c context.Context, inboxIRI *url.URL) (outboxIRI *url.URL, err error) { - if !util.IsInboxPath(inboxIRI) { - 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 _, ok := err.(ErrNoEntries); ok { - return nil, fmt.Errorf("no actor found that corresponds to inbox %s", inboxIRI.String()) - } - return nil, fmt.Errorf("db error searching for actor with inbox %s", inboxIRI.String()) - } - return url.Parse(acct.OutboxURI) -} - -// Exists returns true if the database has an entry for the specified -// id. It may not be owned by this application instance. -// -// The library makes this call only after acquiring a lock first. -func (f *federatingDB) Exists(c context.Context, id *url.URL) (exists bool, err error) { - return false, nil -} - -// Get returns the database entry for the specified id. -// -// The library makes this call only after acquiring a lock first. -func (f *federatingDB) Get(c context.Context, id *url.URL) (value vocab.Type, err error) { - return nil, nil -} - -// Create adds a new entry to the database which must be able to be -// keyed by its id. -// -// Note that Activity values received from federated peers may also be -// created in the database this way if the Federating Protocol is -// enabled. The client may freely decide to store only the id instead of -// the entire value. -// -// The library makes this call only after acquiring a lock first. -// -// Under certain conditions and network activities, Create may be called -// multiple times for the same ActivityStreams object. -func (f *federatingDB) Create(c context.Context, asType vocab.Type) error { - return nil -} - -// Update sets an existing entry to the database based on the value's -// id. -// -// Note that Activity values received from federated peers may also be -// updated in the database this way if the Federating Protocol is -// enabled. The client may freely decide to store only the id instead of -// 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 { - return nil -} - -// Delete removes the entry with the given id. -// -// Delete is only called for federated objects. Deletes from the Social -// Protocol instead call Update to create a Tombstone. -// -// The library makes this call only after acquiring a lock first. -func (f *federatingDB) Delete(c context.Context, id *url.URL) error { - return nil -} - -// GetOutbox returns the first ordered collection page of the outbox -// at the specified IRI, for prepending new items. -// -// The library makes this call only after acquiring a lock first. -func (f *federatingDB) GetOutbox(c context.Context, outboxIRI *url.URL) (inbox vocab.ActivityStreamsOrderedCollectionPage, err error) { - return nil, nil -} - -// SetOutbox saves the outbox value given from GetOutbox, with new items -// prepended. Note that the new items must not be added as independent -// database entries. Separate calls to Create will do that. -// -// The library makes this call only after acquiring a lock first. -func (f *federatingDB) SetOutbox(c context.Context, outbox vocab.ActivityStreamsOrderedCollectionPage) error { - return nil -} - -// NewID creates a new IRI id for the provided activity or object. The -// implementation does not need to set the 'id' property and simply -// needs to determine the value. -// -// The go-fed library will handle setting the 'id' property on the -// activity or object provided with the value returned. -func (f *federatingDB) NewID(c context.Context, t vocab.Type) (id *url.URL, err error) { - return nil, nil -} - -// Followers obtains the Followers Collection for an actor with the -// given id. -// -// If modified, the library will then call Update. -// -// The library makes this call only after acquiring a lock first. -func (f *federatingDB) Followers(c context.Context, actorIRI *url.URL) (followers vocab.ActivityStreamsCollection, err error) { - return nil, nil -} - -// Following obtains the Following Collection for an actor with the -// given id. -// -// If modified, the library will then call Update. -// -// The library makes this call only after acquiring a lock first. -func (f *federatingDB) Following(c context.Context, actorIRI *url.URL) (followers vocab.ActivityStreamsCollection, err error) { - return nil, nil -} - -// Liked obtains the Liked Collection for an actor with the -// given id. -// -// If modified, the library will then call Update. -// -// The library makes this call only after acquiring a lock first. -func (f *federatingDB) Liked(c context.Context, actorIRI *url.URL) (followers vocab.ActivityStreamsCollection, err error) { - return nil, nil -} diff --git a/internal/db/federating_db_test.go b/internal/db/federating_db_test.go deleted file mode 100644 index 529d2efd0..000000000 --- a/internal/db/federating_db_test.go +++ /dev/null @@ -1,21 +0,0 @@ -/* - 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 db - -// TODO: write tests for pgfed diff --git a/internal/db/pg.go b/internal/db/pg.go deleted file mode 100644 index c0fbcc9e0..000000000 --- a/internal/db/pg.go +++ /dev/null @@ -1,1089 +0,0 @@ -/* - 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 db - -import ( - "context" - "crypto/rand" - "crypto/rsa" - "errors" - "fmt" - "net" - "net/mail" - "regexp" - "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" - "github.com/google/uuid" - "github.com/sirupsen/logrus" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/util" - "golang.org/x/crypto/bcrypt" -) - -// postgresService satisfies the DB interface -type postgresService struct { - 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. -// Under the hood, it uses https://github.com/go-pg/pg to create and maintain a database connection. -func NewPostgresService(ctx context.Context, c *config.Config, log *logrus.Logger) (DB, error) { - opts, err := derivePGOptions(c) - if err != nil { - return nil, fmt.Errorf("could not create postgres service: %s", err) - } - log.Debugf("using pg options: %+v", opts) - - // create a connection - pgCtx, cancel := context.WithCancel(ctx) - conn := pg.Connect(opts).WithContext(pgCtx) - - // this will break the logfmt format we normally log in, - // since we can't choose where pg outputs to and it defaults to - // stdout. So use this option with care! - if log.GetLevel() >= logrus.TraceLevel { - conn.AddQueryHook(pgdebug.DebugHook{ - // Print all queries. - Verbose: true, - }) - } - - // actually *begin* the connection so that we can tell if the db is there and listening - if err := conn.Ping(ctx); err != nil { - cancel() - return nil, fmt.Errorf("db connection error: %s", err) - } - - // print out discovered postgres version - var version string - if _, err = conn.QueryOneContext(ctx, pg.Scan(&version), "SELECT version()"); err != nil { - cancel() - return nil, fmt.Errorf("db connection error: %s", err) - } - log.Infof("connected to postgres version: %s", version) - - ps := &postgresService{ - config: c, - conn: conn, - log: log, - cancel: cancel, - } - - federatingDB := NewFederatingDB(ps, c, log) - ps.federationDB = federatingDB - - // we can confidently return this useable postgres service now - return ps, nil -} - -/* - HANDY STUFF -*/ - -// derivePGOptions takes an application config and returns either a ready-to-use *pg.Options -// with sensible defaults, or an error if it's not satisfied by the provided config. -func derivePGOptions(c *config.Config) (*pg.Options, error) { - if strings.ToUpper(c.DBConfig.Type) != DBTypePostgres { - return nil, fmt.Errorf("expected db type of %s but got %s", DBTypePostgres, c.DBConfig.Type) - } - - // validate port - if c.DBConfig.Port == 0 { - return nil, errors.New("no port set") - } - - // validate address - if c.DBConfig.Address == "" { - return nil, errors.New("no address set") - } - - ipv4Regex := regexp.MustCompile(`^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$`) - hostnameRegex := regexp.MustCompile(`^(?:[a-z0-9]+(?:-[a-z0-9]+)*\.)+[a-z]{2,}$`) - if !hostnameRegex.MatchString(c.DBConfig.Address) && !ipv4Regex.MatchString(c.DBConfig.Address) && c.DBConfig.Address != "localhost" { - return nil, fmt.Errorf("address %s was neither an ipv4 address nor a valid hostname", c.DBConfig.Address) - } - - // validate username - if c.DBConfig.User == "" { - return nil, errors.New("no user set") - } - - // validate that there's a password - if c.DBConfig.Password == "" { - return nil, errors.New("no password set") - } - - // validate database - if c.DBConfig.Database == "" { - return nil, errors.New("no database set") - } - - // We can rely on the pg library we're using to set - // sensible defaults for everything we don't set here. - options := &pg.Options{ - Addr: fmt.Sprintf("%s:%d", c.DBConfig.Address, c.DBConfig.Port), - User: c.DBConfig.User, - Password: c.DBConfig.Password, - Database: c.DBConfig.Database, - ApplicationName: c.ApplicationName, - } - - return options, nil -} - -/* - FEDERATION FUNCTIONALITY -*/ - -func (ps *postgresService) Federation() pub.Database { - return ps.federationDB -} - -/* - BASIC DB FUNCTIONALITY -*/ - -func (ps *postgresService) CreateTable(i interface{}) error { - return ps.conn.Model(i).CreateTable(&orm.CreateTableOptions{ - IfNotExists: true, - }) -} - -func (ps *postgresService) DropTable(i interface{}) error { - return ps.conn.Model(i).DropTable(&orm.DropTableOptions{ - IfExists: true, - }) -} - -func (ps *postgresService) Stop(ctx context.Context) error { - ps.log.Info("closing db connection") - if err := ps.conn.Close(); err != nil { - // only cancel if there's a problem closing the db - ps.cancel() - return err - } - return nil -} - -func (ps *postgresService) IsHealthy(ctx context.Context) error { - return ps.conn.Ping(ctx) -} - -func (ps *postgresService) CreateSchema(ctx context.Context) error { - models := []interface{}{ - (*gtsmodel.Account)(nil), - (*gtsmodel.Status)(nil), - (*gtsmodel.User)(nil), - } - ps.log.Info("creating db schema") - - for _, model := range models { - err := ps.conn.Model(model).CreateTable(&orm.CreateTableOptions{ - IfNotExists: true, - }) - if err != nil { - return err - } - } - - ps.log.Info("db schema created") - return nil -} - -func (ps *postgresService) GetByID(id string, i interface{}) error { - if err := ps.conn.Model(i).Where("id = ?", id).Select(); err != nil { - if err == pg.ErrNoRows { - return ErrNoEntries{} - } - return err - - } - 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 { - if err == pg.ErrNoRows { - return ErrNoEntries{} - } - return err - } - return nil -} - -// func (ps *postgresService) GetWhereMany(i interface{}, where ...model.Where) error { -// return nil -// } - -func (ps *postgresService) GetAll(i interface{}) error { - if err := ps.conn.Model(i).Select(); err != nil { - if err == pg.ErrNoRows { - return ErrNoEntries{} - } - return err - } - return nil -} - -func (ps *postgresService) Put(i interface{}) error { - _, err := ps.conn.Model(i).Insert(i) - return err -} - -func (ps *postgresService) Upsert(i interface{}, conflictColumn string) error { - if _, err := ps.conn.Model(i).OnConflict(fmt.Sprintf("(%s) DO UPDATE", conflictColumn)).Insert(); err != nil { - if err == pg.ErrNoRows { - return ErrNoEntries{} - } - return err - } - return nil -} - -func (ps *postgresService) UpdateByID(id string, i interface{}) error { - if _, err := ps.conn.Model(i).Where("id = ?", id).OnConflict("(id) DO UPDATE").Insert(); err != nil { - if err == pg.ErrNoRows { - return ErrNoEntries{} - } - return err - } - return nil -} - -func (ps *postgresService) UpdateOneByID(id string, key string, value interface{}, i interface{}) error { - _, err := ps.conn.Model(i).Set("? = ?", pg.Safe(key), value).Where("id = ?", id).Update() - return err -} - -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 ErrNoEntries{} - } - 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 ErrNoEntries{} - } - return err - } - return nil -} - -/* - HANDY SHORTCUTS -*/ - -func (ps *postgresService) CreateInstanceAccount() error { - username := ps.config.Host - key, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - ps.log.Errorf("error creating new rsa key: %s", err) - return err - } - - newAccountURIs := util.GenerateURIsForAccount(username, ps.config.Protocol, ps.config.Host) - a := >smodel.Account{ - Username: ps.config.Host, - DisplayName: username, - URL: newAccountURIs.UserURL, - PrivateKey: key, - PublicKey: &key.PublicKey, - PublicKeyURI: newAccountURIs.PublicKeyURI, - ActorType: gtsmodel.ActivityStreamsPerson, - URI: newAccountURIs.UserURI, - InboxURI: newAccountURIs.InboxURI, - OutboxURI: newAccountURIs.OutboxURI, - FollowersURI: newAccountURIs.FollowersURI, - FollowingURI: newAccountURIs.FollowingURI, - FeaturedCollectionURI: newAccountURIs.CollectionURI, - } - inserted, err := ps.conn.Model(a).Where("username = ?", username).SelectOrInsert() - if err != nil { - return err - } - if inserted { - ps.log.Infof("created instance account %s with id %s", username, a.ID) - } else { - ps.log.Infof("instance account %s already exists with id %s", username, a.ID) - } - return nil -} - -func (ps *postgresService) CreateInstanceInstance() error { - i := >smodel.Instance{ - Domain: ps.config.Host, - Title: ps.config.Host, - URI: fmt.Sprintf("%s://%s", ps.config.Protocol, ps.config.Host), - } - inserted, err := ps.conn.Model(i).Where("domain = ?", ps.config.Host).SelectOrInsert() - if err != nil { - return err - } - if inserted { - ps.log.Infof("created instance instance %s with id %s", ps.config.Host, i.ID) - } else { - ps.log.Infof("instance instance %s already exists with id %s", ps.config.Host, i.ID) - } - return nil -} - -func (ps *postgresService) GetAccountByUserID(userID string, account *gtsmodel.Account) error { - user := >smodel.User{ - ID: userID, - } - if err := ps.conn.Model(user).Where("id = ?", userID).Select(); err != nil { - if err == pg.ErrNoRows { - return ErrNoEntries{} - } - return err - } - if err := ps.conn.Model(account).Where("id = ?", user.AccountID).Select(); err != nil { - if err == pg.ErrNoRows { - return ErrNoEntries{} - } - return err - } - return nil -} - -func (ps *postgresService) GetLocalAccountByUsername(username string, account *gtsmodel.Account) error { - if err := ps.conn.Model(account).Where("username = ?", username).Where("? IS NULL", pg.Ident("domain")).Select(); err != nil { - if err == pg.ErrNoRows { - return ErrNoEntries{} - } - return err - } - return nil -} - -func (ps *postgresService) GetFollowRequestsForAccountID(accountID string, followRequests *[]gtsmodel.FollowRequest) error { - if err := ps.conn.Model(followRequests).Where("target_account_id = ?", accountID).Select(); err != nil { - if err == pg.ErrNoRows { - return ErrNoEntries{} - } - return err - } - return nil -} - -func (ps *postgresService) GetFollowingByAccountID(accountID string, following *[]gtsmodel.Follow) error { - if err := ps.conn.Model(following).Where("account_id = ?", accountID).Select(); err != nil { - if err == pg.ErrNoRows { - return ErrNoEntries{} - } - return err - } - return nil -} - -func (ps *postgresService) GetFollowersByAccountID(accountID string, followers *[]gtsmodel.Follow) error { - if err := ps.conn.Model(followers).Where("target_account_id = ?", accountID).Select(); err != nil { - if err == pg.ErrNoRows { - return ErrNoEntries{} - } - return err - } - return nil -} - -func (ps *postgresService) GetStatusesByAccountID(accountID string, statuses *[]gtsmodel.Status) error { - if err := ps.conn.Model(statuses).Where("account_id = ?", accountID).Select(); err != nil { - if err == pg.ErrNoRows { - return ErrNoEntries{} - } - return err - } - return nil -} - -func (ps *postgresService) GetStatusesByTimeDescending(accountID string, statuses *[]gtsmodel.Status, limit int) error { - q := ps.conn.Model(statuses).Order("created_at DESC") - if limit != 0 { - q = q.Limit(limit) - } - if accountID != "" { - q = q.Where("account_id = ?", accountID) - } - if err := q.Select(); err != nil { - if err == pg.ErrNoRows { - return ErrNoEntries{} - } - return err - } - return nil -} - -func (ps *postgresService) GetLastStatusForAccountID(accountID string, status *gtsmodel.Status) error { - if err := ps.conn.Model(status).Order("created_at DESC").Limit(1).Where("account_id = ?", accountID).Select(); err != nil { - if err == pg.ErrNoRows { - return ErrNoEntries{} - } - return err - } - return nil - -} - -func (ps *postgresService) IsUsernameAvailable(username string) error { - // if no error we fail because it means we found something - // if error but it's not pg.ErrNoRows then we fail - // if err is pg.ErrNoRows we're good, we found nothing so continue - if err := ps.conn.Model(>smodel.Account{}).Where("username = ?", username).Where("domain = ?", nil).Select(); err == nil { - return fmt.Errorf("username %s already in use", username) - } else if err != pg.ErrNoRows { - return fmt.Errorf("db error: %s", err) - } - return nil -} - -func (ps *postgresService) IsEmailAvailable(email string) error { - // parse the domain from the email - m, err := mail.ParseAddress(email) - if err != nil { - return fmt.Errorf("error parsing email address %s: %s", email, err) - } - domain := strings.Split(m.Address, "@")[1] // domain will always be the second part after @ - - // check if the email domain is blocked - if err := ps.conn.Model(>smodel.EmailDomainBlock{}).Where("domain = ?", domain).Select(); err == nil { - // fail because we found something - return fmt.Errorf("email domain %s is blocked", domain) - } else if err != pg.ErrNoRows { - // fail because we got an unexpected error - return fmt.Errorf("db error: %s", err) - } - - // check if this email is associated with a user already - if err := ps.conn.Model(>smodel.User{}).Where("email = ?", email).WhereOr("unconfirmed_email = ?", email).Select(); err == nil { - // fail because we found something - return fmt.Errorf("email %s already in use", email) - } else if err != pg.ErrNoRows { - // fail because we got an unexpected error - return fmt.Errorf("db error: %s", err) - } - return nil -} - -func (ps *postgresService) NewSignup(username string, reason string, requireApproval bool, email string, password string, signUpIP net.IP, locale string, appID string) (*gtsmodel.User, error) { - key, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - ps.log.Errorf("error creating new rsa key: %s", err) - return nil, err - } - - newAccountURIs := util.GenerateURIsForAccount(username, ps.config.Protocol, ps.config.Host) - - a := >smodel.Account{ - Username: username, - DisplayName: username, - Reason: reason, - URL: newAccountURIs.UserURL, - PrivateKey: key, - PublicKey: &key.PublicKey, - PublicKeyURI: newAccountURIs.PublicKeyURI, - ActorType: gtsmodel.ActivityStreamsPerson, - URI: newAccountURIs.UserURI, - InboxURI: newAccountURIs.InboxURI, - OutboxURI: newAccountURIs.OutboxURI, - FollowersURI: newAccountURIs.FollowersURI, - FollowingURI: newAccountURIs.FollowingURI, - FeaturedCollectionURI: newAccountURIs.CollectionURI, - } - if _, err = ps.conn.Model(a).Insert(); err != nil { - return nil, err - } - - pw, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - if err != nil { - return nil, fmt.Errorf("error hashing password: %s", err) - } - u := >smodel.User{ - AccountID: a.ID, - EncryptedPassword: string(pw), - SignUpIP: signUpIP, - Locale: locale, - UnconfirmedEmail: email, - CreatedByApplicationID: appID, - Approved: !requireApproval, // if we don't require moderator approval, just pre-approve the user - } - if _, err = ps.conn.Model(u).Insert(); err != nil { - return nil, err - } - - return u, nil -} - -func (ps *postgresService) SetHeaderOrAvatarForAccountID(mediaAttachment *gtsmodel.MediaAttachment, accountID string) error { - if mediaAttachment.Avatar && mediaAttachment.Header { - return errors.New("one media attachment cannot be both header and avatar") - } - - var headerOrAVI string - if mediaAttachment.Avatar { - headerOrAVI = "avatar" - } else if mediaAttachment.Header { - headerOrAVI = "header" - } else { - return errors.New("given media attachment was neither a header nor an avatar") - } - - // TODO: there are probably more side effects here that need to be handled - if _, err := ps.conn.Model(mediaAttachment).OnConflict("(id) DO UPDATE").Insert(); err != nil { - return err - } - - if _, err := ps.conn.Model(>smodel.Account{}).Set(fmt.Sprintf("%s_media_attachment_id = ?", headerOrAVI), mediaAttachment.ID).Where("id = ?", accountID).Update(); err != nil { - return err - } - return nil -} - -func (ps *postgresService) GetHeaderForAccountID(header *gtsmodel.MediaAttachment, accountID string) error { - acct := >smodel.Account{} - if err := ps.conn.Model(acct).Where("id = ?", accountID).Select(); err != nil { - if err == pg.ErrNoRows { - return ErrNoEntries{} - } - return err - } - - if acct.HeaderMediaAttachmentID == "" { - return ErrNoEntries{} - } - - if err := ps.conn.Model(header).Where("id = ?", acct.HeaderMediaAttachmentID).Select(); err != nil { - if err == pg.ErrNoRows { - return ErrNoEntries{} - } - return err - } - return nil -} - -func (ps *postgresService) GetAvatarForAccountID(avatar *gtsmodel.MediaAttachment, accountID string) error { - acct := >smodel.Account{} - if err := ps.conn.Model(acct).Where("id = ?", accountID).Select(); err != nil { - if err == pg.ErrNoRows { - return ErrNoEntries{} - } - return err - } - - if acct.AvatarMediaAttachmentID == "" { - return ErrNoEntries{} - } - - if err := ps.conn.Model(avatar).Where("id = ?", acct.AvatarMediaAttachmentID).Select(); err != nil { - if err == pg.ErrNoRows { - return ErrNoEntries{} - } - return err - } - return nil -} - -func (ps *postgresService) Blocked(account1 string, account2 string) (bool, error) { - // TODO: check domain blocks as well - var blocked bool - if err := ps.conn.Model(>smodel.Block{}). - Where("account_id = ?", account1).Where("target_account_id = ?", account2). - WhereOr("target_account_id = ?", account1).Where("account_id = ?", account2). - Select(); err != nil { - if err == pg.ErrNoRows { - blocked = false - return blocked, nil - } - return blocked, err - } - blocked = true - return blocked, 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") - - // if target account is suspended then don't show the status - if !targetAccount.SuspendedAt.IsZero() { - l.Debug("target account suspended at is not zero") - return false, nil - } - - // if the target user doesn't exist (anymore) then the status also shouldn't be visible - targetUser := >smodel.User{} - if err := ps.conn.Model(targetUser).Where("account_id = ?", targetAccount.ID).Select(); err != nil { - l.Debug("target user could not be selected") - if err == pg.ErrNoRows { - return false, ErrNoEntries{} - } - return false, err - } - - // if target user is disabled, not yet approved, or not confirmed then don't show the status - // (although in the latter two cases it's unlikely they posted a status yet anyway, but you never know!) - if targetUser.Disabled || !targetUser.Approved || targetUser.ConfirmedAt.IsZero() { - l.Debug("target user is disabled, not approved, or not confirmed") - return false, nil - } - - // If requesting account is nil, that means whoever requested the status didn't auth, or their auth failed. - // In this case, we can still serve the status if it's public, otherwise we definitely shouldn't. - if requestingAccount == nil { - - if targetStatus.Visibility == gtsmodel.VisibilityPublic { - return true, nil - } - l.Debug("requesting account is nil but the target status isn't public") - return false, nil - } - - // if requesting account is suspended then don't show the status -- although they probably shouldn't have gotten - // this far (ie., been authed) in the first place: this is just for safety. - if !requestingAccount.SuspendedAt.IsZero() { - l.Debug("requesting account is suspended") - return false, nil - } - - // check if we have a local account -- if so we can check the user for that account in the DB - if requestingAccount.Domain == "" { - requestingUser := >smodel.User{} - if err := ps.conn.Model(requestingUser).Where("account_id = ?", requestingAccount.ID).Select(); err != nil { - // if the requesting account is local but doesn't have a corresponding user in the db this is a problem - if err == pg.ErrNoRows { - l.Debug("requesting account is local but there's no corresponding user") - return false, nil - } - l.Debugf("requesting account is local but there was an error getting the corresponding user: %s", err) - return false, err - } - // okay, user exists, so make sure it has full privileges/is confirmed/approved - if requestingUser.Disabled || !requestingUser.Approved || requestingUser.ConfirmedAt.IsZero() { - l.Debug("requesting account is local but corresponding user is either disabled, not approved, or not confirmed") - return false, nil - } - } - - // if the target status belongs to the requesting account, they should always be able to view it at this point - if targetStatus.AccountID == requestingAccount.ID { - return true, nil - } - - // At this point we have a populated targetAccount, targetStatus, and requestingAccount, so we can check for blocks and whathaveyou - // First check if a block exists directly between the target account (which authored the status) and the requesting account. - if blocked, err := ps.Blocked(targetAccount.ID, requestingAccount.ID); err != nil { - l.Debugf("something went wrong figuring out if the accounts have a block: %s", err) - return false, err - } else if blocked { - // don't allow the status to be viewed if a block exists in *either* direction between these two accounts, no creepy stalking please - l.Debug("a block exists between requesting account and target account") - return false, nil - } - - // check other accounts mentioned/boosted by/replied to by the status, if they exist - if relevantAccounts != nil { - // status replies to account id - if relevantAccounts.ReplyToAccount != nil { - if blocked, err := ps.Blocked(relevantAccounts.ReplyToAccount.ID, requestingAccount.ID); err != nil { - return false, err - } else if blocked { - return false, nil - } - } - - // status boosts accounts id - if relevantAccounts.BoostedAccount != nil { - if blocked, err := ps.Blocked(relevantAccounts.BoostedAccount.ID, requestingAccount.ID); err != nil { - return false, err - } else if blocked { - return false, nil - } - } - - // status boosts a reply to account id - if relevantAccounts.BoostedReplyToAccount != nil { - if blocked, err := ps.Blocked(relevantAccounts.BoostedReplyToAccount.ID, requestingAccount.ID); err != nil { - return false, err - } else if blocked { - return false, nil - } - } - - // status mentions accounts - for _, a := range relevantAccounts.MentionedAccounts { - if blocked, err := ps.Blocked(a.ID, requestingAccount.ID); err != nil { - return false, err - } else if blocked { - return false, nil - } - } - } - - // at this point we know neither account blocks the other, or another account mentioned or otherwise referred to in the status - // that means it's now just a matter of checking the visibility settings of the status itself - switch targetStatus.Visibility { - case gtsmodel.VisibilityPublic, gtsmodel.VisibilityUnlocked: - // no problem here, just return OK - return true, nil - case gtsmodel.VisibilityFollowersOnly: - // check one-way follow - follows, err := ps.Follows(requestingAccount, targetAccount) - if err != nil { - return false, err - } - if !follows { - return false, nil - } - return true, nil - case gtsmodel.VisibilityMutualsOnly: - // check mutual follow - mutuals, err := ps.Mutuals(requestingAccount, targetAccount) - if err != nil { - return false, err - } - if !mutuals { - return false, nil - } - return true, nil - case gtsmodel.VisibilityDirect: - // make sure the requesting account is mentioned in the status - for _, menchie := range targetStatus.Mentions { - if menchie == requestingAccount.ID { - return true, nil // yep it's mentioned! - } - } - return false, nil // it's not mentioned -_- - } - - return false, errors.New("reached the end of StatusVisible with no result") -} - -func (ps *postgresService) Follows(sourceAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) (bool, error) { - return ps.conn.Model(>smodel.Follow{}).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() - if err != nil { - if err == pg.ErrNoRows { - return false, nil - } - return false, err - } - - // make sure account 2 follows account 1 - f2, err := ps.conn.Model(>smodel.Follow{}).Where("account_id = ?", account2.ID).Where("target_account_id = ?", account1.ID).Exists() - if err != nil { - if err == pg.ErrNoRows { - return false, nil - } - return false, err - } - - return f1 && f2, nil -} - -func (ps *postgresService) PullRelevantAccountsFromStatus(targetStatus *gtsmodel.Status) (*gtsmodel.RelevantAccounts, error) { - accounts := >smodel.RelevantAccounts{ - MentionedAccounts: []*gtsmodel.Account{}, - } - - // get the replied to account from the status and add it to the pile - if targetStatus.InReplyToAccountID != "" { - repliedToAccount := >smodel.Account{} - if err := ps.conn.Model(repliedToAccount).Where("id = ?", targetStatus.InReplyToAccountID).Select(); err != nil { - return accounts, err - } - accounts.ReplyToAccount = repliedToAccount - } - - // get the boosted account from the status and add it to the pile - if targetStatus.BoostOfID != "" { - // retrieve the boosted status first - boostedStatus := >smodel.Status{} - if err := ps.conn.Model(boostedStatus).Where("id = ?", targetStatus.BoostOfID).Select(); err != nil { - return accounts, err - } - boostedAccount := >smodel.Account{} - if err := ps.conn.Model(boostedAccount).Where("id = ?", boostedStatus.AccountID).Select(); err != nil { - return accounts, err - } - accounts.BoostedAccount = boostedAccount - - // the boosted status might be a reply to another account so we should get that too - if boostedStatus.InReplyToAccountID != "" { - boostedStatusRepliedToAccount := >smodel.Account{} - if err := ps.conn.Model(boostedStatusRepliedToAccount).Where("id = ?", boostedStatus.InReplyToAccountID).Select(); err != nil { - return accounts, err - } - accounts.BoostedReplyToAccount = boostedStatusRepliedToAccount - } - } - - // now get all accounts with IDs that are mentioned in the status - for _, mentionedAccountID := range targetStatus.Mentions { - mentionedAccount := >smodel.Account{} - if err := ps.conn.Model(mentionedAccount).Where("id = ?", mentionedAccountID).Select(); err != nil { - return accounts, err - } - accounts.MentionedAccounts = append(accounts.MentionedAccounts, mentionedAccount) - } - - return accounts, nil -} - -func (ps *postgresService) GetReplyCountForStatus(status *gtsmodel.Status) (int, error) { - return ps.conn.Model(>smodel.Status{}).Where("in_reply_to_id = ?", status.ID).Count() -} - -func (ps *postgresService) GetReblogCountForStatus(status *gtsmodel.Status) (int, error) { - return ps.conn.Model(>smodel.Status{}).Where("boost_of_id = ?", status.ID).Count() -} - -func (ps *postgresService) GetFaveCountForStatus(status *gtsmodel.Status) (int, error) { - return ps.conn.Model(>smodel.StatusFave{}).Where("status_id = ?", status.ID).Count() -} - -func (ps *postgresService) StatusFavedBy(status *gtsmodel.Status, accountID string) (bool, error) { - return ps.conn.Model(>smodel.StatusFave{}).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Exists() -} - -func (ps *postgresService) StatusRebloggedBy(status *gtsmodel.Status, accountID string) (bool, error) { - return ps.conn.Model(>smodel.Status{}).Where("boost_of_id = ?", status.ID).Where("account_id = ?", accountID).Exists() -} - -func (ps *postgresService) StatusMutedBy(status *gtsmodel.Status, accountID string) (bool, error) { - return ps.conn.Model(>smodel.StatusMute{}).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Exists() -} - -func (ps *postgresService) StatusBookmarkedBy(status *gtsmodel.Status, accountID string) (bool, error) { - return ps.conn.Model(>smodel.StatusBookmark{}).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Exists() -} - -func (ps *postgresService) StatusPinnedBy(status *gtsmodel.Status, accountID string) (bool, error) { - return ps.conn.Model(>smodel.StatusPin{}).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Exists() -} - -func (ps *postgresService) FaveStatus(status *gtsmodel.Status, accountID string) (*gtsmodel.StatusFave, error) { - // first check if a fave already exists, we can just return if so - existingFave := >smodel.StatusFave{} - err := ps.conn.Model(existingFave).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Select() - if err == nil { - // fave already exists so just return nothing at all - return nil, nil - } - - // an error occurred so it might exist or not, we don't know - if err != pg.ErrNoRows { - return nil, err - } - - // it doesn't exist so create it - newFave := >smodel.StatusFave{ - AccountID: accountID, - TargetAccountID: status.AccountID, - StatusID: status.ID, - } - if _, err = ps.conn.Model(newFave).Insert(); err != nil { - return nil, err - } - - return newFave, nil -} - -func (ps *postgresService) UnfaveStatus(status *gtsmodel.Status, accountID string) (*gtsmodel.StatusFave, error) { - // if a fave doesn't exist, we don't need to do anything - existingFave := >smodel.StatusFave{} - err := ps.conn.Model(existingFave).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Select() - // the fave doesn't exist so return nothing at all - if err == pg.ErrNoRows { - return nil, nil - } - - // an error occurred so it might exist or not, we don't know - if err != nil && err != pg.ErrNoRows { - return nil, err - } - - // the fave exists so remove it - if _, err = ps.conn.Model(>smodel.StatusFave{}).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Delete(); err != nil { - return nil, err - } - - return existingFave, nil -} - -func (ps *postgresService) WhoFavedStatus(status *gtsmodel.Status) ([]*gtsmodel.Account, error) { - accounts := []*gtsmodel.Account{} - - faves := []*gtsmodel.StatusFave{} - if err := ps.conn.Model(&faves).Where("status_id = ?", status.ID).Select(); err != nil { - if err == pg.ErrNoRows { - return accounts, nil // no rows just means nobody has faved this status, so that's fine - } - return nil, err // an actual error has occurred - } - - for _, f := range faves { - acc := >smodel.Account{} - if err := ps.conn.Model(acc).Where("id = ?", f.AccountID).Select(); err != nil { - if err == pg.ErrNoRows { - continue // the account doesn't exist for some reason??? but this isn't the place to worry about that so just skip it - } - return nil, err // an actual error has occurred - } - accounts = append(accounts, acc) - } - return accounts, nil -} - -/* - CONVERSION FUNCTIONS -*/ - -func (ps *postgresService) MentionStringsToMentions(targetAccounts []string, originAccountID string, statusID string) ([]*gtsmodel.Mention, error) { - menchies := []*gtsmodel.Mention{} - for _, a := range targetAccounts { - // A mentioned account looks like "@test@example.org" or just "@test" for a local account - // -- we can guarantee this from the regex that targetAccounts should have been derived from. - // But we still need to do a bit of fiddling to get what we need here -- the username and domain (if given). - - // 1. trim off the first @ - t := strings.TrimPrefix(a, "@") - - // 2. split the username and domain - s := strings.Split(t, "@") - - // 3. if it's length 1 it's a local account, length 2 means remote, anything else means something is wrong - var local bool - switch len(s) { - case 1: - local = true - case 2: - local = false - default: - return nil, fmt.Errorf("mentioned account format '%s' was not valid", a) - } - - var username, domain string - username = s[0] - if !local { - domain = s[1] - } - - // 4. check we now have a proper username and domain - if username == "" || (!local && domain == "") { - return nil, fmt.Errorf("username or domain for '%s' was nil", a) - } - - // okay we're good now, we can start pulling accounts out of the database - mentionedAccount := >smodel.Account{} - var err error - if local { - // local user -- should have a null domain - err = ps.conn.Model(mentionedAccount).Where("username = ?", username).Where("? IS NULL", pg.Ident("domain")).Select() - } else { - // remote user -- should have domain defined - err = ps.conn.Model(mentionedAccount).Where("username = ?", username).Where("? = ?", pg.Ident("domain"), domain).Select() - } - - if err != nil { - if err == pg.ErrNoRows { - // no result found for this username/domain so just don't include it as a mencho and carry on about our business - ps.log.Debugf("no account found with username '%s' and domain '%s', skipping it", username, domain) - continue - } - // a serious error has happened so bail - return nil, fmt.Errorf("error getting account with username '%s' and domain '%s': %s", username, domain, err) - } - - // 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, - }) - } - return menchies, nil -} - -func (ps *postgresService) TagStringsToTags(tags []string, originAccountID string, statusID string) ([]*gtsmodel.Tag, error) { - newTags := []*gtsmodel.Tag{} - for _, t := range tags { - tag := >smodel.Tag{} - // we can use selectorinsert here to create the new tag if it doesn't exist already - // inserted will be true if this is a new tag we just created - if err := ps.conn.Model(tag).Where("name = ?", t).Select(); err != nil { - if err == pg.ErrNoRows { - // tag doesn't exist yet so populate it - tag.ID = uuid.NewString() - tag.Name = t - tag.FirstSeenFromAccountID = originAccountID - tag.CreatedAt = time.Now() - tag.UpdatedAt = time.Now() - tag.Useable = true - tag.Listable = true - } else { - return nil, fmt.Errorf("error getting tag with name %s: %s", t, err) - } - } - - // bail already if the tag isn't useable - if !tag.Useable { - continue - } - tag.LastStatusAt = time.Now() - newTags = append(newTags, tag) - } - return newTags, nil -} - -func (ps *postgresService) EmojiStringsToEmojis(emojis []string, originAccountID string, statusID string) ([]*gtsmodel.Emoji, error) { - newEmojis := []*gtsmodel.Emoji{} - for _, e := range emojis { - emoji := >smodel.Emoji{} - err := ps.conn.Model(emoji).Where("shortcode = ?", e).Where("visible_in_picker = true").Where("disabled = false").Select() - if err != nil { - if err == pg.ErrNoRows { - // no result found for this username/domain so just don't include it as an emoji and carry on about our business - ps.log.Debugf("no emoji found with shortcode %s, skipping it", e) - continue - } - // a serious error has happened so bail - return nil, fmt.Errorf("error getting emoji with shortcode %s: %s", e, err) - } - newEmojis = append(newEmojis, emoji) - } - return newEmojis, nil -} diff --git a/internal/db/pg/pg.go b/internal/db/pg/pg.go new file mode 100644 index 000000000..f8c2fdbe8 --- /dev/null +++ b/internal/db/pg/pg.go @@ -0,0 +1,1127 @@ +/* + 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 pg + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "errors" + "fmt" + "net" + "net/mail" + "regexp" + "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" + "github.com/google/uuid" + "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" +) + +// postgresService satisfies the DB interface +type postgresService struct { + 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. +// Under the hood, it uses https://github.com/go-pg/pg to create and maintain a database connection. +func NewPostgresService(ctx context.Context, c *config.Config, log *logrus.Logger) (db.DB, error) { + opts, err := derivePGOptions(c) + if err != nil { + return nil, fmt.Errorf("could not create postgres service: %s", err) + } + log.Debugf("using pg options: %+v", opts) + + // create a connection + pgCtx, cancel := context.WithCancel(ctx) + conn := pg.Connect(opts).WithContext(pgCtx) + + // this will break the logfmt format we normally log in, + // since we can't choose where pg outputs to and it defaults to + // stdout. So use this option with care! + if log.GetLevel() >= logrus.TraceLevel { + conn.AddQueryHook(pgdebug.DebugHook{ + // Print all queries. + Verbose: true, + }) + } + + // actually *begin* the connection so that we can tell if the db is there and listening + if err := conn.Ping(ctx); err != nil { + cancel() + return nil, fmt.Errorf("db connection error: %s", err) + } + + // print out discovered postgres version + var version string + if _, err = conn.QueryOneContext(ctx, pg.Scan(&version), "SELECT version()"); err != nil { + cancel() + return nil, fmt.Errorf("db connection error: %s", err) + } + log.Infof("connected to postgres version: %s", version) + + ps := &postgresService{ + config: c, + conn: conn, + log: log, + cancel: cancel, + } + + federatingDB := federation.NewFederatingDB(ps, c, log) + ps.federationDB = federatingDB + + // we can confidently return this useable postgres service now + return ps, nil +} + +/* + HANDY STUFF +*/ + +// derivePGOptions takes an application config and returns either a ready-to-use *pg.Options +// with sensible defaults, or an error if it's not satisfied by the provided config. +func derivePGOptions(c *config.Config) (*pg.Options, error) { + if strings.ToUpper(c.DBConfig.Type) != db.DBTypePostgres { + return nil, fmt.Errorf("expected db type of %s but got %s", db.DBTypePostgres, c.DBConfig.Type) + } + + // validate port + if c.DBConfig.Port == 0 { + return nil, errors.New("no port set") + } + + // validate address + if c.DBConfig.Address == "" { + return nil, errors.New("no address set") + } + + ipv4Regex := regexp.MustCompile(`^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$`) + hostnameRegex := regexp.MustCompile(`^(?:[a-z0-9]+(?:-[a-z0-9]+)*\.)+[a-z]{2,}$`) + if !hostnameRegex.MatchString(c.DBConfig.Address) && !ipv4Regex.MatchString(c.DBConfig.Address) && c.DBConfig.Address != "localhost" { + return nil, fmt.Errorf("address %s was neither an ipv4 address nor a valid hostname", c.DBConfig.Address) + } + + // validate username + if c.DBConfig.User == "" { + return nil, errors.New("no user set") + } + + // validate that there's a password + if c.DBConfig.Password == "" { + return nil, errors.New("no password set") + } + + // validate database + if c.DBConfig.Database == "" { + return nil, errors.New("no database set") + } + + // We can rely on the pg library we're using to set + // sensible defaults for everything we don't set here. + options := &pg.Options{ + Addr: fmt.Sprintf("%s:%d", c.DBConfig.Address, c.DBConfig.Port), + User: c.DBConfig.User, + Password: c.DBConfig.Password, + Database: c.DBConfig.Database, + ApplicationName: c.ApplicationName, + } + + return options, nil +} + +/* + FEDERATION FUNCTIONALITY +*/ + +func (ps *postgresService) Federation() pub.Database { + return ps.federationDB +} + +/* + BASIC DB FUNCTIONALITY +*/ + +func (ps *postgresService) CreateTable(i interface{}) error { + return ps.conn.Model(i).CreateTable(&orm.CreateTableOptions{ + IfNotExists: true, + }) +} + +func (ps *postgresService) DropTable(i interface{}) error { + return ps.conn.Model(i).DropTable(&orm.DropTableOptions{ + IfExists: true, + }) +} + +func (ps *postgresService) Stop(ctx context.Context) error { + ps.log.Info("closing db connection") + if err := ps.conn.Close(); err != nil { + // only cancel if there's a problem closing the db + ps.cancel() + return err + } + return nil +} + +func (ps *postgresService) IsHealthy(ctx context.Context) error { + return ps.conn.Ping(ctx) +} + +func (ps *postgresService) CreateSchema(ctx context.Context) error { + models := []interface{}{ + (*gtsmodel.Account)(nil), + (*gtsmodel.Status)(nil), + (*gtsmodel.User)(nil), + } + ps.log.Info("creating db schema") + + for _, model := range models { + err := ps.conn.Model(model).CreateTable(&orm.CreateTableOptions{ + IfNotExists: true, + }) + if err != nil { + return err + } + } + + ps.log.Info("db schema created") + return nil +} + +func (ps *postgresService) GetByID(id string, i interface{}) error { + if err := ps.conn.Model(i).Where("id = ?", id).Select(); err != nil { + if err == pg.ErrNoRows { + return db.ErrNoEntries{} + } + return err + + } + 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 { + if err == pg.ErrNoRows { + return db.ErrNoEntries{} + } + return err + } + return nil +} + +// func (ps *postgresService) GetWhereMany(i interface{}, where ...model.Where) error { +// return nil +// } + +func (ps *postgresService) GetAll(i interface{}) error { + if err := ps.conn.Model(i).Select(); err != nil { + if err == pg.ErrNoRows { + return db.ErrNoEntries{} + } + return err + } + return nil +} + +func (ps *postgresService) Put(i interface{}) error { + _, err := ps.conn.Model(i).Insert(i) + return err +} + +func (ps *postgresService) Upsert(i interface{}, conflictColumn string) error { + if _, err := ps.conn.Model(i).OnConflict(fmt.Sprintf("(%s) DO UPDATE", conflictColumn)).Insert(); err != nil { + if err == pg.ErrNoRows { + return db.ErrNoEntries{} + } + return err + } + return nil +} + +func (ps *postgresService) UpdateByID(id string, i interface{}) error { + if _, err := ps.conn.Model(i).Where("id = ?", id).OnConflict("(id) DO UPDATE").Insert(); err != nil { + if err == pg.ErrNoRows { + return db.ErrNoEntries{} + } + return err + } + return nil +} + +func (ps *postgresService) UpdateOneByID(id string, key string, value interface{}, i interface{}) error { + _, err := ps.conn.Model(i).Set("? = ?", pg.Safe(key), value).Where("id = ?", id).Update() + return err +} + +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{} + } + 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{} + } + return err + } + return nil +} + +/* + HANDY SHORTCUTS +*/ + +func (ps *postgresService) AcceptFollowRequest(originAccountID string, targetAccountID string) error { + 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 err + } + + follow := >smodel.Follow{ + AccountID: originAccountID, + TargetAccountID: targetAccountID, + URI: fr.URI, + } + + if _, err := ps.conn.Model(follow).Insert(); err != nil { + return err + } + + if _, err := ps.conn.Model(>smodel.FollowRequest{}).Where("account_id = ?", originAccountID).Where("target_account_id = ?", targetAccountID).Delete(); err != nil { + return err + } + + return nil +} + +func (ps *postgresService) CreateInstanceAccount() error { + username := ps.config.Host + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + ps.log.Errorf("error creating new rsa key: %s", err) + return err + } + + newAccountURIs := util.GenerateURIsForAccount(username, ps.config.Protocol, ps.config.Host) + a := >smodel.Account{ + Username: ps.config.Host, + DisplayName: username, + URL: newAccountURIs.UserURL, + PrivateKey: key, + PublicKey: &key.PublicKey, + PublicKeyURI: newAccountURIs.PublicKeyURI, + ActorType: gtsmodel.ActivityStreamsPerson, + URI: newAccountURIs.UserURI, + InboxURI: newAccountURIs.InboxURI, + OutboxURI: newAccountURIs.OutboxURI, + FollowersURI: newAccountURIs.FollowersURI, + FollowingURI: newAccountURIs.FollowingURI, + FeaturedCollectionURI: newAccountURIs.CollectionURI, + } + inserted, err := ps.conn.Model(a).Where("username = ?", username).SelectOrInsert() + if err != nil { + return err + } + if inserted { + ps.log.Infof("created instance account %s with id %s", username, a.ID) + } else { + ps.log.Infof("instance account %s already exists with id %s", username, a.ID) + } + return nil +} + +func (ps *postgresService) CreateInstanceInstance() error { + i := >smodel.Instance{ + Domain: ps.config.Host, + Title: ps.config.Host, + URI: fmt.Sprintf("%s://%s", ps.config.Protocol, ps.config.Host), + } + inserted, err := ps.conn.Model(i).Where("domain = ?", ps.config.Host).SelectOrInsert() + if err != nil { + return err + } + if inserted { + ps.log.Infof("created instance instance %s with id %s", ps.config.Host, i.ID) + } else { + ps.log.Infof("instance instance %s already exists with id %s", ps.config.Host, i.ID) + } + return nil +} + +func (ps *postgresService) GetAccountByUserID(userID string, account *gtsmodel.Account) error { + user := >smodel.User{ + ID: userID, + } + if err := ps.conn.Model(user).Where("id = ?", userID).Select(); err != nil { + if err == pg.ErrNoRows { + return db.ErrNoEntries{} + } + return err + } + if err := ps.conn.Model(account).Where("id = ?", user.AccountID).Select(); err != nil { + if err == pg.ErrNoRows { + return db.ErrNoEntries{} + } + return err + } + return nil +} + +func (ps *postgresService) GetLocalAccountByUsername(username string, account *gtsmodel.Account) error { + if err := ps.conn.Model(account).Where("username = ?", username).Where("? IS NULL", pg.Ident("domain")).Select(); err != nil { + if err == pg.ErrNoRows { + return db.ErrNoEntries{} + } + return err + } + return nil +} + +func (ps *postgresService) GetFollowRequestsForAccountID(accountID string, followRequests *[]gtsmodel.FollowRequest) error { + if err := ps.conn.Model(followRequests).Where("target_account_id = ?", accountID).Select(); err != nil { + if err == pg.ErrNoRows { + return nil + } + return err + } + return nil +} + +func (ps *postgresService) GetFollowingByAccountID(accountID string, following *[]gtsmodel.Follow) error { + if err := ps.conn.Model(following).Where("account_id = ?", accountID).Select(); err != nil { + if err == pg.ErrNoRows { + return nil + } + return err + } + return nil +} + +func (ps *postgresService) GetFollowersByAccountID(accountID string, followers *[]gtsmodel.Follow) error { + if err := ps.conn.Model(followers).Where("target_account_id = ?", accountID).Select(); err != nil { + if err == pg.ErrNoRows { + return nil + } + return err + } + return nil +} + +func (ps *postgresService) GetFavesByAccountID(accountID string, faves *[]gtsmodel.StatusFave) error { + if err := ps.conn.Model(faves).Where("account_id = ?", accountID).Select(); err != nil { + if err == pg.ErrNoRows { + return nil + } + return err + } + return nil +} + +func (ps *postgresService) GetStatusesByAccountID(accountID string, statuses *[]gtsmodel.Status) error { + if err := ps.conn.Model(statuses).Where("account_id = ?", accountID).Select(); err != nil { + if err == pg.ErrNoRows { + return db.ErrNoEntries{} + } + return err + } + return nil +} + +func (ps *postgresService) GetStatusesByTimeDescending(accountID string, statuses *[]gtsmodel.Status, limit int) error { + q := ps.conn.Model(statuses).Order("created_at DESC") + if limit != 0 { + q = q.Limit(limit) + } + if accountID != "" { + q = q.Where("account_id = ?", accountID) + } + if err := q.Select(); err != nil { + if err == pg.ErrNoRows { + return db.ErrNoEntries{} + } + return err + } + return nil +} + +func (ps *postgresService) GetLastStatusForAccountID(accountID string, status *gtsmodel.Status) error { + if err := ps.conn.Model(status).Order("created_at DESC").Limit(1).Where("account_id = ?", accountID).Select(); err != nil { + if err == pg.ErrNoRows { + return db.ErrNoEntries{} + } + return err + } + return nil + +} + +func (ps *postgresService) IsUsernameAvailable(username string) error { + // if no error we fail because it means we found something + // if error but it's not pg.ErrNoRows then we fail + // if err is pg.ErrNoRows we're good, we found nothing so continue + if err := ps.conn.Model(>smodel.Account{}).Where("username = ?", username).Where("domain = ?", nil).Select(); err == nil { + return fmt.Errorf("username %s already in use", username) + } else if err != pg.ErrNoRows { + return fmt.Errorf("db error: %s", err) + } + return nil +} + +func (ps *postgresService) IsEmailAvailable(email string) error { + // parse the domain from the email + m, err := mail.ParseAddress(email) + if err != nil { + return fmt.Errorf("error parsing email address %s: %s", email, err) + } + domain := strings.Split(m.Address, "@")[1] // domain will always be the second part after @ + + // check if the email domain is blocked + if err := ps.conn.Model(>smodel.EmailDomainBlock{}).Where("domain = ?", domain).Select(); err == nil { + // fail because we found something + return fmt.Errorf("email domain %s is blocked", domain) + } else if err != pg.ErrNoRows { + // fail because we got an unexpected error + return fmt.Errorf("db error: %s", err) + } + + // check if this email is associated with a user already + if err := ps.conn.Model(>smodel.User{}).Where("email = ?", email).WhereOr("unconfirmed_email = ?", email).Select(); err == nil { + // fail because we found something + return fmt.Errorf("email %s already in use", email) + } else if err != pg.ErrNoRows { + // fail because we got an unexpected error + return fmt.Errorf("db error: %s", err) + } + return nil +} + +func (ps *postgresService) NewSignup(username string, reason string, requireApproval bool, email string, password string, signUpIP net.IP, locale string, appID string) (*gtsmodel.User, error) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + ps.log.Errorf("error creating new rsa key: %s", err) + return nil, err + } + + newAccountURIs := util.GenerateURIsForAccount(username, ps.config.Protocol, ps.config.Host) + + a := >smodel.Account{ + Username: username, + DisplayName: username, + Reason: reason, + URL: newAccountURIs.UserURL, + PrivateKey: key, + PublicKey: &key.PublicKey, + PublicKeyURI: newAccountURIs.PublicKeyURI, + ActorType: gtsmodel.ActivityStreamsPerson, + URI: newAccountURIs.UserURI, + InboxURI: newAccountURIs.InboxURI, + OutboxURI: newAccountURIs.OutboxURI, + FollowersURI: newAccountURIs.FollowersURI, + FollowingURI: newAccountURIs.FollowingURI, + FeaturedCollectionURI: newAccountURIs.CollectionURI, + } + if _, err = ps.conn.Model(a).Insert(); err != nil { + return nil, err + } + + pw, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return nil, fmt.Errorf("error hashing password: %s", err) + } + u := >smodel.User{ + AccountID: a.ID, + EncryptedPassword: string(pw), + SignUpIP: signUpIP, + Locale: locale, + UnconfirmedEmail: email, + CreatedByApplicationID: appID, + Approved: !requireApproval, // if we don't require moderator approval, just pre-approve the user + } + if _, err = ps.conn.Model(u).Insert(); err != nil { + return nil, err + } + + return u, nil +} + +func (ps *postgresService) SetHeaderOrAvatarForAccountID(mediaAttachment *gtsmodel.MediaAttachment, accountID string) error { + if mediaAttachment.Avatar && mediaAttachment.Header { + return errors.New("one media attachment cannot be both header and avatar") + } + + var headerOrAVI string + if mediaAttachment.Avatar { + headerOrAVI = "avatar" + } else if mediaAttachment.Header { + headerOrAVI = "header" + } else { + return errors.New("given media attachment was neither a header nor an avatar") + } + + // TODO: there are probably more side effects here that need to be handled + if _, err := ps.conn.Model(mediaAttachment).OnConflict("(id) DO UPDATE").Insert(); err != nil { + return err + } + + if _, err := ps.conn.Model(>smodel.Account{}).Set(fmt.Sprintf("%s_media_attachment_id = ?", headerOrAVI), mediaAttachment.ID).Where("id = ?", accountID).Update(); err != nil { + return err + } + return nil +} + +func (ps *postgresService) GetHeaderForAccountID(header *gtsmodel.MediaAttachment, accountID string) error { + acct := >smodel.Account{} + if err := ps.conn.Model(acct).Where("id = ?", accountID).Select(); err != nil { + if err == pg.ErrNoRows { + return db.ErrNoEntries{} + } + return err + } + + if acct.HeaderMediaAttachmentID == "" { + return db.ErrNoEntries{} + } + + if err := ps.conn.Model(header).Where("id = ?", acct.HeaderMediaAttachmentID).Select(); err != nil { + if err == pg.ErrNoRows { + return db.ErrNoEntries{} + } + return err + } + return nil +} + +func (ps *postgresService) GetAvatarForAccountID(avatar *gtsmodel.MediaAttachment, accountID string) error { + acct := >smodel.Account{} + if err := ps.conn.Model(acct).Where("id = ?", accountID).Select(); err != nil { + if err == pg.ErrNoRows { + return db.ErrNoEntries{} + } + return err + } + + if acct.AvatarMediaAttachmentID == "" { + return db.ErrNoEntries{} + } + + if err := ps.conn.Model(avatar).Where("id = ?", acct.AvatarMediaAttachmentID).Select(); err != nil { + if err == pg.ErrNoRows { + return db.ErrNoEntries{} + } + return err + } + return nil +} + +func (ps *postgresService) Blocked(account1 string, account2 string) (bool, error) { + // TODO: check domain blocks as well + var blocked bool + if err := ps.conn.Model(>smodel.Block{}). + Where("account_id = ?", account1).Where("target_account_id = ?", account2). + WhereOr("target_account_id = ?", account1).Where("account_id = ?", account2). + Select(); err != nil { + if err == pg.ErrNoRows { + blocked = false + return blocked, nil + } + return blocked, err + } + blocked = true + return blocked, 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") + + // if target account is suspended then don't show the status + if !targetAccount.SuspendedAt.IsZero() { + l.Debug("target account suspended at is not zero") + return false, nil + } + + // if the target user doesn't exist (anymore) then the status also shouldn't be visible + targetUser := >smodel.User{} + if err := ps.conn.Model(targetUser).Where("account_id = ?", targetAccount.ID).Select(); err != nil { + l.Debug("target user could not be selected") + if err == pg.ErrNoRows { + return false, db.ErrNoEntries{} + } + return false, err + } + + // if target user is disabled, not yet approved, or not confirmed then don't show the status + // (although in the latter two cases it's unlikely they posted a status yet anyway, but you never know!) + if targetUser.Disabled || !targetUser.Approved || targetUser.ConfirmedAt.IsZero() { + l.Debug("target user is disabled, not approved, or not confirmed") + return false, nil + } + + // If requesting account is nil, that means whoever requested the status didn't auth, or their auth failed. + // In this case, we can still serve the status if it's public, otherwise we definitely shouldn't. + if requestingAccount == nil { + + if targetStatus.Visibility == gtsmodel.VisibilityPublic { + return true, nil + } + l.Debug("requesting account is nil but the target status isn't public") + return false, nil + } + + // if requesting account is suspended then don't show the status -- although they probably shouldn't have gotten + // this far (ie., been authed) in the first place: this is just for safety. + if !requestingAccount.SuspendedAt.IsZero() { + l.Debug("requesting account is suspended") + return false, nil + } + + // check if we have a local account -- if so we can check the user for that account in the DB + if requestingAccount.Domain == "" { + requestingUser := >smodel.User{} + if err := ps.conn.Model(requestingUser).Where("account_id = ?", requestingAccount.ID).Select(); err != nil { + // if the requesting account is local but doesn't have a corresponding user in the db this is a problem + if err == pg.ErrNoRows { + l.Debug("requesting account is local but there's no corresponding user") + return false, nil + } + l.Debugf("requesting account is local but there was an error getting the corresponding user: %s", err) + return false, err + } + // okay, user exists, so make sure it has full privileges/is confirmed/approved + if requestingUser.Disabled || !requestingUser.Approved || requestingUser.ConfirmedAt.IsZero() { + l.Debug("requesting account is local but corresponding user is either disabled, not approved, or not confirmed") + return false, nil + } + } + + // if the target status belongs to the requesting account, they should always be able to view it at this point + if targetStatus.AccountID == requestingAccount.ID { + return true, nil + } + + // At this point we have a populated targetAccount, targetStatus, and requestingAccount, so we can check for blocks and whathaveyou + // First check if a block exists directly between the target account (which authored the status) and the requesting account. + if blocked, err := ps.Blocked(targetAccount.ID, requestingAccount.ID); err != nil { + l.Debugf("something went wrong figuring out if the accounts have a block: %s", err) + return false, err + } else if blocked { + // don't allow the status to be viewed if a block exists in *either* direction between these two accounts, no creepy stalking please + l.Debug("a block exists between requesting account and target account") + return false, nil + } + + // check other accounts mentioned/boosted by/replied to by the status, if they exist + if relevantAccounts != nil { + // status replies to account id + if relevantAccounts.ReplyToAccount != nil { + if blocked, err := ps.Blocked(relevantAccounts.ReplyToAccount.ID, requestingAccount.ID); err != nil { + return false, err + } else if blocked { + return false, nil + } + } + + // status boosts accounts id + if relevantAccounts.BoostedAccount != nil { + if blocked, err := ps.Blocked(relevantAccounts.BoostedAccount.ID, requestingAccount.ID); err != nil { + return false, err + } else if blocked { + return false, nil + } + } + + // status boosts a reply to account id + if relevantAccounts.BoostedReplyToAccount != nil { + if blocked, err := ps.Blocked(relevantAccounts.BoostedReplyToAccount.ID, requestingAccount.ID); err != nil { + return false, err + } else if blocked { + return false, nil + } + } + + // status mentions accounts + for _, a := range relevantAccounts.MentionedAccounts { + if blocked, err := ps.Blocked(a.ID, requestingAccount.ID); err != nil { + return false, err + } else if blocked { + return false, nil + } + } + } + + // at this point we know neither account blocks the other, or another account mentioned or otherwise referred to in the status + // that means it's now just a matter of checking the visibility settings of the status itself + switch targetStatus.Visibility { + case gtsmodel.VisibilityPublic, gtsmodel.VisibilityUnlocked: + // no problem here, just return OK + return true, nil + case gtsmodel.VisibilityFollowersOnly: + // check one-way follow + follows, err := ps.Follows(requestingAccount, targetAccount) + if err != nil { + return false, err + } + if !follows { + return false, nil + } + return true, nil + case gtsmodel.VisibilityMutualsOnly: + // check mutual follow + mutuals, err := ps.Mutuals(requestingAccount, targetAccount) + if err != nil { + return false, err + } + if !mutuals { + return false, nil + } + return true, nil + case gtsmodel.VisibilityDirect: + // make sure the requesting account is mentioned in the status + for _, menchie := range targetStatus.Mentions { + if menchie == requestingAccount.ID { + return true, nil // yep it's mentioned! + } + } + return false, nil // it's not mentioned -_- + } + + return false, errors.New("reached the end of StatusVisible with no result") +} + +func (ps *postgresService) Follows(sourceAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) (bool, error) { + return ps.conn.Model(>smodel.Follow{}).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() + if err != nil { + if err == pg.ErrNoRows { + return false, nil + } + return false, err + } + + // make sure account 2 follows account 1 + f2, err := ps.conn.Model(>smodel.Follow{}).Where("account_id = ?", account2.ID).Where("target_account_id = ?", account1.ID).Exists() + if err != nil { + if err == pg.ErrNoRows { + return false, nil + } + return false, err + } + + return f1 && f2, nil +} + +func (ps *postgresService) PullRelevantAccountsFromStatus(targetStatus *gtsmodel.Status) (*gtsmodel.RelevantAccounts, error) { + accounts := >smodel.RelevantAccounts{ + MentionedAccounts: []*gtsmodel.Account{}, + } + + // get the replied to account from the status and add it to the pile + if targetStatus.InReplyToAccountID != "" { + repliedToAccount := >smodel.Account{} + if err := ps.conn.Model(repliedToAccount).Where("id = ?", targetStatus.InReplyToAccountID).Select(); err != nil { + return accounts, err + } + accounts.ReplyToAccount = repliedToAccount + } + + // get the boosted account from the status and add it to the pile + if targetStatus.BoostOfID != "" { + // retrieve the boosted status first + boostedStatus := >smodel.Status{} + if err := ps.conn.Model(boostedStatus).Where("id = ?", targetStatus.BoostOfID).Select(); err != nil { + return accounts, err + } + boostedAccount := >smodel.Account{} + if err := ps.conn.Model(boostedAccount).Where("id = ?", boostedStatus.AccountID).Select(); err != nil { + return accounts, err + } + accounts.BoostedAccount = boostedAccount + + // the boosted status might be a reply to another account so we should get that too + if boostedStatus.InReplyToAccountID != "" { + boostedStatusRepliedToAccount := >smodel.Account{} + if err := ps.conn.Model(boostedStatusRepliedToAccount).Where("id = ?", boostedStatus.InReplyToAccountID).Select(); err != nil { + return accounts, err + } + accounts.BoostedReplyToAccount = boostedStatusRepliedToAccount + } + } + + // now get all accounts with IDs that are mentioned in the status + for _, mentionedAccountID := range targetStatus.Mentions { + mentionedAccount := >smodel.Account{} + if err := ps.conn.Model(mentionedAccount).Where("id = ?", mentionedAccountID).Select(); err != nil { + return accounts, err + } + accounts.MentionedAccounts = append(accounts.MentionedAccounts, mentionedAccount) + } + + return accounts, nil +} + +func (ps *postgresService) GetReplyCountForStatus(status *gtsmodel.Status) (int, error) { + return ps.conn.Model(>smodel.Status{}).Where("in_reply_to_id = ?", status.ID).Count() +} + +func (ps *postgresService) GetReblogCountForStatus(status *gtsmodel.Status) (int, error) { + return ps.conn.Model(>smodel.Status{}).Where("boost_of_id = ?", status.ID).Count() +} + +func (ps *postgresService) GetFaveCountForStatus(status *gtsmodel.Status) (int, error) { + return ps.conn.Model(>smodel.StatusFave{}).Where("status_id = ?", status.ID).Count() +} + +func (ps *postgresService) StatusFavedBy(status *gtsmodel.Status, accountID string) (bool, error) { + return ps.conn.Model(>smodel.StatusFave{}).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Exists() +} + +func (ps *postgresService) StatusRebloggedBy(status *gtsmodel.Status, accountID string) (bool, error) { + return ps.conn.Model(>smodel.Status{}).Where("boost_of_id = ?", status.ID).Where("account_id = ?", accountID).Exists() +} + +func (ps *postgresService) StatusMutedBy(status *gtsmodel.Status, accountID string) (bool, error) { + return ps.conn.Model(>smodel.StatusMute{}).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Exists() +} + +func (ps *postgresService) StatusBookmarkedBy(status *gtsmodel.Status, accountID string) (bool, error) { + return ps.conn.Model(>smodel.StatusBookmark{}).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Exists() +} + +func (ps *postgresService) StatusPinnedBy(status *gtsmodel.Status, accountID string) (bool, error) { + return ps.conn.Model(>smodel.StatusPin{}).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Exists() +} + +func (ps *postgresService) FaveStatus(status *gtsmodel.Status, accountID string) (*gtsmodel.StatusFave, error) { + // first check if a fave already exists, we can just return if so + existingFave := >smodel.StatusFave{} + err := ps.conn.Model(existingFave).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Select() + if err == nil { + // fave already exists so just return nothing at all + return nil, nil + } + + // an error occurred so it might exist or not, we don't know + if err != pg.ErrNoRows { + return nil, err + } + + // it doesn't exist so create it + newFave := >smodel.StatusFave{ + AccountID: accountID, + TargetAccountID: status.AccountID, + StatusID: status.ID, + } + if _, err = ps.conn.Model(newFave).Insert(); err != nil { + return nil, err + } + + return newFave, nil +} + +func (ps *postgresService) UnfaveStatus(status *gtsmodel.Status, accountID string) (*gtsmodel.StatusFave, error) { + // if a fave doesn't exist, we don't need to do anything + existingFave := >smodel.StatusFave{} + err := ps.conn.Model(existingFave).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Select() + // the fave doesn't exist so return nothing at all + if err == pg.ErrNoRows { + return nil, nil + } + + // an error occurred so it might exist or not, we don't know + if err != nil && err != pg.ErrNoRows { + return nil, err + } + + // the fave exists so remove it + if _, err = ps.conn.Model(>smodel.StatusFave{}).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Delete(); err != nil { + return nil, err + } + + return existingFave, nil +} + +func (ps *postgresService) WhoFavedStatus(status *gtsmodel.Status) ([]*gtsmodel.Account, error) { + accounts := []*gtsmodel.Account{} + + faves := []*gtsmodel.StatusFave{} + if err := ps.conn.Model(&faves).Where("status_id = ?", status.ID).Select(); err != nil { + if err == pg.ErrNoRows { + return accounts, nil // no rows just means nobody has faved this status, so that's fine + } + return nil, err // an actual error has occurred + } + + for _, f := range faves { + acc := >smodel.Account{} + if err := ps.conn.Model(acc).Where("id = ?", f.AccountID).Select(); err != nil { + if err == pg.ErrNoRows { + continue // the account doesn't exist for some reason??? but this isn't the place to worry about that so just skip it + } + return nil, err // an actual error has occurred + } + accounts = append(accounts, acc) + } + return accounts, nil +} + +/* + CONVERSION FUNCTIONS +*/ + +func (ps *postgresService) MentionStringsToMentions(targetAccounts []string, originAccountID string, statusID string) ([]*gtsmodel.Mention, error) { + menchies := []*gtsmodel.Mention{} + for _, a := range targetAccounts { + // A mentioned account looks like "@test@example.org" or just "@test" for a local account + // -- we can guarantee this from the regex that targetAccounts should have been derived from. + // But we still need to do a bit of fiddling to get what we need here -- the username and domain (if given). + + // 1. trim off the first @ + t := strings.TrimPrefix(a, "@") + + // 2. split the username and domain + s := strings.Split(t, "@") + + // 3. if it's length 1 it's a local account, length 2 means remote, anything else means something is wrong + var local bool + switch len(s) { + case 1: + local = true + case 2: + local = false + default: + return nil, fmt.Errorf("mentioned account format '%s' was not valid", a) + } + + var username, domain string + username = s[0] + if !local { + domain = s[1] + } + + // 4. check we now have a proper username and domain + if username == "" || (!local && domain == "") { + return nil, fmt.Errorf("username or domain for '%s' was nil", a) + } + + // okay we're good now, we can start pulling accounts out of the database + mentionedAccount := >smodel.Account{} + var err error + if local { + // local user -- should have a null domain + err = ps.conn.Model(mentionedAccount).Where("username = ?", username).Where("? IS NULL", pg.Ident("domain")).Select() + } else { + // remote user -- should have domain defined + err = ps.conn.Model(mentionedAccount).Where("username = ?", username).Where("? = ?", pg.Ident("domain"), domain).Select() + } + + if err != nil { + if err == pg.ErrNoRows { + // no result found for this username/domain so just don't include it as a mencho and carry on about our business + ps.log.Debugf("no account found with username '%s' and domain '%s', skipping it", username, domain) + continue + } + // a serious error has happened so bail + return nil, fmt.Errorf("error getting account with username '%s' and domain '%s': %s", username, domain, err) + } + + // 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, + }) + } + return menchies, nil +} + +func (ps *postgresService) TagStringsToTags(tags []string, originAccountID string, statusID string) ([]*gtsmodel.Tag, error) { + newTags := []*gtsmodel.Tag{} + for _, t := range tags { + tag := >smodel.Tag{} + // we can use selectorinsert here to create the new tag if it doesn't exist already + // inserted will be true if this is a new tag we just created + if err := ps.conn.Model(tag).Where("name = ?", t).Select(); err != nil { + if err == pg.ErrNoRows { + // tag doesn't exist yet so populate it + tag.ID = uuid.NewString() + tag.Name = t + tag.FirstSeenFromAccountID = originAccountID + tag.CreatedAt = time.Now() + tag.UpdatedAt = time.Now() + tag.Useable = true + tag.Listable = true + } else { + return nil, fmt.Errorf("error getting tag with name %s: %s", t, err) + } + } + + // bail already if the tag isn't useable + if !tag.Useable { + continue + } + tag.LastStatusAt = time.Now() + newTags = append(newTags, tag) + } + return newTags, nil +} + +func (ps *postgresService) EmojiStringsToEmojis(emojis []string, originAccountID string, statusID string) ([]*gtsmodel.Emoji, error) { + newEmojis := []*gtsmodel.Emoji{} + for _, e := range emojis { + emoji := >smodel.Emoji{} + err := ps.conn.Model(emoji).Where("shortcode = ?", e).Where("visible_in_picker = true").Where("disabled = false").Select() + if err != nil { + if err == pg.ErrNoRows { + // no result found for this username/domain so just don't include it as an emoji and carry on about our business + ps.log.Debugf("no emoji found with shortcode %s, skipping it", e) + continue + } + // a serious error has happened so bail + return nil, fmt.Errorf("error getting emoji with shortcode %s: %s", e, err) + } + newEmojis = append(newEmojis, emoji) + } + return newEmojis, nil +} diff --git a/internal/db/pg_test.go b/internal/db/pg_test.go deleted file mode 100644 index a54784022..000000000 --- a/internal/db/pg_test.go +++ /dev/null @@ -1,21 +0,0 @@ -/* - 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 db_test - -// TODO: write tests for postgres diff --git a/internal/federation/clock.go b/internal/federation/clock.go index f0d6f5e84..cc67f8b73 100644 --- a/internal/federation/clock.go +++ b/internal/federation/clock.go @@ -37,6 +37,7 @@ func (c *Clock) Now() time.Time { return time.Now() } +// NewClock returns a simple pub.Clock for use in federation interfaces. func NewClock() pub.Clock { return &Clock{} } diff --git a/internal/federation/commonbehavior.go b/internal/federation/commonbehavior.go index 9274e78b4..8ed6fd2cb 100644 --- a/internal/federation/commonbehavior.go +++ b/internal/federation/commonbehavior.go @@ -57,7 +57,7 @@ import ( // authenticated must be true and error nil. The request will continue // to be processed. func (f *federator) AuthenticateGetInbox(ctx context.Context, w http.ResponseWriter, r *http.Request) (context.Context, bool, error) { - // IMPLEMENTATION NOTE: For GoToSocial, we serve outboxes and inboxes through + // IMPLEMENTATION NOTE: For GoToSocial, we serve GETS to outboxes and inboxes through // the CLIENT API, not through the federation API, so we just do nothing here. return nil, false, nil } @@ -82,7 +82,7 @@ func (f *federator) AuthenticateGetInbox(ctx context.Context, w http.ResponseWri // authenticated must be true and error nil. The request will continue // to be processed. func (f *federator) AuthenticateGetOutbox(ctx context.Context, w http.ResponseWriter, r *http.Request) (context.Context, bool, error) { - // IMPLEMENTATION NOTE: For GoToSocial, we serve outboxes and inboxes through + // IMPLEMENTATION NOTE: For GoToSocial, we serve GETS to outboxes and inboxes through // the CLIENT API, not through the federation API, so we just do nothing here. return nil, false, nil } @@ -96,7 +96,7 @@ func (f *federator) AuthenticateGetOutbox(ctx context.Context, w http.ResponseWr // Always called, regardless whether the Federated Protocol or Social // API is enabled. func (f *federator) GetOutbox(ctx context.Context, r *http.Request) (vocab.ActivityStreamsOrderedCollectionPage, error) { - // IMPLEMENTATION NOTE: For GoToSocial, we serve outboxes and inboxes through + // IMPLEMENTATION NOTE: For GoToSocial, we serve GETS to outboxes and inboxes through // the CLIENT API, not through the federation API, so we just do nothing here. return nil, nil } diff --git a/internal/federation/federating_db.go b/internal/federation/federating_db.go new file mode 100644 index 000000000..4ea0412e7 --- /dev/null +++ b/internal/federation/federating_db.go @@ -0,0 +1,599 @@ +/* + 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 federation + +import ( + "context" + "errors" + "fmt" + "net/url" + "sync" + + "github.com/go-fed/activity/pub" + "github.com/go-fed/activity/streams" + "github.com/go-fed/activity/streams/vocab" + "github.com/google/uuid" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +// 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 { + locks *sync.Map + db db.DB + config *config.Config + log *logrus.Logger + 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 { + return &federatingDB{ + locks: new(sync.Map), + db: db, + config: config, + log: log, + typeConverter: typeutils.NewConverter(config, db), + } +} + +/* + GO-FED DB INTERFACE-IMPLEMENTING FUNCTIONS +*/ + +// Lock takes a lock for the object at the specified id. If an error +// is returned, the lock must not have been taken. +// +// The lock must be able to succeed for an id that does not exist in +// the database. This means acquiring the lock does not guarantee the +// entry exists in the database. +// +// Locks are encouraged to be lightweight and in the Go layer, as some +// processes require tight loops acquiring and releasing locks. +// +// Used to ensure race conditions in multiple requests do not occur. +func (f *federatingDB) Lock(c context.Context, id *url.URL) error { + // Before any other Database methods are called, the relevant `id` + // entries are locked to allow for fine-grained concurrency. + + // Strategy: create a new lock, if stored, continue. Otherwise, lock the + // existing mutex. + mu := &sync.Mutex{} + mu.Lock() // Optimistically lock if we do store it. + i, loaded := f.locks.LoadOrStore(id.String(), mu) + if loaded { + mu = i.(*sync.Mutex) + mu.Lock() + } + return nil +} + +// Unlock makes the lock for the object at the specified id available. +// If an error is returned, the lock must have still been freed. +// +// Used to ensure race conditions in multiple requests do not occur. +func (f *federatingDB) Unlock(c context.Context, id *url.URL) error { + // Once Go-Fed is done calling Database methods, the relevant `id` + // entries are unlocked. + + i, ok := f.locks.Load(id.String()) + if !ok { + return errors.New("missing an id in unlock") + } + mu := i.(*sync.Mutex) + mu.Unlock() + return nil +} + +// InboxContains returns true if the OrderedCollection at 'inbox' +// contains the specified 'id'. +// +// The library makes this call only after acquiring a lock first. +func (f *federatingDB) InboxContains(c context.Context, inbox, id *url.URL) (contains bool, err error) { + l := f.log.WithFields( + logrus.Fields{ + "func": "InboxContains", + "id": id.String(), + }, + ) + l.Debugf("entering INBOXCONTAINS function with for inbox %s and id %s", inbox.String(), id.String()) + + if !util.IsInboxPath(inbox) { + return false, fmt.Errorf("%s is not an inbox URI", inbox.String()) + } + + activityI := c.Value(util.APActivity) + if activityI == nil { + return false, fmt.Errorf("no activity was set for id %s", id.String()) + } + activity, ok := activityI.(pub.Activity) + if !ok || activity == nil { + return false, fmt.Errorf("could not parse contextual activity for id %s", id.String()) + } + + l.Debugf("activity type %s for id %s", activity.GetTypeName(), id.String()) + + return false, nil + + // if err := f.db.GetByID(statusID, >smodel.Status{}); err != nil { + // if _, ok := err.(db.ErrNoEntries); ok { + // // we don't have it + // return false, nil + // } + // // actual error + // return false, fmt.Errorf("error getting status from db: %s", err) + // } + + // // we must have it + // return true, nil +} + +// GetInbox returns the first ordered collection page of the outbox at +// the specified IRI, for prepending new items. +// +// The library makes this call only after acquiring a lock first. +func (f *federatingDB) GetInbox(c context.Context, inboxIRI *url.URL) (inbox vocab.ActivityStreamsOrderedCollectionPage, err error) { + l := f.log.WithFields( + logrus.Fields{ + "func": "GetInbox", + }, + ) + l.Debugf("entering GETINBOX function with inboxIRI %s", inboxIRI.String()) + return streams.NewActivityStreamsOrderedCollectionPage(), nil +} + +// SetInbox saves the inbox value given from GetInbox, with new items +// prepended. Note that the new items must not be added as independent +// database entries. Separate calls to Create will do that. +// +// The library makes this call only after acquiring a lock first. +func (f *federatingDB) SetInbox(c context.Context, inbox vocab.ActivityStreamsOrderedCollectionPage) error { + l := f.log.WithFields( + logrus.Fields{ + "func": "SetInbox", + }, + ) + l.Debug("entering SETINBOX function") + return nil +} + +// Owns returns true if the IRI belongs to this instance, and if +// the database has an entry for the IRI. +// The library makes this call only after acquiring a lock first. +func (f *federatingDB) Owns(c context.Context, id *url.URL) (bool, error) { + l := f.log.WithFields( + logrus.Fields{ + "func": "Owns", + "id": id.String(), + }, + ) + l.Debugf("entering OWNS function with id %s", id.String()) + + // if the id host isn't this instance host, we don't own this IRI + if id.Host != f.config.Host { + l.Debugf("we DO NOT own activity because the host is %s not %s", id.Host, f.config.Host) + return false, nil + } + + // apparently it belongs to this host, so what *is* it? + + // check if it's a status, eg /users/example_username/statuses/SOME_UUID_OF_A_STATUS + if util.IsStatusesPath(id) { + _, uid, err := util.ParseStatusesPath(id) + 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 _, ok := err.(db.ErrNoEntries); ok { + // there are no entries for this status + return false, nil + } + // an actual error happened + return false, fmt.Errorf("database error fetching status with id %s: %s", uid, err) + } + l.Debug("we DO own this") + return true, nil + } + + // check if it's a user, eg /users/example_username + if util.IsUserPath(id) { + username, err := util.ParseUserPath(id) + if err != nil { + return false, fmt.Errorf("error parsing statuses path for url %s: %s", id.String(), err) + } + if err := f.db.GetLocalAccountByUsername(username, >smodel.Account{}); err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + // there are no entries for this username + return false, nil + } + // an actual error happened + return false, fmt.Errorf("database error fetching account with username %s: %s", username, err) + } + l.Debug("we DO own this") + return true, nil + } + + return false, fmt.Errorf("could not match activityID: %s", id.String()) +} + +// ActorForOutbox fetches the actor's IRI for the given outbox IRI. +// +// The library makes this call only after acquiring a lock first. +func (f *federatingDB) ActorForOutbox(c context.Context, outboxIRI *url.URL) (actorIRI *url.URL, err error) { + l := f.log.WithFields( + logrus.Fields{ + "func": "ActorForOutbox", + "inboxIRI": outboxIRI.String(), + }, + ) + l.Debugf("entering ACTORFOROUTBOX function with outboxIRI %s", outboxIRI.String()) + + if !util.IsOutboxPath(outboxIRI) { + 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 _, ok := err.(db.ErrNoEntries); ok { + return nil, fmt.Errorf("no actor found that corresponds to outbox %s", outboxIRI.String()) + } + return nil, fmt.Errorf("db error searching for actor with outbox %s", outboxIRI.String()) + } + return url.Parse(acct.URI) +} + +// ActorForInbox fetches the actor's IRI for the given outbox IRI. +// +// The library makes this call only after acquiring a lock first. +func (f *federatingDB) ActorForInbox(c context.Context, inboxIRI *url.URL) (actorIRI *url.URL, err error) { + l := f.log.WithFields( + logrus.Fields{ + "func": "ActorForInbox", + "inboxIRI": inboxIRI.String(), + }, + ) + l.Debugf("entering ACTORFORINBOX function with inboxIRI %s", inboxIRI.String()) + + if !util.IsInboxPath(inboxIRI) { + 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 _, ok := err.(db.ErrNoEntries); ok { + return nil, fmt.Errorf("no actor found that corresponds to inbox %s", inboxIRI.String()) + } + return nil, fmt.Errorf("db error searching for actor with inbox %s", inboxIRI.String()) + } + return url.Parse(acct.URI) +} + +// OutboxForInbox fetches the corresponding actor's outbox IRI for the +// actor's inbox IRI. +// +// The library makes this call only after acquiring a lock first. +func (f *federatingDB) OutboxForInbox(c context.Context, inboxIRI *url.URL) (outboxIRI *url.URL, err error) { + l := f.log.WithFields( + logrus.Fields{ + "func": "OutboxForInbox", + "inboxIRI": inboxIRI.String(), + }, + ) + l.Debugf("entering OUTBOXFORINBOX function with inboxIRI %s", inboxIRI.String()) + + if !util.IsInboxPath(inboxIRI) { + 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 _, ok := err.(db.ErrNoEntries); ok { + return nil, fmt.Errorf("no actor found that corresponds to inbox %s", inboxIRI.String()) + } + return nil, fmt.Errorf("db error searching for actor with inbox %s", inboxIRI.String()) + } + return url.Parse(acct.OutboxURI) +} + +// Exists returns true if the database has an entry for the specified +// id. It may not be owned by this application instance. +// +// The library makes this call only after acquiring a lock first. +func (f *federatingDB) Exists(c context.Context, id *url.URL) (exists bool, err error) { + l := f.log.WithFields( + logrus.Fields{ + "func": "Exists", + "id": id.String(), + }, + ) + l.Debugf("entering EXISTS function with id %s", id.String()) + + return false, nil +} + +// Get returns the database entry for the specified id. +// +// The library makes this call only after acquiring a lock first. +func (f *federatingDB) Get(c context.Context, id *url.URL) (value vocab.Type, err error) { + l := f.log.WithFields( + logrus.Fields{ + "func": "Get", + "id": id.String(), + }, + ) + l.Debug("entering GET function") + + if util.IsUserPath(id) { + acct := >smodel.Account{} + if err := f.db.GetWhere("uri", id.String(), acct); err != nil { + return nil, err + } + return f.typeConverter.AccountToAS(acct) + } + + return nil, nil +} + +// Create adds a new entry to the database which must be able to be +// keyed by its id. +// +// Note that Activity values received from federated peers may also be +// created in the database this way if the Federating Protocol is +// enabled. The client may freely decide to store only the id instead of +// the entire value. +// +// The library makes this call only after acquiring a lock first. +// +// Under certain conditions and network activities, Create may be called +// multiple times for the same ActivityStreams object. +func (f *federatingDB) Create(c context.Context, asType vocab.Type) error { + l := f.log.WithFields( + logrus.Fields{ + "func": "Create", + "asType": asType.GetTypeName(), + }, + ) + l.Debugf("received CREATE asType %+v", asType) + + switch gtsmodel.ActivityStreamsActivity(asType.GetTypeName()) { + case gtsmodel.ActivityStreamsCreate: + create, ok := asType.(vocab.ActivityStreamsCreate) + if !ok { + return errors.New("could not convert type to create") + } + object := create.GetActivityStreamsObject() + for objectIter := object.Begin(); objectIter != object.End(); objectIter = objectIter.Next() { + switch gtsmodel.ActivityStreamsObject(objectIter.GetType().GetTypeName()) { + case gtsmodel.ActivityStreamsNote: + note := objectIter.GetActivityStreamsNote() + status, err := f.typeConverter.ASStatusToStatus(note) + if err != nil { + return fmt.Errorf("error converting note to status: %s", err) + } + if err := f.db.Put(status); err != nil { + return fmt.Errorf("database error inserting status: %s", err) + } + } + } + case gtsmodel.ActivityStreamsFollow: + follow, ok := asType.(vocab.ActivityStreamsFollow) + if !ok { + return errors.New("could not convert type to follow") + } + + followRequest, err := f.typeConverter.ASFollowToFollowRequest(follow) + if err != nil { + return fmt.Errorf("could not convert Follow to follow request: %s", err) + } + + if err := f.db.Put(followRequest); err != nil { + return fmt.Errorf("database error inserting follow request: %s", err) + } + } + return nil +} + +// Update sets an existing entry to the database based on the value's +// id. +// +// Note that Activity values received from federated peers may also be +// updated in the database this way if the Federating Protocol is +// enabled. The client may freely decide to store only the id instead of +// 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 { + l := f.log.WithFields( + logrus.Fields{ + "func": "Update", + "asType": asType.GetTypeName(), + }, + ) + l.Debugf("received UPDATE asType %+v", asType) + return nil +} + +// Delete removes the entry with the given id. +// +// Delete is only called for federated objects. Deletes from the Social +// Protocol instead call Update to create a Tombstone. +// +// The library makes this call only after acquiring a lock first. +func (f *federatingDB) Delete(c context.Context, id *url.URL) error { + l := f.log.WithFields( + logrus.Fields{ + "func": "Delete", + "id": id.String(), + }, + ) + l.Debugf("received DELETE id %s", id.String()) + return nil +} + +// GetOutbox returns the first ordered collection page of the outbox +// at the specified IRI, for prepending new items. +// +// The library makes this call only after acquiring a lock first. +func (f *federatingDB) GetOutbox(c context.Context, outboxIRI *url.URL) (inbox vocab.ActivityStreamsOrderedCollectionPage, err error) { + l := f.log.WithFields( + logrus.Fields{ + "func": "GetOutbox", + }, + ) + l.Debug("entering GETOUTBOX function") + + return nil, nil +} + +// SetOutbox saves the outbox value given from GetOutbox, with new items +// prepended. Note that the new items must not be added as independent +// database entries. Separate calls to Create will do that. +// +// The library makes this call only after acquiring a lock first. +func (f *federatingDB) SetOutbox(c context.Context, outbox vocab.ActivityStreamsOrderedCollectionPage) error { + l := f.log.WithFields( + logrus.Fields{ + "func": "SetOutbox", + }, + ) + l.Debug("entering SETOUTBOX function") + + return nil +} + +// NewID creates a new IRI id for the provided activity or object. The +// implementation does not need to set the 'id' property and simply +// needs to determine the value. +// +// The go-fed library will handle setting the 'id' property on the +// activity or object provided with the value returned. +func (f *federatingDB) NewID(c context.Context, t vocab.Type) (id *url.URL, err error) { + l := f.log.WithFields( + logrus.Fields{ + "func": "NewID", + "asType": t.GetTypeName(), + }, + ) + l.Debugf("received NEWID request for asType %+v", t) + + return url.Parse(fmt.Sprintf("%s://%s/", f.config.Protocol, uuid.NewString())) +} + +// Followers obtains the Followers Collection for an actor with the +// given id. +// +// If modified, the library will then call Update. +// +// The library makes this call only after acquiring a lock first. +func (f *federatingDB) Followers(c context.Context, actorIRI *url.URL) (followers vocab.ActivityStreamsCollection, err error) { + l := f.log.WithFields( + logrus.Fields{ + "func": "Followers", + "actorIRI": actorIRI.String(), + }, + ) + l.Debugf("entering FOLLOWERS function with actorIRI %s", actorIRI.String()) + + acct := >smodel.Account{} + if err := f.db.GetWhere("uri", actorIRI.String(), acct); err != nil { + return nil, fmt.Errorf("db error getting account with uri %s: %s", actorIRI.String(), err) + } + + acctFollowers := []gtsmodel.Follow{} + if err := f.db.GetFollowersByAccountID(acct.ID, &acctFollowers); err != nil { + return nil, fmt.Errorf("db error getting followers for account id %s: %s", acct.ID, err) + } + + followers = streams.NewActivityStreamsCollection() + items := streams.NewActivityStreamsItemsProperty() + for _, follow := range acctFollowers { + gtsFollower := >smodel.Account{} + if err := f.db.GetByID(follow.AccountID, gtsFollower); err != nil { + return nil, fmt.Errorf("db error getting account id %s: %s", follow.AccountID, err) + } + uri, err := url.Parse(gtsFollower.URI) + if err != nil { + return nil, fmt.Errorf("error parsing %s as url: %s", gtsFollower.URI, err) + } + items.AppendIRI(uri) + } + followers.SetActivityStreamsItems(items) + return +} + +// Following obtains the Following Collection for an actor with the +// given id. +// +// If modified, the library will then call Update. +// +// The library makes this call only after acquiring a lock first. +func (f *federatingDB) Following(c context.Context, actorIRI *url.URL) (following vocab.ActivityStreamsCollection, err error) { + l := f.log.WithFields( + logrus.Fields{ + "func": "Following", + "actorIRI": actorIRI.String(), + }, + ) + l.Debugf("entering FOLLOWING function with actorIRI %s", actorIRI.String()) + + acct := >smodel.Account{} + if err := f.db.GetWhere("uri", actorIRI.String(), acct); err != nil { + return nil, fmt.Errorf("db error getting account with uri %s: %s", actorIRI.String(), err) + } + + acctFollowing := []gtsmodel.Follow{} + if err := f.db.GetFollowingByAccountID(acct.ID, &acctFollowing); err != nil { + return nil, fmt.Errorf("db error getting following for account id %s: %s", acct.ID, err) + } + + following = streams.NewActivityStreamsCollection() + items := streams.NewActivityStreamsItemsProperty() + for _, follow := range acctFollowing { + gtsFollowing := >smodel.Account{} + if err := f.db.GetByID(follow.AccountID, gtsFollowing); err != nil { + return nil, fmt.Errorf("db error getting account id %s: %s", follow.AccountID, err) + } + uri, err := url.Parse(gtsFollowing.URI) + if err != nil { + return nil, fmt.Errorf("error parsing %s as url: %s", gtsFollowing.URI, err) + } + items.AppendIRI(uri) + } + following.SetActivityStreamsItems(items) + return +} + +// Liked obtains the Liked Collection for an actor with the +// given id. +// +// If modified, the library will then call Update. +// +// The library makes this call only after acquiring a lock first. +func (f *federatingDB) Liked(c context.Context, actorIRI *url.URL) (liked vocab.ActivityStreamsCollection, err error) { + l := f.log.WithFields( + logrus.Fields{ + "func": "Liked", + "actorIRI": actorIRI.String(), + }, + ) + l.Debugf("entering LIKED function with actorIRI %s", actorIRI.String()) + return nil, nil +} diff --git a/internal/federation/federating_db_test.go b/internal/federation/federating_db_test.go new file mode 100644 index 000000000..b4695b55b --- /dev/null +++ b/internal/federation/federating_db_test.go @@ -0,0 +1,21 @@ +/* + 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 federation + +// TODO: write tests for pgfed diff --git a/internal/federation/federatingactor.go b/internal/federation/federatingactor.go index f105d9125..65a16efaf 100644 --- a/internal/federation/federatingactor.go +++ b/internal/federation/federatingactor.go @@ -77,6 +77,13 @@ func (f *federatingActor) PostInbox(c context.Context, w http.ResponseWriter, r return f.actor.PostInbox(c, w, r) } +// PostInboxScheme is similar to PostInbox, except clients are able to +// specify which protocol scheme to handle the incoming request and the +// data stored within the application (HTTP, HTTPS, etc). +func (f *federatingActor) PostInboxScheme(c context.Context, w http.ResponseWriter, r *http.Request, scheme string) (bool, error) { + return f.actor.PostInboxScheme(c, w, r, scheme) +} + // GetInbox returns true if the request was handled as an ActivityPub // GET to an actor's inbox. If false, the request was not an ActivityPub // request and may still be handled by the caller in another way, such @@ -118,6 +125,13 @@ func (f *federatingActor) PostOutbox(c context.Context, w http.ResponseWriter, r return f.actor.PostOutbox(c, w, r) } +// PostOutboxScheme is similar to PostOutbox, except clients are able to +// specify which protocol scheme to handle the incoming request and the +// data stored within the application (HTTP, HTTPS, etc). +func (f *federatingActor) PostOutboxScheme(c context.Context, w http.ResponseWriter, r *http.Request, scheme string) (bool, error) { + return f.actor.PostOutboxScheme(c, w, r, scheme) +} + // GetOutbox returns true if the request was handled as an ActivityPub // GET to an actor's outbox. If false, the request was not an // ActivityPub request. diff --git a/internal/federation/federatingprotocol.go b/internal/federation/federatingprotocol.go index 1764eb791..0d2a8d9dd 100644 --- a/internal/federation/federatingprotocol.go +++ b/internal/federation/federatingprotocol.go @@ -72,8 +72,49 @@ func (f *federator) PostInboxRequestBodyHook(ctx context.Context, r *http.Reques return nil, err } - ctxWithActivity := context.WithValue(ctx, util.APActivity, activity) - return ctxWithActivity, nil + // derefence the actor of the activity already + // var requestingActorIRI *url.URL + // actorProp := activity.GetActivityStreamsActor() + // if actorProp != nil { + // for i := actorProp.Begin(); i != actorProp.End(); i = i.Next() { + // if i.IsIRI() { + // requestingActorIRI = i.GetIRI() + // break + // } + // } + // } + // if requestingActorIRI != nil { + + // requestedAccountI := ctx.Value(util.APAccount) + // requestedAccount, ok := requestedAccountI.(*gtsmodel.Account) + // if !ok { + // return nil, errors.New("requested account was not set on request context") + // } + + // requestingActor := >smodel.Account{} + // if err := f.db.GetWhere("uri", requestingActorIRI.String(), requestingActor); err != nil { + // // there's been a proper error so return it + // if _, ok := err.(db.ErrNoEntries); !ok { + // return nil, fmt.Errorf("error getting requesting actor with id %s: %s", requestingActorIRI.String(), err) + // } + + // // we don't know this account (yet) so let's dereference it right now + // person, err := f.DereferenceRemoteAccount(requestedAccount.Username, publicKeyOwnerURI) + // if err != nil { + // return ctx, false, fmt.Errorf("error dereferencing account with public key id %s: %s", publicKeyOwnerURI.String(), err) + // } + + // a, err := f.typeConverter.ASRepresentationToAccount(person) + // if err != nil { + // return ctx, false, fmt.Errorf("error converting person with public key id %s to account: %s", publicKeyOwnerURI.String(), err) + // } + // requestingAccount = a + // } + // } + + // set the activity on the context for use later on + + return context.WithValue(ctx, util.APActivity, activity), nil } // AuthenticatePostInbox delegates the authentication of a POST to an @@ -100,14 +141,22 @@ func (f *federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWr }) l.Trace("received request to authenticate") - requestedAccountI := ctx.Value(util.APAccount) - if requestedAccountI == nil { - return ctx, false, errors.New("requested account not set in context") + if !util.IsInboxPath(r.URL) { + return nil, false, fmt.Errorf("path %s was not an inbox path", r.URL.String()) } - requestedAccount, ok := requestedAccountI.(*gtsmodel.Account) - if !ok || requestedAccount == nil { - return ctx, false, errors.New("requested account not parsebale from context") + username, err := util.ParseInboxPath(r.URL) + if err != nil { + return nil, false, fmt.Errorf("could not parse path %s: %s", r.URL.String(), err) + } + + if username == "" { + return nil, false, errors.New("username was empty") + } + + requestedAccount := >smodel.Account{} + if err := f.db.GetLocalAccountByUsername(username, requestedAccount); err != nil { + return nil, false, fmt.Errorf("could not fetch requested account with username %s: %s", username, err) } publicKeyOwnerURI, err := f.AuthenticateFederatedRequest(requestedAccount.Username, r) @@ -124,7 +173,6 @@ func (f *federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWr } // we don't know this account (yet) so let's dereference it right now - // TODO: slow-fed person, err := f.DereferenceRemoteAccount(requestedAccount.Username, publicKeyOwnerURI) if err != nil { return ctx, false, fmt.Errorf("error dereferencing account with public key id %s: %s", publicKeyOwnerURI.String(), err) @@ -134,12 +182,17 @@ func (f *federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWr if err != nil { return ctx, false, fmt.Errorf("error converting person with public key id %s to account: %s", publicKeyOwnerURI.String(), err) } + + if err := f.db.Put(a); err != nil { + l.Errorf("error inserting dereferenced remote account: %s", err) + } + requestingAccount = a } - contextWithRequestingAccount := context.WithValue(ctx, util.APRequestingAccount, requestingAccount) - - return contextWithRequestingAccount, true, nil + withRequester := context.WithValue(ctx, util.APRequestingAccount, requestingAccount) + withRequested := context.WithValue(withRequester, util.APAccount, requestedAccount) + return withRequested, true, nil } // Blocked should determine whether to permit a set of actors given by @@ -156,8 +209,40 @@ func (f *federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWr // Finally, if the authentication and authorization succeeds, then // blocked must be false and error nil. The request will continue // to be processed. +// +// TODO: implement domain block checking here as well func (f *federator) Blocked(ctx context.Context, actorIRIs []*url.URL) (bool, error) { - // TODO + l := f.log.WithFields(logrus.Fields{ + "func": "Blocked", + }) + l.Debugf("entering BLOCKED function with IRI list: %+v", actorIRIs) + + requestedAccountI := ctx.Value(util.APAccount) + requestedAccount, ok := requestedAccountI.(*gtsmodel.Account) + if !ok { + f.log.Errorf("requested account not set on request context") + return false, errors.New("requested account not set on request context, so couldn't determine blocks") + } + + for _, uri := range actorIRIs { + a := >smodel.Account{} + if err := f.db.GetWhere("uri", 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 + // TODO: allow a different default to be set for this behavior + continue + } + return false, fmt.Errorf("error getting account with uri %s: %s", uri.String(), err) + } + blocked, err := f.db.Blocked(requestedAccount.ID, a.ID) + if err != nil { + return false, fmt.Errorf("error checking account blocks: %s", err) + } + if blocked { + return true, nil + } + } return false, nil } @@ -180,9 +265,40 @@ func (f *federator) Blocked(ctx context.Context, actorIRIs []*url.URL) (bool, er // // Applications are not expected to handle every single ActivityStreams // type and extension. The unhandled ones are passed to DefaultCallback. -func (f *federator) FederatingCallbacks(ctx context.Context) (pub.FederatingWrappedCallbacks, []interface{}, error) { - // TODO - return pub.FederatingWrappedCallbacks{}, nil, nil +func (f *federator) FederatingCallbacks(ctx context.Context) (wrapped pub.FederatingWrappedCallbacks, other []interface{}, err error) { + l := f.log.WithFields(logrus.Fields{ + "func": "FederatingCallbacks", + }) + + targetAcctI := ctx.Value(util.APAccount) + if targetAcctI == nil { + l.Error("target account wasn't set on context") + } + targetAcct, ok := targetAcctI.(*gtsmodel.Account) + if !ok { + l.Error("target account was set on context but couldn't be parsed") + } + + var onFollow pub.OnFollowBehavior = pub.OnFollowAutomaticallyAccept + if targetAcct.Locked { + onFollow = pub.OnFollowDoNothing + } + + wrapped = pub.FederatingWrappedCallbacks{ + // Follow handles additional side effects for the Follow ActivityStreams + // type, specific to the application using go-fed. + // + // The wrapping function can have one of several default behaviors, + // depending on the value of the OnFollow setting. + Follow: func(context.Context, vocab.ActivityStreamsFollow) error { + return nil + }, + // OnFollow determines what action to take for this particular callback + // if a Follow Activity is handled. + OnFollow: onFollow, + } + + return } // DefaultCallback is called for types that go-fed can deserialize but @@ -207,7 +323,7 @@ func (f *federator) DefaultCallback(ctx context.Context, activity pub.Activity) // Zero or negative numbers indicate infinite recursion. func (f *federator) MaxInboxForwardingRecursionDepth(ctx context.Context) int { // TODO - return 0 + return 4 } // MaxDeliveryRecursionDepth determines how deep to search within @@ -217,7 +333,7 @@ func (f *federator) MaxInboxForwardingRecursionDepth(ctx context.Context) int { // Zero or negative numbers indicate infinite recursion. func (f *federator) MaxDeliveryRecursionDepth(ctx context.Context) int { // TODO - return 0 + return 4 } // FilterForwarding allows the implementation to apply business logic @@ -241,7 +357,7 @@ func (f *federator) FilterForwarding(ctx context.Context, potentialRecipients [] // Always called, regardless whether the Federated Protocol or Social // API is enabled. func (f *federator) GetInbox(ctx context.Context, r *http.Request) (vocab.ActivityStreamsOrderedCollectionPage, error) { - // IMPLEMENTATION NOTE: For GoToSocial, we serve outboxes and inboxes through + // IMPLEMENTATION NOTE: For GoToSocial, we serve GETS to outboxes and inboxes through // the CLIENT API, not through the federation API, so we just do nothing here. return nil, nil } diff --git a/internal/gotosocial/actions.go b/internal/gotosocial/actions.go index 5790456dd..fb83a4231 100644 --- a/internal/gotosocial/actions.go +++ b/internal/gotosocial/actions.go @@ -34,6 +34,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/api/client/app" "github.com/superseriousbusiness/gotosocial/internal/api/client/auth" "github.com/superseriousbusiness/gotosocial/internal/api/client/fileserver" + "github.com/superseriousbusiness/gotosocial/internal/api/client/followrequest" "github.com/superseriousbusiness/gotosocial/internal/api/client/instance" mediaModule "github.com/superseriousbusiness/gotosocial/internal/api/client/media" "github.com/superseriousbusiness/gotosocial/internal/api/client/status" @@ -41,7 +42,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/api/s2s/webfinger" "github.com/superseriousbusiness/gotosocial/internal/api/security" "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/db/pg" "github.com/superseriousbusiness/gotosocial/internal/federation" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" @@ -78,7 +79,7 @@ var models []interface{} = []interface{}{ // Run creates and starts a gotosocial server var Run action.GTSAction = func(ctx context.Context, c *config.Config, log *logrus.Logger) error { - dbService, err := db.NewPostgresService(ctx, c, log) + dbService, err := pg.NewPostgresService(ctx, c, log) if err != nil { return fmt.Errorf("error creating dbservice: %s", err) } @@ -111,6 +112,7 @@ var Run action.GTSAction = func(ctx context.Context, c *config.Config, log *logr accountModule := account.New(c, processor, log) instanceModule := instance.New(c, processor, log) appsModule := app.New(c, processor, log) + followRequestsModule := followrequest.New(c, processor, log) webfingerModule := webfinger.New(c, processor, log) usersModule := user.New(c, processor, log) mm := mediaModule.New(c, processor, log) @@ -128,6 +130,7 @@ var Run action.GTSAction = func(ctx context.Context, c *config.Config, log *logr accountModule, instanceModule, appsModule, + followRequestsModule, mm, fileServerModule, adminModule, diff --git a/internal/gtsmodel/account.go b/internal/gtsmodel/account.go index 1903216f8..56c401e62 100644 --- a/internal/gtsmodel/account.go +++ b/internal/gtsmodel/account.go @@ -76,15 +76,15 @@ type Account struct { */ // Does this account need an approval for new followers? - Locked bool `pg:",default:true"` + Locked bool `pg:",default:'true'"` // 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 `pg:",default:'false'"` // What language does this account post in? - Language string `pg:",default:en"` + Language string `pg:",default:'en'"` /* ACTIVITYPUB THINGS diff --git a/internal/gtsmodel/mention.go b/internal/gtsmodel/mention.go index 18eb11082..8e56a1b36 100644 --- a/internal/gtsmodel/mention.go +++ b/internal/gtsmodel/mention.go @@ -30,10 +30,22 @@ type Mention struct { CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` // When was this mention last updated? UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` - // Who created this mention? + // What's the internal account ID of the originator of the mention? OriginAccountID string `pg:",notnull"` - // Who does this mention target? + // What's the AP URI of the originator of the mention? + OriginAccountURI string `pg:",notnull"` + // What's the internal account ID of the mention target? TargetAccountID string `pg:",notnull"` // Prevent this mention from generating a notification? Silent bool + // 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 + // + // This will not be put in the database, it's just for convenience. + NameString string `pg:"-"` + // MentionedAccountURI is the AP ID (uri) of the user mentioned. + // + // This will not be put in the database, it's just for convenience. + MentionedAccountURI string `pg:"-"` } diff --git a/internal/gtsmodel/status.go b/internal/gtsmodel/status.go index 55ae91599..8693bce30 100644 --- a/internal/gtsmodel/status.go +++ b/internal/gtsmodel/status.go @@ -71,12 +71,14 @@ type Status struct { Text string /* - NON-DATABASE FIELDS + INTERNAL MODEL NON-DATABASE FIELDS These are for convenience while passing the status around internally, but these fields should *never* be put in the db. */ + // Account that created this status + GTSAccount *Account `pg:"-"` // Mentions created in this status GTSMentions []*Mention `pg:"-"` // Hashtags used in this status @@ -93,6 +95,20 @@ type Status struct { GTSBoostedStatus *Status `pg:"-"` // Account of the boosted status GTSBoostedAccount *Account `pg:"-"` + + /* + AP NON-DATABASE FIELDS + + These are for convenience while passing the status around internally, + but these fields should *never* be put in the db. + */ + + // AP URI of the status being replied to. + // Useful when that status doesn't exist in the database yet and we still need to dereference it. + APReplyToStatusURI string `pg:"-"` + // The AP URI of the owner/creator of the status. + // Useful when that account doesn't exist in the database yet and we still need to dereference it. + APStatusOwnerURI string `pg:"-"` } // Visibility represents the visibility granularity of a status. diff --git a/internal/gtsmodel/tag.go b/internal/gtsmodel/tag.go index 83c471958..c1b0429d6 100644 --- a/internal/gtsmodel/tag.go +++ b/internal/gtsmodel/tag.go @@ -24,6 +24,8 @@ import "time" type Tag struct { // id of this tag in the database ID string `pg:",unique,type:uuid,default:gen_random_uuid(),pk,notnull"` + // Href of this tag, eg https://example.org/tags/somehashtag + URL string // name of this tag -- the tag without the hash part Name string `pg:",unique,pk,notnull"` // Which account ID is the first one we saw using this tag? diff --git a/internal/media/media_test.go b/internal/media/media_test.go index 8045295d2..03dcdc21d 100644 --- a/internal/media/media_test.go +++ b/internal/media/media_test.go @@ -29,6 +29,7 @@ import ( "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/db/pg" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/storage" ) @@ -78,7 +79,7 @@ func (suite *MediaTestSuite) SetupSuite() { } suite.config = c // use an actual database for this, because it's just easier than mocking one out - database, err := db.NewPostgresService(context.Background(), c, log) + database, err := pg.NewPostgresService(context.Background(), c, log) if err != nil { suite.FailNow(err.Error()) } diff --git a/internal/message/fediprocess.go b/internal/message/fediprocess.go index dad1e848c..133e7dbaa 100644 --- a/internal/message/fediprocess.go +++ b/internal/message/fediprocess.go @@ -1,6 +1,7 @@ package message import ( + "context" "fmt" "net/http" @@ -8,6 +9,7 @@ import ( apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/util" ) // authenticateAndDereferenceFediRequest authenticates the HTTP signature of an incoming federation request, using the given @@ -130,3 +132,9 @@ func (p *processor) GetWebfingerAccount(requestedUsername string, request *http. }, }, nil } + +func (p *processor) InboxPost(ctx context.Context, w http.ResponseWriter, r *http.Request) (bool, error) { + contextWithChannel := context.WithValue(ctx, util.APFromFederatorChanKey, p.fromFederator) + posted, err := p.federator.FederatingActor().PostInbox(contextWithChannel, w, r) + return posted, err +} diff --git a/internal/message/frprocess.go b/internal/message/frprocess.go new file mode 100644 index 000000000..c96b83dec --- /dev/null +++ b/internal/message/frprocess.go @@ -0,0 +1,42 @@ +package message + +import ( + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +func (p *processor) FollowRequestsGet(auth *oauth.Auth) ([]apimodel.Account, ErrorWithCode) { + frs := []gtsmodel.FollowRequest{} + if err := p.db.GetFollowRequestsForAccountID(auth.Account.ID, &frs); err != nil { + if _, ok := err.(db.ErrNoEntries); !ok { + return nil, NewErrorInternalError(err) + } + } + + accts := []apimodel.Account{} + for _, fr := range frs { + acct := >smodel.Account{} + if err := p.db.GetByID(fr.AccountID, acct); err != nil { + return nil, NewErrorInternalError(err) + } + mastoAcct, err := p.tc.AccountToMastoPublic(acct) + if err != nil { + return nil, NewErrorInternalError(err) + } + accts = append(accts, *mastoAcct) + } + 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) + } + return nil +} + +func (p *processor) FollowRequestDeny(auth *oauth.Auth) ErrorWithCode { + return nil +} diff --git a/internal/message/processor.go b/internal/message/processor.go index c8d58a346..7fc850e37 100644 --- a/internal/message/processor.go +++ b/internal/message/processor.go @@ -19,7 +19,11 @@ package message import ( + "context" + "errors" + "fmt" "net/http" + "net/url" "github.com/sirupsen/logrus" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" @@ -77,6 +81,11 @@ type Processor interface { // FileGet handles the fetching of a media attachment file via the fileserver. FileGet(authed *oauth.Auth, form *apimodel.GetContentRequestForm) (*apimodel.Content, error) + // 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 + // InstanceGet retrieves instance information for serving at api/v1/instance InstanceGet(domain string) (*apimodel.Instance, ErrorWithCode) @@ -116,6 +125,18 @@ type Processor interface { // 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) + + // InboxPost handles POST requests to a user's inbox for new activitypub messages. + // + // InboxPost returns true if the request was handled as an ActivityPub POST to an actor's inbox. + // If false, the request was not an ActivityPub request and may still be handled by the caller in another way, such as serving a web page. + // + // If the error is nil, then the ResponseWriter's headers and response has already been written. If a non-nil error is returned, then no response has been written. + // + // If the Actor was constructed with the Federated Protocol enabled, side effects will occur. + // + // If the Federated Protocol is not enabled, writes the http.StatusMethodNotAllowed status code in the response. No side effects occur. + InboxPost(ctx context.Context, w http.ResponseWriter, r *http.Request) (bool, error) } // processor just implements the Processor interface @@ -181,6 +202,9 @@ func (p *processor) Start() error { 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: @@ -227,3 +251,54 @@ type FromFederator struct { APActivityType gtsmodel.ActivityStreamsActivity Activity interface{} } + +func (p *processor) processFromClientAPI(clientMsg FromClientAPI) error { + switch clientMsg.APObjectType { + case gtsmodel.ActivityStreamsNote: + status, ok := clientMsg.Activity.(*gtsmodel.Status) + if !ok { + return errors.New("note was not parseable as *gtsmodel.Status") + } + + if err := p.notifyStatus(status); err != nil { + return err + } + + if status.VisibilityAdvanced.Federated { + return p.federateStatus(status) + } + return nil + } + return fmt.Errorf("message type unprocessable: %+v", clientMsg) +} + +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 + } + } + + outboxURI, err := url.Parse(sendingAcct.OutboxURI) + if err != nil { + return 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 err +} + +func (p *processor) notifyStatus(status *gtsmodel.Status) error { + return nil +} diff --git a/internal/message/processorutil.go b/internal/message/processorutil.go index c928eec1a..233a18ad8 100644 --- a/internal/message/processorutil.go +++ b/internal/message/processorutil.go @@ -179,7 +179,7 @@ func (p *processor) processLanguage(form *apimodel.AdvancedStatusCreateForm, acc func (p *processor) processMentions(form *apimodel.AdvancedStatusCreateForm, accountID string, status *gtsmodel.Status) error { menchies := []string{} - gtsMenchies, err := p.db.MentionStringsToMentions(util.DeriveMentions(form.Status), accountID, status.ID) + gtsMenchies, err := p.db.MentionStringsToMentions(util.DeriveMentionsFromStatus(form.Status), accountID, status.ID) if err != nil { return fmt.Errorf("error generating mentions from status: %s", err) } @@ -198,7 +198,7 @@ func (p *processor) processMentions(form *apimodel.AdvancedStatusCreateForm, acc func (p *processor) processTags(form *apimodel.AdvancedStatusCreateForm, accountID string, status *gtsmodel.Status) error { tags := []string{} - gtsTags, err := p.db.TagStringsToTags(util.DeriveHashtags(form.Status), accountID, status.ID) + gtsTags, err := p.db.TagStringsToTags(util.DeriveHashtagsFromStatus(form.Status), accountID, status.ID) if err != nil { return fmt.Errorf("error generating hashtags from status: %s", err) } @@ -217,7 +217,7 @@ func (p *processor) processTags(form *apimodel.AdvancedStatusCreateForm, account func (p *processor) processEmojis(form *apimodel.AdvancedStatusCreateForm, accountID string, status *gtsmodel.Status) error { emojis := []string{} - gtsEmojis, err := p.db.EmojiStringsToEmojis(util.DeriveEmojis(form.Status), accountID, status.ID) + gtsEmojis, err := p.db.EmojiStringsToEmojis(util.DeriveEmojisFromStatus(form.Status), accountID, status.ID) if err != nil { return fmt.Errorf("error generating emojis from status: %s", err) } diff --git a/internal/message/statusprocess.go b/internal/message/statusprocess.go index bd4329082..d9d115aec 100644 --- a/internal/message/statusprocess.go +++ b/internal/message/statusprocess.go @@ -81,6 +81,13 @@ func (p *processor) StatusCreate(auth *oauth.Auth, form *apimodel.AdvancedStatus } } + // put the new status in the appropriate channel for async processing + p.fromClientAPI <- FromClientAPI{ + APObjectType: newStatus.ActivityStreamsType, + APActivityType: gtsmodel.ActivityStreamsCreate, + Activity: newStatus, + } + // return the frontend representation of the new status to the submitter return p.tc.StatusToMasto(newStatus, auth.Account, auth.Account, nil, newStatus.GTSReplyToAccount, nil) } diff --git a/internal/oauth/clientstore_test.go b/internal/oauth/clientstore_test.go index b77163e48..58c5148b2 100644 --- a/internal/oauth/clientstore_test.go +++ b/internal/oauth/clientstore_test.go @@ -25,6 +25,7 @@ import ( "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/db/pg" "github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/oauth2/v4/models" ) @@ -62,7 +63,7 @@ func (suite *PgClientStoreTestSuite) SetupTest() { Database: "postgres", ApplicationName: "gotosocial", } - db, err := db.NewPostgresService(context.Background(), c, log) + db, err := pg.NewPostgresService(context.Background(), c, log) if err != nil { logrus.Panicf("error creating database connection: %s", err) } diff --git a/internal/router/router.go b/internal/router/router.go index cdd079634..eed85771f 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -27,6 +27,7 @@ import ( "path/filepath" "time" + "github.com/gin-contrib/cors" "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/memstore" "github.com/gin-gonic/gin" @@ -123,6 +124,14 @@ func New(config *config.Config, logger *logrus.Logger) (Router, error) { // create the actual engine here -- this is the core request routing handler for gts engine := gin.Default() + engine.Use(cors.New(cors.Config{ + AllowAllOrigins: true, + AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE"}, + AllowHeaders: []string{"Origin", "Content-Length", "Content-Type", "Authorization"}, + AllowCredentials: false, + MaxAge: 12 * time.Hour, + })) + engine.MaxMultipartMemory = 8 << 20 // 8 MiB // create a new session store middleware store, err := sessionStore() @@ -143,10 +152,10 @@ func New(config *config.Config, logger *logrus.Logger) (Router, error) { // create the actual http server here s := &http.Server{ Handler: engine, - ReadTimeout: 1 * time.Second, - WriteTimeout: 1 * time.Second, + ReadTimeout: 60 * time.Second, + WriteTimeout: 5 * time.Second, IdleTimeout: 30 * time.Second, - ReadHeaderTimeout: 2 * time.Second, + ReadHeaderTimeout: 30 * time.Second, } var m *autocert.Manager diff --git a/internal/transport/controller.go b/internal/transport/controller.go index 2ee23f141..72f41b335 100644 --- a/internal/transport/controller.go +++ b/internal/transport/controller.go @@ -54,15 +54,15 @@ func NewController(config *config.Config, clock pub.Clock, client pub.HttpClient func (c *controller) NewTransport(pubKeyID string, privkey crypto.PrivateKey) (pub.Transport, error) { prefs := []httpsig.Algorithm{httpsig.RSA_SHA256, httpsig.RSA_SHA512} digestAlgo := httpsig.DigestSha256 - getHeaders := []string{"(request-target)", "date", "accept"} - postHeaders := []string{"(request-target)", "date", "accept", "digest"} + getHeaders := []string{"(request-target)", "host", "date"} + postHeaders := []string{"(request-target)", "host", "date", "digest"} - getSigner, _, err := httpsig.NewSigner(prefs, digestAlgo, getHeaders, httpsig.Signature) + getSigner, _, err := httpsig.NewSigner(prefs, digestAlgo, getHeaders, httpsig.Signature, 120) if err != nil { return nil, fmt.Errorf("error creating get signer: %s", err) } - postSigner, _, err := httpsig.NewSigner(prefs, digestAlgo, postHeaders, httpsig.Signature) + postSigner, _, err := httpsig.NewSigner(prefs, digestAlgo, postHeaders, httpsig.Signature, 120) if err != nil { return nil, fmt.Errorf("error creating post signer: %s", err) } diff --git a/internal/typeutils/accountable.go b/internal/typeutils/accountable.go deleted file mode 100644 index ba5c4aa2a..000000000 --- a/internal/typeutils/accountable.go +++ /dev/null @@ -1,101 +0,0 @@ -/* - 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 typeutils - -import "github.com/go-fed/activity/streams/vocab" - -// Accountable represents the minimum activitypub interface for representing an 'account'. -// This interface is fulfilled by: Person, Application, Organization, Service, and Group -type Accountable interface { - withJSONLDId - withGetTypeName - withPreferredUsername - withIcon - withDisplayName - withImage - withSummary - withDiscoverable - withURL - withPublicKey - withInbox - withOutbox - withFollowing - withFollowers - withFeatured -} - -type withJSONLDId interface { - GetJSONLDId() vocab.JSONLDIdProperty -} - -type withGetTypeName interface { - GetTypeName() string -} - -type withPreferredUsername interface { - GetActivityStreamsPreferredUsername() vocab.ActivityStreamsPreferredUsernameProperty -} - -type withIcon interface { - GetActivityStreamsIcon() vocab.ActivityStreamsIconProperty -} - -type withDisplayName interface { - GetActivityStreamsName() vocab.ActivityStreamsNameProperty -} - -type withImage interface { - GetActivityStreamsImage() vocab.ActivityStreamsImageProperty -} - -type withSummary interface { - GetActivityStreamsSummary() vocab.ActivityStreamsSummaryProperty -} - -type withDiscoverable interface { - GetTootDiscoverable() vocab.TootDiscoverableProperty -} - -type withURL interface { - GetActivityStreamsUrl() vocab.ActivityStreamsUrlProperty -} - -type withPublicKey interface { - GetW3IDSecurityV1PublicKey() vocab.W3IDSecurityV1PublicKeyProperty -} - -type withInbox interface { - GetActivityStreamsInbox() vocab.ActivityStreamsInboxProperty -} - -type withOutbox interface { - GetActivityStreamsOutbox() vocab.ActivityStreamsOutboxProperty -} - -type withFollowing interface { - GetActivityStreamsFollowing() vocab.ActivityStreamsFollowingProperty -} - -type withFollowers interface { - GetActivityStreamsFollowers() vocab.ActivityStreamsFollowersProperty -} - -type withFeatured interface { - GetTootFeatured() vocab.TootFeaturedProperty -} diff --git a/internal/typeutils/asextractionutil.go b/internal/typeutils/asextractionutil.go index 8d39be3ec..4ee3347bd 100644 --- a/internal/typeutils/asextractionutil.go +++ b/internal/typeutils/asextractionutil.go @@ -25,8 +25,12 @@ import ( "errors" "fmt" "net/url" + "strings" + "time" "github.com/go-fed/activity/pub" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/util" ) func extractPreferredUsername(i withPreferredUsername) (string, error) { @@ -40,22 +44,89 @@ func extractPreferredUsername(i withPreferredUsername) (string, error) { return u.GetXMLSchemaString(), nil } -func extractName(i withDisplayName) (string, error) { +func extractName(i withName) (string, error) { nameProp := i.GetActivityStreamsName() if nameProp == nil { return "", errors.New("activityStreamsName not found") } // take the first name string we can find - for nameIter := nameProp.Begin(); nameIter != nameProp.End(); nameIter = nameIter.Next() { - if nameIter.IsXMLSchemaString() && nameIter.GetXMLSchemaString() != "" { - return nameIter.GetXMLSchemaString(), nil + for iter := nameProp.Begin(); iter != nameProp.End(); iter = iter.Next() { + if iter.IsXMLSchemaString() && iter.GetXMLSchemaString() != "" { + return iter.GetXMLSchemaString(), nil } } return "", errors.New("activityStreamsName not found") } +func extractInReplyToURI(i withInReplyTo) (*url.URL, error) { + inReplyToProp := i.GetActivityStreamsInReplyTo() + for iter := inReplyToProp.Begin(); iter != inReplyToProp.End(); iter = iter.Next() { + if iter.IsIRI() { + if iter.GetIRI() != nil { + return iter.GetIRI(), nil + } + } + } + return nil, errors.New("couldn't find iri for in reply to") +} + +func extractTos(i withTo) ([]*url.URL, error) { + to := []*url.URL{} + toProp := i.GetActivityStreamsTo() + for iter := toProp.Begin(); iter != toProp.End(); iter = iter.Next() { + if iter.IsIRI() { + if iter.GetIRI() != nil { + to = append(to, iter.GetIRI()) + } + } + } + return to, nil +} + +func extractCCs(i withCC) ([]*url.URL, error) { + cc := []*url.URL{} + ccProp := i.GetActivityStreamsCc() + for iter := ccProp.Begin(); iter != ccProp.End(); iter = iter.Next() { + if iter.IsIRI() { + if iter.GetIRI() != nil { + cc = append(cc, iter.GetIRI()) + } + } + } + return cc, nil +} + +func extractAttributedTo(i withAttributedTo) (*url.URL, error) { + attributedToProp := i.GetActivityStreamsAttributedTo() + for iter := attributedToProp.Begin(); iter != attributedToProp.End(); iter = iter.Next() { + if iter.IsIRI() { + if iter.GetIRI() != nil { + return iter.GetIRI(), nil + } + } + } + return nil, errors.New("couldn't find iri for attributed to") +} + +func extractPublished(i withPublished) (time.Time, error) { + publishedProp := i.GetActivityStreamsPublished() + if publishedProp == nil { + return time.Time{}, errors.New("published prop was nil") + } + + if !publishedProp.IsXMLSchemaDateTime() { + return time.Time{}, errors.New("published prop was not date time") + } + + t := publishedProp.Get() + if t.IsZero() { + return time.Time{}, errors.New("published time was zero") + } + return t, nil +} + // extractIconURL extracts a URL to a supported image file from something like: // "icon": { // "mediaType": "image/jpeg", @@ -72,12 +143,12 @@ func extractIconURL(i withIcon) (*url.URL, error) { // here in order to find the first one that meets these criteria: // 1. is an image // 2. has a URL so we can grab it - for iconIter := iconProp.Begin(); iconIter != iconProp.End(); iconIter = iconIter.Next() { + for iter := iconProp.Begin(); iter != iconProp.End(); iter = iter.Next() { // 1. is an image - if !iconIter.IsActivityStreamsImage() { + if !iter.IsActivityStreamsImage() { continue } - imageValue := iconIter.GetActivityStreamsImage() + imageValue := iter.GetActivityStreamsImage() if imageValue == nil { continue } @@ -108,12 +179,12 @@ func extractImageURL(i withImage) (*url.URL, error) { // here in order to find the first one that meets these criteria: // 1. is an image // 2. has a URL so we can grab it - for imageIter := imageProp.Begin(); imageIter != imageProp.End(); imageIter = imageIter.Next() { + for iter := imageProp.Begin(); iter != imageProp.End(); iter = iter.Next() { // 1. is an image - if !imageIter.IsActivityStreamsImage() { + if !iter.IsActivityStreamsImage() { continue } - imageValue := imageIter.GetActivityStreamsImage() + imageValue := iter.GetActivityStreamsImage() if imageValue == nil { continue } @@ -134,9 +205,9 @@ func extractSummary(i withSummary) (string, error) { return "", errors.New("summary property was nil") } - for summaryIter := summaryProp.Begin(); summaryIter != summaryProp.End(); summaryIter = summaryIter.Next() { - if summaryIter.IsXMLSchemaString() && summaryIter.GetXMLSchemaString() != "" { - return summaryIter.GetXMLSchemaString(), nil + for iter := summaryProp.Begin(); iter != summaryProp.End(); iter = iter.Next() { + if iter.IsXMLSchemaString() && iter.GetXMLSchemaString() != "" { + return iter.GetXMLSchemaString(), nil } } @@ -156,9 +227,9 @@ func extractURL(i withURL) (*url.URL, error) { return nil, errors.New("url property was nil") } - for urlIter := urlProp.Begin(); urlIter != urlProp.End(); urlIter = urlIter.Next() { - if urlIter.IsIRI() && urlIter.GetIRI() != nil { - return urlIter.GetIRI(), nil + for iter := urlProp.Begin(); iter != urlProp.End(); iter = iter.Next() { + if iter.IsIRI() && iter.GetIRI() != nil { + return iter.GetIRI(), nil } } @@ -171,8 +242,8 @@ func extractPublicKeyForOwner(i withPublicKey, forOwner *url.URL) (*rsa.PublicKe return nil, nil, errors.New("public key property was nil") } - for publicKeyIter := publicKeyProp.Begin(); publicKeyIter != publicKeyProp.End(); publicKeyIter = publicKeyIter.Next() { - pkey := publicKeyIter.Get() + for iter := publicKeyProp.Begin(); iter != publicKeyProp.End(); iter = iter.Next() { + pkey := iter.Get() if pkey == nil { continue } @@ -214,3 +285,263 @@ func extractPublicKeyForOwner(i withPublicKey, forOwner *url.URL) (*rsa.PublicKe } return nil, nil, errors.New("couldn't find public key") } + +func extractContent(i withContent) (string, error) { + contentProperty := i.GetActivityStreamsContent() + if contentProperty == nil { + return "", errors.New("content property was nil") + } + for iter := contentProperty.Begin(); iter != contentProperty.End(); iter = iter.Next() { + if iter.IsXMLSchemaString() && iter.GetXMLSchemaString() != "" { + return iter.GetXMLSchemaString(), nil + } + } + return "", errors.New("no content found") +} + +func extractAttachments(i withAttachment) ([]*gtsmodel.MediaAttachment, error) { + attachments := []*gtsmodel.MediaAttachment{} + + attachmentProp := i.GetActivityStreamsAttachment() + for iter := attachmentProp.Begin(); iter != attachmentProp.End(); iter = iter.Next() { + attachmentable, ok := iter.(Attachmentable) + if !ok { + continue + } + attachment, err := extractAttachment(attachmentable) + if err != nil { + continue + } + attachments = append(attachments, attachment) + } + return attachments, nil +} + +func extractAttachment(i Attachmentable) (*gtsmodel.MediaAttachment, error) { + attachment := >smodel.MediaAttachment{ + File: gtsmodel.File{}, + } + + attachmentURL, err := extractURL(i) + if err != nil { + return nil, err + } + attachment.RemoteURL = attachmentURL.String() + + mediaType := i.GetActivityStreamsMediaType() + if mediaType == nil { + return nil, errors.New("no media type") + } + if mediaType.Get() == "" { + return nil, errors.New("no media type") + } + attachment.File.ContentType = mediaType.Get() + attachment.Type = gtsmodel.FileTypeImage + + name, err := extractName(i) + if err == nil { + attachment.Description = name + } + + blurhash, err := extractBlurhash(i) + if err == nil { + attachment.Blurhash = blurhash + } + + return attachment, nil +} + +func extractBlurhash(i withBlurhash) (string, error) { + if i.GetTootBlurhashProperty() == nil { + return "", errors.New("blurhash property was nil") + } + if i.GetTootBlurhashProperty().Get() == "" { + return "", errors.New("empty blurhash string") + } + return i.GetTootBlurhashProperty().Get(), nil +} + +func extractHashtags(i withTag) ([]*gtsmodel.Tag, error) { + tags := []*gtsmodel.Tag{} + + tagsProp := i.GetActivityStreamsTag() + for iter := tagsProp.Begin(); iter != tagsProp.End(); iter = iter.Next() { + t := iter.GetType() + if t == nil { + continue + } + + if t.GetTypeName() != "Hashtag" { + continue + } + + hashtaggable, ok := t.(Hashtaggable) + if !ok { + continue + } + + tag, err := extractHashtag(hashtaggable) + if err != nil { + continue + } + + tags = append(tags, tag) + } + return tags, nil +} + +func extractHashtag(i Hashtaggable) (*gtsmodel.Tag, error) { + tag := >smodel.Tag{} + + hrefProp := i.GetActivityStreamsHref() + if hrefProp == nil || !hrefProp.IsIRI() { + return nil, errors.New("no href prop") + } + tag.URL = hrefProp.GetIRI().String() + + name, err := extractName(i) + if err != nil { + return nil, err + } + tag.Name = strings.TrimPrefix(name, "#") + + return tag, nil +} + +func extractEmojis(i withTag) ([]*gtsmodel.Emoji, error) { + emojis := []*gtsmodel.Emoji{} + tagsProp := i.GetActivityStreamsTag() + for iter := tagsProp.Begin(); iter != tagsProp.End(); iter = iter.Next() { + t := iter.GetType() + if t == nil { + continue + } + + if t.GetTypeName() != "Emoji" { + continue + } + + emojiable, ok := t.(Emojiable) + if !ok { + continue + } + + emoji, err := extractEmoji(emojiable) + if err != nil { + continue + } + + emojis = append(emojis, emoji) + } + return emojis, nil +} + +func extractEmoji(i Emojiable) (*gtsmodel.Emoji, error) { + emoji := >smodel.Emoji{} + + idProp := i.GetJSONLDId() + if idProp == nil || !idProp.IsIRI() { + return nil, errors.New("no id for emoji") + } + uri := idProp.GetIRI() + emoji.URI = uri.String() + emoji.Domain = uri.Host + + name, err := extractName(i) + if err != nil { + return nil, err + } + emoji.Shortcode = strings.Trim(name, ":") + + if i.GetActivityStreamsIcon() == nil { + return nil, errors.New("no icon for emoji") + } + imageURL, err := extractIconURL(i) + if err != nil { + return nil, errors.New("no url for emoji image") + } + emoji.ImageRemoteURL = imageURL.String() + + return emoji, nil +} + +func extractMentions(i withTag) ([]*gtsmodel.Mention, error) { + mentions := []*gtsmodel.Mention{} + tagsProp := i.GetActivityStreamsTag() + for iter := tagsProp.Begin(); iter != tagsProp.End(); iter = iter.Next() { + t := iter.GetType() + if t == nil { + continue + } + + if t.GetTypeName() != "Mention" { + continue + } + + mentionable, ok := t.(Mentionable) + if !ok { + continue + } + + mention, err := extractMention(mentionable) + if err != nil { + continue + } + + mentions = append(mentions, mention) + } + return mentions, nil +} + +func extractMention(i Mentionable) (*gtsmodel.Mention, error) { + mention := >smodel.Mention{} + + mentionString, err := extractName(i) + if err != nil { + return nil, err + } + + // just make sure the mention string is valid so we can handle it properly later on... + username, domain, err := util.ExtractMentionParts(mentionString) + if err != nil { + return nil, err + } + if username == "" || domain == "" { + return nil, errors.New("username or domain was empty") + } + mention.NameString = mentionString + + // the href prop should be the AP URI of a user we know, eg https://example.org/users/whatever_user + hrefProp := i.GetActivityStreamsHref() + if hrefProp == nil || !hrefProp.IsIRI() { + return nil, errors.New("no href prop") + } + mention.MentionedAccountURI = hrefProp.GetIRI().String() + return mention, nil +} + +func extractActor(i withActor) (*url.URL, error) { + actorProp := i.GetActivityStreamsActor() + if actorProp == nil { + return nil, errors.New("actor property was nil") + } + for iter := actorProp.Begin(); iter != actorProp.End(); iter = iter.Next() { + if iter.IsIRI() && iter.GetIRI() != nil { + return iter.GetIRI(), nil + } + } + return nil, errors.New("no iri found for actor prop") +} + +func extractObject(i withObject) (*url.URL, error) { + objectProp := i.GetActivityStreamsObject() + if objectProp == nil { + return nil, errors.New("object property was nil") + } + for iter := objectProp.Begin(); iter != objectProp.End(); iter = iter.Next() { + if iter.IsIRI() && iter.GetIRI() != nil { + return iter.GetIRI(), nil + } + } + return nil, errors.New("no iri found for object prop") +} diff --git a/internal/typeutils/asinterfaces.go b/internal/typeutils/asinterfaces.go new file mode 100644 index 000000000..970ed2ecf --- /dev/null +++ b/internal/typeutils/asinterfaces.go @@ -0,0 +1,237 @@ +/* + 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 typeutils + +import "github.com/go-fed/activity/streams/vocab" + +// Accountable represents the minimum activitypub interface for representing an 'account'. +// This interface is fulfilled by: Person, Application, Organization, Service, and Group +type Accountable interface { + withJSONLDId + withTypeName + + withPreferredUsername + withIcon + withName + withImage + withSummary + withDiscoverable + withURL + withPublicKey + withInbox + withOutbox + withFollowing + withFollowers + withFeatured +} + +// Statusable represents the minimum activitypub interface for representing a 'status'. +// This interface is fulfilled by: Article, Document, Image, Video, Note, Page, Event, Place, Mention, Profile +type Statusable interface { + withJSONLDId + withTypeName + + withSummary + withInReplyTo + withPublished + withURL + withAttributedTo + withTo + withCC + withSensitive + withConversation + withContent + withAttachment + withTag + withReplies +} + +// Attachmentable represents the minimum activitypub interface for representing a 'mediaAttachment'. +// This interface is fulfilled by: Audio, Document, Image, Video +type Attachmentable interface { + withTypeName + withMediaType + withURL + withName + withBlurhash + withFocalPoint +} + +// Hashtaggable represents the minimum activitypub interface for representing a 'hashtag' tag. +type Hashtaggable interface { + withTypeName + withHref + withName +} + +// Emojiable represents the minimum interface for an 'emoji' tag. +type Emojiable interface { + withJSONLDId + withTypeName + withName + withUpdated + withIcon +} + +// Mentionable represents the minimum interface for a 'mention' tag. +type Mentionable interface { + withName + withHref +} + +// Followable represents the minimum interface for an activitystreams 'follow' activity. +type Followable interface { + withJSONLDId + withTypeName + + withActor + withObject +} + +type withJSONLDId interface { + GetJSONLDId() vocab.JSONLDIdProperty +} + +type withTypeName interface { + GetTypeName() string +} + +type withPreferredUsername interface { + GetActivityStreamsPreferredUsername() vocab.ActivityStreamsPreferredUsernameProperty +} + +type withIcon interface { + GetActivityStreamsIcon() vocab.ActivityStreamsIconProperty +} + +type withName interface { + GetActivityStreamsName() vocab.ActivityStreamsNameProperty +} + +type withImage interface { + GetActivityStreamsImage() vocab.ActivityStreamsImageProperty +} + +type withSummary interface { + GetActivityStreamsSummary() vocab.ActivityStreamsSummaryProperty +} + +type withDiscoverable interface { + GetTootDiscoverable() vocab.TootDiscoverableProperty +} + +type withURL interface { + GetActivityStreamsUrl() vocab.ActivityStreamsUrlProperty +} + +type withPublicKey interface { + GetW3IDSecurityV1PublicKey() vocab.W3IDSecurityV1PublicKeyProperty +} + +type withInbox interface { + GetActivityStreamsInbox() vocab.ActivityStreamsInboxProperty +} + +type withOutbox interface { + GetActivityStreamsOutbox() vocab.ActivityStreamsOutboxProperty +} + +type withFollowing interface { + GetActivityStreamsFollowing() vocab.ActivityStreamsFollowingProperty +} + +type withFollowers interface { + GetActivityStreamsFollowers() vocab.ActivityStreamsFollowersProperty +} + +type withFeatured interface { + GetTootFeatured() vocab.TootFeaturedProperty +} + +type withAttributedTo interface { + GetActivityStreamsAttributedTo() vocab.ActivityStreamsAttributedToProperty +} + +type withAttachment interface { + GetActivityStreamsAttachment() vocab.ActivityStreamsAttachmentProperty +} + +type withTo interface { + GetActivityStreamsTo() vocab.ActivityStreamsToProperty +} + +type withInReplyTo interface { + GetActivityStreamsInReplyTo() vocab.ActivityStreamsInReplyToProperty +} + +type withCC interface { + GetActivityStreamsCc() vocab.ActivityStreamsCcProperty +} + +type withSensitive interface { + // TODO +} + +type withConversation interface { + // TODO +} + +type withContent interface { + GetActivityStreamsContent() vocab.ActivityStreamsContentProperty +} + +type withPublished interface { + GetActivityStreamsPublished() vocab.ActivityStreamsPublishedProperty +} + +type withTag interface { + GetActivityStreamsTag() vocab.ActivityStreamsTagProperty +} + +type withReplies interface { + GetActivityStreamsReplies() vocab.ActivityStreamsRepliesProperty +} + +type withMediaType interface { + GetActivityStreamsMediaType() vocab.ActivityStreamsMediaTypeProperty +} + +type withBlurhash interface { + GetTootBlurhashProperty() vocab.TootBlurhashProperty +} + +type withFocalPoint interface { + // TODO +} + +type withHref interface { + GetActivityStreamsHref() vocab.ActivityStreamsHrefProperty +} + +type withUpdated interface { + GetActivityStreamsUpdated() vocab.ActivityStreamsUpdatedProperty +} + +type withActor interface { + GetActivityStreamsActor() vocab.ActivityStreamsActorProperty +} + +type withObject interface { + GetActivityStreamsObject() vocab.ActivityStreamsObjectProperty +} diff --git a/internal/typeutils/astointernal.go b/internal/typeutils/astointernal.go index 7842411ea..7f0a4c1a4 100644 --- a/internal/typeutils/astointernal.go +++ b/internal/typeutils/astointernal.go @@ -21,6 +21,8 @@ package typeutils import ( "errors" "fmt" + "net/url" + "strings" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" @@ -157,3 +159,202 @@ func (c *converter) ASRepresentationToAccount(accountable Accountable) (*gtsmode return acct, nil } + +func (c *converter) ASStatusToStatus(statusable Statusable) (*gtsmodel.Status, error) { + status := >smodel.Status{} + + // uri at which this status is reachable + uriProp := statusable.GetJSONLDId() + if uriProp == nil || !uriProp.IsIRI() { + return nil, errors.New("no id property found, or id was not an iri") + } + status.URI = uriProp.GetIRI().String() + + // web url for viewing this status + if statusURL, err := extractURL(statusable); err == nil { + status.URL = statusURL.String() + } + + // the html-formatted content of this status + if content, err := extractContent(statusable); err == nil { + status.Content = content + } + + // attachments to dereference and fetch later on (we don't do that here) + if attachments, err := extractAttachments(statusable); err == nil { + status.GTSMediaAttachments = attachments + } + + // hashtags to dereference later on + if hashtags, err := extractHashtags(statusable); err == nil { + status.GTSTags = hashtags + } + + // emojis to dereference and fetch later on + if emojis, err := extractEmojis(statusable); err == nil { + status.GTSEmojis = emojis + } + + // mentions to dereference later on + if mentions, err := extractMentions(statusable); err == nil { + status.GTSMentions = mentions + } + + // cw string for this status + if cw, err := extractSummary(statusable); err == nil { + status.ContentWarning = cw + } + + // when was this status created? + published, err := extractPublished(statusable) + if err == nil { + status.CreatedAt = published + } + + // which account posted this status? + // if we don't know the account yet we can dereference it later + attributedTo, err := extractAttributedTo(statusable) + if err != nil { + return nil, errors.New("attributedTo was empty") + } + status.APStatusOwnerURI = attributedTo.String() + + statusOwner := >smodel.Account{} + if err := c.db.GetWhere("uri", attributedTo.String(), statusOwner); err != nil { + return nil, fmt.Errorf("couldn't get status owner from db: %s", err) + } + status.AccountID = statusOwner.ID + status.GTSAccount = statusOwner + + // check if there's a post that this is a reply to + inReplyToURI, err := extractInReplyToURI(statusable) + if err == nil { + // something is set so we can at least set this field on the + // status and dereference using this later if we need to + status.APReplyToStatusURI = inReplyToURI.String() + + // 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 { + // we have the status in our database already + // so we can set these fields here and then... + status.InReplyToID = inReplyToStatus.ID + status.InReplyToAccountID = inReplyToStatus.AccountID + status.GTSReplyToStatus = inReplyToStatus + + // ... check if we've seen the account already + inReplyToAccount := >smodel.Account{} + if err := c.db.GetByID(inReplyToStatus.AccountID, inReplyToAccount); err == nil { + status.GTSReplyToAccount = inReplyToAccount + } + } + } + + // visibility entry for this status + var visibility gtsmodel.Visibility + + to, err := extractTos(statusable) + if err != nil { + return nil, fmt.Errorf("error extracting TO values: %s", err) + } + + cc, err := extractCCs(statusable) + if err != nil { + return nil, fmt.Errorf("error extracting CC values: %s", err) + } + + if len(to) == 0 && len(cc) == 0 { + return nil, errors.New("message wasn't TO or CC anyone") + } + + // for visibility derivation, we start by assuming most restrictive, and work our way to least restrictive + + // if it's a DM then it's addressed to SPECIFIC ACCOUNTS and not followers or public + if len(to) != 0 && len(cc) == 0 { + visibility = gtsmodel.VisibilityDirect + } + + // if it's just got followers in TO and it's not also CC'ed to public, it's followers only + if isFollowers(to, statusOwner.FollowersURI) { + visibility = gtsmodel.VisibilityFollowersOnly + } + + // 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(to) { + visibility = gtsmodel.VisibilityPublic + } + + // we should have a visibility by now + if visibility == "" { + return nil, errors.New("couldn't derive visibility") + } + status.Visibility = visibility + + // advanced visibility for this status + // TODO: a lot of work to be done here -- a new type needs to be created for this in go-fed/activity using ASTOOL + + // sensitive + // TODO: this is a bool + + // language + // we might be able to extract this from the contentMap field + + // ActivityStreamsType + status.ActivityStreamsType = gtsmodel.ActivityStreamsObject(statusable.GetTypeName()) + + return status, nil +} + +func (c *converter) ASFollowToFollowRequest(followable Followable) (*gtsmodel.FollowRequest, 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("uri", 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("uri", target.String(), targetAccount); err != nil { + return nil, fmt.Errorf("error extracting account with uri %s from the database: %s", origin.String(), err) + } + + followRequest := >smodel.FollowRequest{ + URI: uri, + AccountID: originAccount.ID, + TargetAccountID: targetAccount.ID, + } + + return followRequest, nil +} + +func isPublic(tos []*url.URL) bool { + for _, entry := range tos { + if strings.EqualFold(entry.String(), "https://www.w3.org/ns/activitystreams#Public") { + return true + } + } + return false +} + +func isFollowers(ccs []*url.URL, followersURI string) bool { + for _, entry := range ccs { + if strings.EqualFold(entry.String(), followersURI) { + return true + } + } + return false +} diff --git a/internal/typeutils/astointernal_test.go b/internal/typeutils/astointernal_test.go index 1cd66a0ab..f1287e027 100644 --- a/internal/typeutils/astointernal_test.go +++ b/internal/typeutils/astointernal_test.go @@ -25,6 +25,7 @@ import ( "testing" "github.com/go-fed/activity/streams" + "github.com/go-fed/activity/streams/vocab" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/typeutils" @@ -36,6 +37,182 @@ type ASToInternalTestSuite struct { } const ( + statusWithMentionsActivityJson = `{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + { + "ostatus": "http://ostatus.org#", + "atomUri": "ostatus:atomUri", + "inReplyToAtomUri": "ostatus:inReplyToAtomUri", + "conversation": "ostatus:conversation", + "sensitive": "as:sensitive", + "toot": "http://joinmastodon.org/ns#", + "votersCount": "toot:votersCount" + } + ], + "id": "https://ondergrond.org/users/dumpsterqueer/statuses/106221634728637552/activity", + "type": "Create", + "actor": "https://ondergrond.org/users/dumpsterqueer", + "published": "2021-05-12T09:58:38Z", + "to": [ + "https://ondergrond.org/users/dumpsterqueer/followers" + ], + "cc": [ + "https://www.w3.org/ns/activitystreams#Public", + "https://social.pixie.town/users/f0x" + ], + "object": { + "id": "https://ondergrond.org/users/dumpsterqueer/statuses/106221634728637552", + "type": "Note", + "summary": null, + "inReplyTo": "https://social.pixie.town/users/f0x/statuses/106221628567855262", + "published": "2021-05-12T09:58:38Z", + "url": "https://ondergrond.org/@dumpsterqueer/106221634728637552", + "attributedTo": "https://ondergrond.org/users/dumpsterqueer", + "to": [ + "https://ondergrond.org/users/dumpsterqueer/followers" + ], + "cc": [ + "https://www.w3.org/ns/activitystreams#Public", + "https://social.pixie.town/users/f0x" + ], + "sensitive": false, + "atomUri": "https://ondergrond.org/users/dumpsterqueer/statuses/106221634728637552", + "inReplyToAtomUri": "https://social.pixie.town/users/f0x/statuses/106221628567855262", + "conversation": "tag:ondergrond.org,2021-05-12:objectId=1132361:objectType=Conversation", + "content": "

@f0x nice there it is:

https://social.pixie.town/users/f0x/statuses/106221628567855262/activity

", + "contentMap": { + "en": "

@f0x nice there it is:

https://social.pixie.town/users/f0x/statuses/106221628567855262/activity

" + }, + "attachment": [], + "tag": [ + { + "type": "Mention", + "href": "https://social.pixie.town/users/f0x", + "name": "@f0x@pixie.town" + } + ], + "replies": { + "id": "https://ondergrond.org/users/dumpsterqueer/statuses/106221634728637552/replies", + "type": "Collection", + "first": { + "type": "CollectionPage", + "next": "https://ondergrond.org/users/dumpsterqueer/statuses/106221634728637552/replies?only_other_accounts=true&page=true", + "partOf": "https://ondergrond.org/users/dumpsterqueer/statuses/106221634728637552/replies", + "items": [] + } + } + } + }` + statusWithEmojisAndTagsAsActivityJson = `{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + { + "ostatus": "http://ostatus.org#", + "atomUri": "ostatus:atomUri", + "inReplyToAtomUri": "ostatus:inReplyToAtomUri", + "conversation": "ostatus:conversation", + "sensitive": "as:sensitive", + "toot": "http://joinmastodon.org/ns#", + "votersCount": "toot:votersCount", + "Hashtag": "as:Hashtag", + "Emoji": "toot:Emoji", + "focalPoint": { + "@container": "@list", + "@id": "toot:focalPoint" + } + } + ], + "id": "https://ondergrond.org/users/dumpsterqueer/statuses/106221567884565704/activity", + "type": "Create", + "actor": "https://ondergrond.org/users/dumpsterqueer", + "published": "2021-05-12T09:41:38Z", + "to": [ + "https://ondergrond.org/users/dumpsterqueer/followers" + ], + "cc": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "object": { + "id": "https://ondergrond.org/users/dumpsterqueer/statuses/106221567884565704", + "type": "Note", + "summary": null, + "inReplyTo": null, + "published": "2021-05-12T09:41:38Z", + "url": "https://ondergrond.org/@dumpsterqueer/106221567884565704", + "attributedTo": "https://ondergrond.org/users/dumpsterqueer", + "to": [ + "https://ondergrond.org/users/dumpsterqueer/followers" + ], + "cc": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "sensitive": false, + "atomUri": "https://ondergrond.org/users/dumpsterqueer/statuses/106221567884565704", + "inReplyToAtomUri": null, + "conversation": "tag:ondergrond.org,2021-05-12:objectId=1132361:objectType=Conversation", + "content": "

just testing activitypub representations of #tags and #emoji :party_parrot: :amaze: :blobsunglasses:

don't mind me....

", + "contentMap": { + "en": "

just testing activitypub representations of #tags and #emoji :party_parrot: :amaze: :blobsunglasses:

don't mind me....

" + }, + "attachment": [], + "tag": [ + { + "type": "Hashtag", + "href": "https://ondergrond.org/tags/tags", + "name": "#tags" + }, + { + "type": "Hashtag", + "href": "https://ondergrond.org/tags/emoji", + "name": "#emoji" + }, + { + "id": "https://ondergrond.org/emojis/2390", + "type": "Emoji", + "name": ":party_parrot:", + "updated": "2020-11-06T13:42:11Z", + "icon": { + "type": "Image", + "mediaType": "image/gif", + "url": "https://ondergrond.org/system/custom_emojis/images/000/002/390/original/ef133aac7ab23341.gif" + } + }, + { + "id": "https://ondergrond.org/emojis/2395", + "type": "Emoji", + "name": ":amaze:", + "updated": "2020-09-26T12:29:56Z", + "icon": { + "type": "Image", + "mediaType": "image/png", + "url": "https://ondergrond.org/system/custom_emojis/images/000/002/395/original/2c7d9345e57367ed.png" + } + }, + { + "id": "https://ondergrond.org/emojis/764", + "type": "Emoji", + "name": ":blobsunglasses:", + "updated": "2020-09-26T12:13:23Z", + "icon": { + "type": "Image", + "mediaType": "image/png", + "url": "https://ondergrond.org/system/custom_emojis/images/000/000/764/original/3f8eef9de773c90d.png" + } + } + ], + "replies": { + "id": "https://ondergrond.org/users/dumpsterqueer/statuses/106221567884565704/replies", + "type": "Collection", + "first": { + "type": "CollectionPage", + "next": "https://ondergrond.org/users/dumpsterqueer/statuses/106221567884565704/replies?only_other_accounts=true&page=true", + "partOf": "https://ondergrond.org/users/dumpsterqueer/statuses/106221567884565704/replies", + "items": [] + } + } + } + }` gargronAsActivityJson = `{ "@context": [ "https://www.w3.org/ns/activitystreams", @@ -197,6 +374,62 @@ func (suite *ASToInternalTestSuite) TestParseGargron() { // TODO: write assertions here, rn we're just eyeballing the output } +func (suite *ASToInternalTestSuite) TestParseStatus() { + m := make(map[string]interface{}) + err := json.Unmarshal([]byte(statusWithEmojisAndTagsAsActivityJson), &m) + assert.NoError(suite.T(), err) + + t, err := streams.ToType(context.Background(), m) + assert.NoError(suite.T(), err) + + create, ok := t.(vocab.ActivityStreamsCreate) + assert.True(suite.T(), ok) + + obj := create.GetActivityStreamsObject() + assert.NotNil(suite.T(), obj) + + first := obj.Begin() + assert.NotNil(suite.T(), first) + + rep, ok := first.GetType().(typeutils.Statusable) + assert.True(suite.T(), ok) + + status, err := suite.typeconverter.ASStatusToStatus(rep) + assert.NoError(suite.T(), err) + + assert.Len(suite.T(), status.GTSEmojis, 3) + // assert.Len(suite.T(), status.GTSTags, 2) TODO: implement this first so that it can pick up tags +} + +func (suite *ASToInternalTestSuite) TestParseStatusWithMention() { + m := make(map[string]interface{}) + err := json.Unmarshal([]byte(statusWithMentionsActivityJson), &m) + assert.NoError(suite.T(), err) + + t, err := streams.ToType(context.Background(), m) + assert.NoError(suite.T(), err) + + create, ok := t.(vocab.ActivityStreamsCreate) + assert.True(suite.T(), ok) + + obj := create.GetActivityStreamsObject() + assert.NotNil(suite.T(), obj) + + first := obj.Begin() + assert.NotNil(suite.T(), first) + + rep, ok := first.GetType().(typeutils.Statusable) + assert.True(suite.T(), ok) + + status, err := suite.typeconverter.ASStatusToStatus(rep) + assert.NoError(suite.T(), err) + + fmt.Printf("%+v", status) + + assert.Len(suite.T(), status.GTSMentions, 1) + fmt.Println(status.GTSMentions[0]) +} + func (suite *ASToInternalTestSuite) TearDownTest() { testrig.StandardDBTeardown(suite.db) } diff --git a/internal/typeutils/converter.go b/internal/typeutils/converter.go index f269fa182..8f310c921 100644 --- a/internal/typeutils/converter.go +++ b/internal/typeutils/converter.go @@ -90,6 +90,10 @@ type TypeConverter interface { // ASPersonToAccount converts a remote account/person/application representation into a gts model account ASRepresentationToAccount(accountable Accountable) (*gtsmodel.Account, error) + // ASStatus converts a remote activitystreams 'status' representation into a gts model status. + ASStatusToStatus(statusable Statusable) (*gtsmodel.Status, error) + // ASFollowToFollowRequest converts a remote activitystreams `follow` representation into gts model follow request. + ASFollowToFollowRequest(followable Followable) (*gtsmodel.FollowRequest, error) /* INTERNAL (gts) MODEL TO ACTIVITYSTREAMS MODEL diff --git a/internal/typeutils/internaltoas.go b/internal/typeutils/internaltoas.go index 73c121155..0216dea5e 100644 --- a/internal/typeutils/internaltoas.go +++ b/internal/typeutils/internaltoas.go @@ -200,15 +200,15 @@ func (c *converter) AccountToAS(a *gtsmodel.Account) (vocab.ActivityStreamsPerso // icon // Used as profile avatar. if a.AvatarMediaAttachmentID != "" { - iconProperty := streams.NewActivityStreamsIconProperty() - - iconImage := streams.NewActivityStreamsImage() - avatar := >smodel.MediaAttachment{} if err := c.db.GetByID(a.AvatarMediaAttachmentID, avatar); err != nil { return nil, err } + iconProperty := streams.NewActivityStreamsIconProperty() + + iconImage := streams.NewActivityStreamsImage() + mediaType := streams.NewActivityStreamsMediaTypeProperty() mediaType.Set(avatar.File.ContentType) iconImage.SetActivityStreamsMediaType(mediaType) @@ -228,15 +228,15 @@ func (c *converter) AccountToAS(a *gtsmodel.Account) (vocab.ActivityStreamsPerso // image // Used as profile header. if a.HeaderMediaAttachmentID != "" { - headerProperty := streams.NewActivityStreamsImageProperty() - - headerImage := streams.NewActivityStreamsImage() - header := >smodel.MediaAttachment{} if err := c.db.GetByID(a.HeaderMediaAttachmentID, header); err != nil { return nil, err } + headerProperty := streams.NewActivityStreamsImageProperty() + + headerImage := streams.NewActivityStreamsImage() + mediaType := streams.NewActivityStreamsMediaTypeProperty() mediaType.Set(header.File.ContentType) headerImage.SetActivityStreamsMediaType(mediaType) diff --git a/internal/util/regexes.go b/internal/util/regexes.go index a59bd678a..adab8c87f 100644 --- a/internal/util/regexes.go +++ b/internal/util/regexes.go @@ -35,6 +35,11 @@ const ( ) var ( + mentionNameRegexString = `^@([a-zA-Z0-9_]+)(?:@([a-zA-Z0-9_\-\.]+)?)$` + // mention name regex captures the username and domain part from a mention string + // such as @whatever_user@example.org, returning whatever_user and example.org (without the @ symbols) + mentionNameRegex = regexp.MustCompile(mentionNameRegexString) + // mention regex can be played around with here: https://regex101.com/r/qwM9D3/1 mentionFinderRegexString = `(?: |^|\W)(@[a-zA-Z0-9_]+(?:@[a-zA-Z0-9_\-\.]+)?)(?: |\n)` mentionFinderRegex = regexp.MustCompile(mentionFinderRegexString) diff --git a/internal/util/statustools.go b/internal/util/statustools.go index 5591f185a..2c74749e5 100644 --- a/internal/util/statustools.go +++ b/internal/util/statustools.go @@ -19,17 +19,18 @@ package util import ( + "fmt" "strings" ) -// DeriveMentions takes a plaintext (ie., not html-formatted) status, +// DeriveMentionsFromStatus takes a plaintext (ie., not html-formatted) status, // and applies a regex to it to return a deduplicated list of accounts // mentioned in that status. // // It will look for fully-qualified account names in the form "@user@example.org". // or the form "@username" for local users. // The case of the returned mentions will be lowered, for consistency. -func DeriveMentions(status string) []string { +func DeriveMentionsFromStatus(status string) []string { mentionedAccounts := []string{} for _, m := range mentionFinderRegex.FindAllStringSubmatch(status, -1) { mentionedAccounts = append(mentionedAccounts, m[1]) @@ -37,11 +38,11 @@ func DeriveMentions(status string) []string { return lower(unique(mentionedAccounts)) } -// DeriveHashtags takes a plaintext (ie., not html-formatted) status, +// DeriveHashtagsFromStatus takes a plaintext (ie., not html-formatted) status, // and applies a regex to it to return a deduplicated list of hashtags // used in that status, without the leading #. The case of the returned // tags will be lowered, for consistency. -func DeriveHashtags(status string) []string { +func DeriveHashtagsFromStatus(status string) []string { tags := []string{} for _, m := range hashtagFinderRegex.FindAllStringSubmatch(status, -1) { tags = append(tags, m[1]) @@ -49,11 +50,11 @@ func DeriveHashtags(status string) []string { return lower(unique(tags)) } -// DeriveEmojis takes a plaintext (ie., not html-formatted) status, +// DeriveEmojisFromStatus takes a plaintext (ie., not html-formatted) status, // and applies a regex to it to return a deduplicated list of emojis // used in that status, without the surround ::. The case of the returned // emojis will be lowered, for consistency. -func DeriveEmojis(status string) []string { +func DeriveEmojisFromStatus(status string) []string { emojis := []string{} for _, m := range emojiFinderRegex.FindAllStringSubmatch(status, -1) { emojis = append(emojis, m[1]) @@ -61,6 +62,21 @@ func DeriveEmojis(status string) []string { return lower(unique(emojis)) } +// ExtractMentionParts extracts the username test_user and the domain example.org +// from a mention string like @test_user@example.org. +// +// If nothing is matched, it will return an error. +func ExtractMentionParts(mention string) (username, domain string, err error) { + matches := mentionNameRegex.FindStringSubmatch(mention) + if matches == nil || len(matches) != 3 { + err = fmt.Errorf("could't match mention %s", mention) + return + } + username = matches[1] + domain = matches[2] + return +} + // unique returns a deduplicated version of a given string slice. func unique(s []string) []string { keys := make(map[string]bool) diff --git a/internal/util/statustools_test.go b/internal/util/statustools_test.go index 7c9af2cbd..2a12c7690 100644 --- a/internal/util/statustools_test.go +++ b/internal/util/statustools_test.go @@ -42,7 +42,7 @@ func (suite *StatusTestSuite) TestDeriveMentionsOK() { here is a duplicate mention: @hello@test.lgbt ` - menchies := util.DeriveMentions(statusText) + menchies := util.DeriveMentionsFromStatus(statusText) assert.Len(suite.T(), menchies, 4) assert.Equal(suite.T(), "@dumpsterqueer@example.org", menchies[0]) assert.Equal(suite.T(), "@someone_else@testing.best-horse.com", menchies[1]) @@ -52,7 +52,7 @@ func (suite *StatusTestSuite) TestDeriveMentionsOK() { func (suite *StatusTestSuite) TestDeriveMentionsEmpty() { statusText := `` - menchies := util.DeriveMentions(statusText) + menchies := util.DeriveMentionsFromStatus(statusText) assert.Len(suite.T(), menchies, 0) } @@ -67,7 +67,7 @@ func (suite *StatusTestSuite) TestDeriveHashtagsOK() { #111111 thisalsoshouldn'twork#### ##` - tags := util.DeriveHashtags(statusText) + tags := util.DeriveHashtagsFromStatus(statusText) assert.Len(suite.T(), tags, 5) assert.Equal(suite.T(), "testing123", tags[0]) assert.Equal(suite.T(), "also", tags[1]) @@ -90,7 +90,7 @@ Here's some normal text with an :emoji: at the end :underscores_ok_too: ` - tags := util.DeriveEmojis(statusText) + tags := util.DeriveEmojisFromStatus(statusText) assert.Len(suite.T(), tags, 7) assert.Equal(suite.T(), "test", tags[0]) assert.Equal(suite.T(), "another", tags[1]) diff --git a/internal/util/uri.go b/internal/util/uri.go index 538df9210..edcfc5c02 100644 --- a/internal/util/uri.go +++ b/internal/util/uri.go @@ -58,9 +58,15 @@ const ( // APAccount can be used the set and retrieve the account being interacted with APAccount APContextKey = "account" // APRequestingAccount can be used to set and retrieve the account of an incoming federation request. + // This will often be the actor of the instance that's posting the request. APRequestingAccount APContextKey = "requestingAccount" + // APRequestingActorIRI can be used to set and retrieve the actor of an incoming federation request. + // This will usually be the owner of whatever activity is being posted. + APRequestingActorIRI APContextKey = "requestingActorIRI" // APRequestingPublicKeyID can be used to set and retrieve the public key ID of an incoming federation request. APRequestingPublicKeyID APContextKey = "requestingPublicKeyID" + // APFromFederatorChanKey can be used to pass a pointer to the fromFederator channel into the federator for use in callbacks. + APFromFederatorChanKey APContextKey = "fromFederatorChan" ) type ginContextKey struct{} -- cgit v1.2.3