gtsocial-umbx

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README | LICENSE

commit d389e7b150df6ecd215c7b661b294ea153ad0103
parent cf19aaf0dff29867dbeb15e48135d1e18c9be1f5
Author: Tobi Smethurst <31960611+tsmethurst@users.noreply.github.com>
Date:   Mon,  5 Jul 2021 13:23:03 +0200

Domain block (#76)

* start work on admin domain blocking

* move stuff around + further work on domain blocks

* move + restructure processor

* prep work for deleting account

* tidy

* go fmt

* formatting

* domain blocking more work

* check domain blocks way earlier on

* progress on delete account

* delete more stuff when an account is gone

* and more...

* domain blocky block block

* get individual domain block, delete a block
Diffstat:
Minternal/api/client/account/accountupdate_test.go | 2+-
Minternal/api/client/account/statuses.go | 6+++---
Minternal/api/client/admin/admin.go | 17+++++++++++++++--
Ainternal/api/client/admin/domainblockcreate.go | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/admin/domainblockdelete.go | 47+++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/admin/domainblockget.go | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/admin/domainblocksget.go | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/api/client/fileserver/servefile_test.go | 2+-
Minternal/api/client/instance/instancepatch.go | 1+
Minternal/api/client/media/mediacreate_test.go | 2+-
Minternal/api/client/status/statusboost_test.go | 2+-
Minternal/api/client/status/statuscreate_test.go | 2+-
Minternal/api/client/status/statusfave_test.go | 2+-
Minternal/api/client/status/statusfavedby_test.go | 2+-
Minternal/api/client/status/statusget_test.go | 2+-
Minternal/api/client/status/statusunfave_test.go | 2+-
Ainternal/api/model/domainblock.go | 43+++++++++++++++++++++++++++++++++++++++++++
Minternal/api/s2s/user/followers.go | 13++++++++++---
Minternal/api/s2s/user/following.go | 13++++++++++---
Minternal/api/s2s/user/inboxpost.go | 11++++++++++-
Minternal/api/s2s/user/publickeyget.go | 13++++++++++---
Minternal/api/s2s/user/statusget.go | 13++++++++++---
Minternal/api/s2s/user/userget.go | 13++++++++++---
Minternal/api/s2s/user/userget_test.go | 4++--
Minternal/api/s2s/webfinger/webfingerget.go | 11++++++++++-
Minternal/api/security/security.go | 6+++++-
Ainternal/api/security/signaturecheck.go | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/cliactions/server/server.go | 4++--
Minternal/cliactions/testrig/testrig.go | 4++--
Minternal/db/db.go | 13++++++-------
Ainternal/db/pg/instance.go | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dinternal/db/pg/instancestats.go | 52----------------------------------------------------
Minternal/db/pg/pg.go | 33++++++++++++++++++++++-----------
Minternal/federation/authenticate.go | 77++++++++++++++++++++++++++++-------------------------------------------------
Minternal/federation/dereference.go | 371+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/federation/federatingprotocol.go | 17+++++++++++++++--
Minternal/federation/federator.go | 21++++++++++++++++++---
Minternal/federation/federator_test.go | 4++--
Minternal/federation/finger.go | 3+++
Ainternal/federation/util.go | 23+++++++++++++++++++++++
Minternal/gtsmodel/domainblock.go | 18+++++++-----------
Minternal/gtsmodel/instance.go | 2+-
Minternal/oauth/util.go | 22++++++++++++++++++----
Minternal/processing/account.go | 489++-----------------------------------------------------------------------------
Ainternal/processing/account/account.go | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/processing/account/create.go | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/processing/account/createfollow.go | 116+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/processing/account/delete.go | 270+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/processing/account/get.go | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/processing/account/getfollowers.go | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/processing/account/getfollowing.go | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/processing/account/getrelationship.go | 46++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/processing/account/getstatuses.go | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/processing/account/removefollow.go | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/processing/account/update.go | 199+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/processing/admin.go | 56++++++++++++++------------------------------------------
Ainternal/processing/admin/admin.go | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/processing/admin/createdomainblock.go | 154+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/processing/admin/deletedomainblock.go | 36++++++++++++++++++++++++++++++++++++
Ainternal/processing/admin/emoji.go | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/processing/admin/getdomainblock.go | 30++++++++++++++++++++++++++++++
Ainternal/processing/admin/getdomainblocks.go | 30++++++++++++++++++++++++++++++
Minternal/processing/federation.go | 42+++++++++++++++++++++---------------------
Minternal/processing/fromclientapi.go | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Minternal/processing/fromfederator.go | 326++++++-------------------------------------------------------------------------
Minternal/processing/instance.go | 4++--
Minternal/processing/media.go | 253++-----------------------------------------------------------------------------
Ainternal/processing/media/create.go | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/processing/media/delete.go | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/processing/media/getfile.go | 120+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/processing/media/getmedia.go | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/processing/media/media.go | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/processing/media/update.go | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/processing/media/util.go | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/processing/processor.go | 51++++++++++++++++++++++++++++++++++++++-------------
Minternal/processing/search.go | 6+++---
Rinternal/processing/synchronous/status/boost.go -> internal/processing/status/boost.go | 0
Rinternal/processing/synchronous/status/boostedby.go -> internal/processing/status/boostedby.go | 0
Rinternal/processing/synchronous/status/context.go -> internal/processing/status/context.go | 0
Rinternal/processing/synchronous/status/create.go -> internal/processing/status/create.go | 0
Ainternal/processing/status/delete.go | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rinternal/processing/synchronous/status/fave.go -> internal/processing/status/fave.go | 0
Rinternal/processing/synchronous/status/favedby.go -> internal/processing/status/favedby.go | 0
Rinternal/processing/synchronous/status/get.go -> internal/processing/status/get.go | 0
Rinternal/processing/synchronous/status/status.go -> internal/processing/status/status.go | 0
Rinternal/processing/synchronous/status/unboost.go -> internal/processing/status/unboost.go | 0
Rinternal/processing/synchronous/status/unfave.go -> internal/processing/status/unfave.go | 0
Rinternal/processing/synchronous/status/util.go -> internal/processing/status/util.go | 0
Rinternal/processing/synchronous/streaming/authorize.go -> internal/processing/streaming/authorize.go | 0
Rinternal/processing/synchronous/streaming/openstream.go -> internal/processing/streaming/openstream.go | 0
Rinternal/processing/synchronous/streaming/streamdelete.go -> internal/processing/streaming/streamdelete.go | 0
Rinternal/processing/synchronous/streaming/streaming.go -> internal/processing/streaming/streaming.go | 0
Rinternal/processing/synchronous/streaming/streamnotification.go -> internal/processing/streaming/streamnotification.go | 0
Rinternal/processing/synchronous/streaming/streamstatus.go -> internal/processing/streaming/streamstatus.go | 0
Dinternal/processing/synchronous/status/delete.go | 55-------------------------------------------------------
Dinternal/processing/util.go | 135-------------------------------------------------------------------------------
Minternal/typeutils/converter.go | 2++
Minternal/typeutils/internaltofrontend.go | 20++++++++++++++++++++
Minternal/util/uri.go | 4++--
Minternal/visibility/statusvisible.go | 27++++++++++++++++++++++++---
Minternal/visibility/util.go | 146+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mtestrig/federator.go | 5+++--
102 files changed, 3539 insertions(+), 1511 deletions(-)

diff --git a/internal/api/client/account/accountupdate_test.go b/internal/api/client/account/accountupdate_test.go @@ -53,7 +53,7 @@ func (suite *AccountUpdateTestSuite) SetupTest() { suite.db = testrig.NewTestDB() suite.storage = testrig.NewTestStorage() suite.log = testrig.NewTestLog() - suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil))) + suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)), suite.storage) suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator) suite.accountModule = account.New(suite.config, suite.processor, suite.log).(*account.Module) testrig.StandardDBSetup(suite.db) diff --git a/internal/api/client/account/statuses.go b/internal/api/client/account/statuses.go @@ -82,7 +82,7 @@ func (m *Module) AccountStatusesGETHandler(c *gin.Context) { maxID = maxIDString } - pinned := false + pinnedOnly := false pinnedString := c.Query(PinnedKey) if pinnedString != "" { i, err := strconv.ParseBool(pinnedString) @@ -91,7 +91,7 @@ func (m *Module) AccountStatusesGETHandler(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "couldn't parse pinned query param"}) return } - pinned = i + pinnedOnly = i } mediaOnly := false @@ -106,7 +106,7 @@ func (m *Module) AccountStatusesGETHandler(c *gin.Context) { mediaOnly = i } - statuses, errWithCode := m.processor.AccountStatusesGet(authed, targetAcctID, limit, excludeReplies, maxID, pinned, mediaOnly) + statuses, errWithCode := m.processor.AccountStatusesGet(authed, targetAcctID, limit, excludeReplies, maxID, pinnedOnly, mediaOnly) if errWithCode != nil { l.Debugf("error from processor account statuses get: %s", errWithCode) c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) diff --git a/internal/api/client/admin/admin.go b/internal/api/client/admin/admin.go @@ -29,10 +29,19 @@ import ( ) const ( - // BasePath is the base API path for this module + // BasePath is the base API path for this module. BasePath = "/api/v1/admin" - // EmojiPath is used for posting/deleting custom emojis + // EmojiPath is used for posting/deleting custom emojis. EmojiPath = BasePath + "/custom_emojis" + // DomainBlocksPath is used for posting domain blocks. + DomainBlocksPath = BasePath + "/domain_blocks" + // DomainBlockPath is used for interacting with a single domain block. + DomainBlockPath = DomainBlocksPath + "/:" + IDKey + + // ExportQueryKey is for requesting a public export of some data. + ExportQueryKey = "export" + // IDKey specifies the ID of a single item being interacted with. + IDKey = "id" ) // Module implements the ClientAPIModule interface for admin-related actions (reports, emojis, etc) @@ -54,5 +63,9 @@ func New(config *config.Config, processor processing.Processor, log *logrus.Logg // Route attaches all routes from this module to the given router func (m *Module) Route(r router.Router) error { r.AttachHandler(http.MethodPost, EmojiPath, m.emojiCreatePOSTHandler) + r.AttachHandler(http.MethodPost, DomainBlocksPath, m.DomainBlocksPOSTHandler) + r.AttachHandler(http.MethodGet, DomainBlocksPath, m.DomainBlocksGETHandler) + r.AttachHandler(http.MethodGet, DomainBlockPath, m.DomainBlockGETHandler) + r.AttachHandler(http.MethodDelete, DomainBlockPath, m.DomainBlockDELETEHandler) return nil } diff --git a/internal/api/client/admin/domainblockcreate.go b/internal/api/client/admin/domainblockcreate.go @@ -0,0 +1,70 @@ +package admin + +import ( + "errors" + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// DomainBlocksPOSTHandler deals with the creation of a new domain block. +func (m *Module) DomainBlocksPOSTHandler(c *gin.Context) { + l := m.log.WithFields(logrus.Fields{ + "func": "DomainBlocksPOSTHandler", + "request_uri": c.Request.RequestURI, + "user_agent": c.Request.UserAgent(), + "origin_ip": c.ClientIP(), + }) + + // make sure we're authed with an admin account + 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.Admin { + l.Debugf("user %s not an admin", authed.User.ID) + c.JSON(http.StatusForbidden, gin.H{"error": "not an admin"}) + return + } + + // extract the media create form from the request context + l.Tracef("parsing request form: %+v", c.Request.Form) + form := &model.DomainBlockCreateRequest{} + if err := c.ShouldBind(form); err != nil { + l.Debugf("error parsing form %+v: %s", c.Request.Form, err) + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("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 := validateCreateDomainBlock(form); err != nil { + l.Debugf("error validating form: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + domainBlock, err := m.processor.AdminDomainBlockCreate(authed, form) + if err != nil { + l.Debugf("error creating domain block: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, domainBlock) +} + +func validateCreateDomainBlock(form *model.DomainBlockCreateRequest) error { + // add some more validation here later if necessary + if form.Domain == "" { + return errors.New("empty domain provided") + } + + return nil +} diff --git a/internal/api/client/admin/domainblockdelete.go b/internal/api/client/admin/domainblockdelete.go @@ -0,0 +1,47 @@ +package admin + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// DomainBlockDELETEHandler deals with the delete of an existing domain block. +func (m *Module) DomainBlockDELETEHandler(c *gin.Context) { + l := m.log.WithFields(logrus.Fields{ + "func": "DomainBlockDELETEHandler", + "request_uri": c.Request.RequestURI, + "user_agent": c.Request.UserAgent(), + "origin_ip": c.ClientIP(), + }) + + // make sure we're authed with an admin account + 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.Admin { + l.Debugf("user %s not an admin", authed.User.ID) + c.JSON(http.StatusForbidden, gin.H{"error": "not an admin"}) + return + } + + domainBlockID := c.Param(IDKey) + if domainBlockID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "no domain block id provided"}) + return + } + + domainBlock, errWithCode := m.processor.AdminDomainBlockDelete(authed, domainBlockID) + if errWithCode != nil { + l.Debugf("error deleting domain block: %s", errWithCode.Error()) + c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + return + } + + c.JSON(http.StatusOK, domainBlock) +} diff --git a/internal/api/client/admin/domainblockget.go b/internal/api/client/admin/domainblockget.go @@ -0,0 +1,60 @@ +package admin + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// DomainBlockGETHandler returns one existing domain block, identified by its id. +func (m *Module) DomainBlockGETHandler(c *gin.Context) { + l := m.log.WithFields(logrus.Fields{ + "func": "DomainBlockGETHandler", + "request_uri": c.Request.RequestURI, + "user_agent": c.Request.UserAgent(), + "origin_ip": c.ClientIP(), + }) + + // make sure we're authed with an admin account + 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.Admin { + l.Debugf("user %s not an admin", authed.User.ID) + c.JSON(http.StatusForbidden, gin.H{"error": "not an admin"}) + return + } + + domainBlockID := c.Param(IDKey) + if domainBlockID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "no domain block id provided"}) + return + } + + export := false + exportString := c.Query(ExportQueryKey) + if exportString != "" { + i, err := strconv.ParseBool(exportString) + if err != nil { + l.Debugf("error parsing export string: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "couldn't parse export query param"}) + return + } + export = i + } + + domainBlock, err := m.processor.AdminDomainBlockGet(authed, domainBlockID, export) + if err != nil { + l.Debugf("error getting domain block: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, domainBlock) +} diff --git a/internal/api/client/admin/domainblocksget.go b/internal/api/client/admin/domainblocksget.go @@ -0,0 +1,54 @@ +package admin + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// DomainBlocksGETHandler returns a list of all existing domain blocks. +func (m *Module) DomainBlocksGETHandler(c *gin.Context) { + l := m.log.WithFields(logrus.Fields{ + "func": "DomainBlocksGETHandler", + "request_uri": c.Request.RequestURI, + "user_agent": c.Request.UserAgent(), + "origin_ip": c.ClientIP(), + }) + + // make sure we're authed with an admin account + 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.Admin { + l.Debugf("user %s not an admin", authed.User.ID) + c.JSON(http.StatusForbidden, gin.H{"error": "not an admin"}) + return + } + + export := false + exportString := c.Query(ExportQueryKey) + if exportString != "" { + i, err := strconv.ParseBool(exportString) + if err != nil { + l.Debugf("error parsing export string: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "couldn't parse export query param"}) + return + } + export = i + } + + domainBlocks, err := m.processor.AdminDomainBlocksGet(authed, export) + if err != nil { + l.Debugf("error getting domain blocks: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, domainBlocks) +} diff --git a/internal/api/client/fileserver/servefile_test.go b/internal/api/client/fileserver/servefile_test.go @@ -78,7 +78,7 @@ func (suite *ServeFileTestSuite) SetupSuite() { suite.db = testrig.NewTestDB() suite.log = testrig.NewTestLog() suite.storage = testrig.NewTestStorage() - suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil))) + suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)), suite.storage) suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator) suite.tc = testrig.NewTestTypeConverter(suite.db) suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage) diff --git a/internal/api/client/instance/instancepatch.go b/internal/api/client/instance/instancepatch.go @@ -8,6 +8,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/oauth" ) +// InstanceUpdatePATCHHandler allows an admin to update the instance information served at /api/v1/instance func (m *Module) InstanceUpdatePATCHHandler(c *gin.Context) { l := m.log.WithField("func", "InstanceUpdatePATCHHandler") authed, err := oauth.Authed(c, true, true, true, true) diff --git a/internal/api/client/media/mediacreate_test.go b/internal/api/client/media/mediacreate_test.go @@ -84,7 +84,7 @@ func (suite *MediaCreateTestSuite) SetupSuite() { suite.tc = testrig.NewTestTypeConverter(suite.db) suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage) suite.oauthServer = testrig.NewTestOauthServer(suite.db) - suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil))) + suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)), suite.storage) suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator) // setup module being tested diff --git a/internal/api/client/status/statusboost_test.go b/internal/api/client/status/statusboost_test.go @@ -52,7 +52,7 @@ func (suite *StatusBoostTestSuite) SetupTest() { suite.db = testrig.NewTestDB() suite.storage = testrig.NewTestStorage() suite.log = testrig.NewTestLog() - suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil))) + suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)), suite.storage) suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator) suite.statusModule = status.New(suite.config, suite.processor, suite.log).(*status.Module) testrig.StandardDBSetup(suite.db) diff --git a/internal/api/client/status/statuscreate_test.go b/internal/api/client/status/statuscreate_test.go @@ -57,7 +57,7 @@ func (suite *StatusCreateTestSuite) SetupTest() { suite.db = testrig.NewTestDB() suite.storage = testrig.NewTestStorage() suite.log = testrig.NewTestLog() - suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil))) + suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)), suite.storage) suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator) suite.statusModule = status.New(suite.config, suite.processor, suite.log).(*status.Module) testrig.StandardDBSetup(suite.db) diff --git a/internal/api/client/status/statusfave_test.go b/internal/api/client/status/statusfave_test.go @@ -55,7 +55,7 @@ func (suite *StatusFaveTestSuite) SetupTest() { suite.db = testrig.NewTestDB() suite.storage = testrig.NewTestStorage() suite.log = testrig.NewTestLog() - suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil))) + suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)), suite.storage) suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator) suite.statusModule = status.New(suite.config, suite.processor, suite.log).(*status.Module) testrig.StandardDBSetup(suite.db) diff --git a/internal/api/client/status/statusfavedby_test.go b/internal/api/client/status/statusfavedby_test.go @@ -55,7 +55,7 @@ func (suite *StatusFavedByTestSuite) SetupTest() { suite.db = testrig.NewTestDB() suite.storage = testrig.NewTestStorage() suite.log = testrig.NewTestLog() - suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil))) + suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)), suite.storage) suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator) suite.statusModule = status.New(suite.config, suite.processor, suite.log).(*status.Module) testrig.StandardDBSetup(suite.db) diff --git a/internal/api/client/status/statusget_test.go b/internal/api/client/status/statusget_test.go @@ -45,7 +45,7 @@ func (suite *StatusGetTestSuite) SetupTest() { suite.db = testrig.NewTestDB() suite.storage = testrig.NewTestStorage() suite.log = testrig.NewTestLog() - suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil))) + suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)), suite.storage) suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator) suite.statusModule = status.New(suite.config, suite.processor, suite.log).(*status.Module) testrig.StandardDBSetup(suite.db) diff --git a/internal/api/client/status/statusunfave_test.go b/internal/api/client/status/statusunfave_test.go @@ -55,7 +55,7 @@ func (suite *StatusUnfaveTestSuite) SetupTest() { suite.db = testrig.NewTestDB() suite.storage = testrig.NewTestStorage() suite.log = testrig.NewTestLog() - suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil))) + suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)), suite.storage) suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator) suite.statusModule = status.New(suite.config, suite.processor, suite.log).(*status.Module) testrig.StandardDBSetup(suite.db) diff --git a/internal/api/model/domainblock.go b/internal/api/model/domainblock.go @@ -0,0 +1,43 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package model + +// DomainBlock represents a block on one domain +type DomainBlock struct { + ID string `json:"id,omitempty"` + Domain string `json:"domain"` + Obfuscate bool `json:"obfuscate,omitempty"` + PrivateComment string `json:"private_comment,omitempty"` + PublicComment string `json:"public_comment,omitempty"` + SubscriptionID string `json:"subscription_id,omitempty"` + CreatedBy string `json:"created_by,omitempty"` + CreatedAt string `json:"created_at,omitempty"` +} + +// DomainBlockCreateRequest is the form submitted as a POST to /api/v1/admin/domain_blocks to create a new block. +type DomainBlockCreateRequest struct { + // hostname/domain to block + Domain string `form:"domain" json:"domain" xml:"domain" validation:"required"` + // whether the domain should be obfuscated when being displayed publicly + Obfuscate bool `form:"obfuscate" json:"obfuscate" xml:"obfuscate"` + // private comment for other admins on why the domain was blocked + PrivateComment string `form:"private_comment" json:"private_comment" xml:"private_comment"` + // public comment on the reason for the domain block + PublicComment string `form:"public_comment" json:"public_comment" xml:"public_comment"` +} diff --git a/internal/api/s2s/user/followers.go b/internal/api/s2s/user/followers.go @@ -19,10 +19,12 @@ package user import ( + "context" "net/http" "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/util" ) // FollowersGETHandler returns a collection of URIs for followers of the target user, formatted so that other AP servers can understand it. @@ -46,9 +48,14 @@ func (m *Module) FollowersGETHandler(c *gin.Context) { } l.Tracef("negotiated format: %s", format) - // make a copy of the context to pass along so we don't break anything - cp := c.Copy() - user, err := m.processor.GetFediFollowers(requestedUsername, cp.Request) // GetFediUser handles auth as well + // transfer the signature verifier from the gin context to the request context + ctx := c.Request.Context() + verifier, signed := c.Get(string(util.APRequestingPublicKeyVerifier)) + if signed { + ctx = context.WithValue(ctx, util.APRequestingPublicKeyVerifier, verifier) + } + + user, err := m.processor.GetFediFollowers(ctx, requestedUsername, c.Request.URL) // GetFediUser handles auth as well if err != nil { l.Info(err.Error()) c.JSON(err.Code(), gin.H{"error": err.Safe()}) diff --git a/internal/api/s2s/user/following.go b/internal/api/s2s/user/following.go @@ -19,10 +19,12 @@ package user import ( + "context" "net/http" "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/util" ) // FollowingGETHandler returns a collection of URIs for accounts that the target user follows, formatted so that other AP servers can understand it. @@ -46,9 +48,14 @@ func (m *Module) FollowingGETHandler(c *gin.Context) { } l.Tracef("negotiated format: %s", format) - // make a copy of the context to pass along so we don't break anything - cp := c.Copy() - user, err := m.processor.GetFediFollowing(requestedUsername, cp.Request) // handles auth as well + // transfer the signature verifier from the gin context to the request context + ctx := c.Request.Context() + verifier, signed := c.Get(string(util.APRequestingPublicKeyVerifier)) + if signed { + ctx = context.WithValue(ctx, util.APRequestingPublicKeyVerifier, verifier) + } + + user, err := m.processor.GetFediFollowing(ctx, requestedUsername, c.Request.URL) // handles auth as well if err != nil { l.Info(err.Error()) c.JSON(err.Code(), gin.H{"error": err.Safe()}) diff --git a/internal/api/s2s/user/inboxpost.go b/internal/api/s2s/user/inboxpost.go @@ -19,11 +19,13 @@ package user import ( + "context" "net/http" "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/util" ) // InboxPOSTHandler deals with incoming POST requests to an actor's inbox. @@ -40,7 +42,14 @@ func (m *Module) InboxPOSTHandler(c *gin.Context) { return } - posted, err := m.processor.InboxPost(c.Request.Context(), c.Writer, c.Request) + // transfer the signature verifier from the gin context to the request context + ctx := c.Request.Context() + verifier, signed := c.Get(string(util.APRequestingPublicKeyVerifier)) + if signed { + ctx = context.WithValue(ctx, util.APRequestingPublicKeyVerifier, verifier) + } + + posted, err := m.processor.InboxPost(ctx, c.Writer, c.Request) if err != nil { if withCode, ok := err.(gtserror.WithCode); ok { l.Debug(withCode.Error()) diff --git a/internal/api/s2s/user/publickeyget.go b/internal/api/s2s/user/publickeyget.go @@ -1,10 +1,12 @@ package user import ( + "context" "net/http" "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/util" ) // PublicKeyGETHandler should be served at eg https://example.org/users/:username/main-key. @@ -32,9 +34,14 @@ func (m *Module) PublicKeyGETHandler(c *gin.Context) { } l.Tracef("negotiated format: %s", format) - // make a copy of the context to pass along so we don't break anything - cp := c.Copy() - user, err := m.processor.GetFediUser(requestedUsername, cp.Request) // GetFediUser handles auth as well + // transfer the signature verifier from the gin context to the request context + ctx := c.Request.Context() + verifier, signed := c.Get(string(util.APRequestingPublicKeyVerifier)) + if signed { + ctx = context.WithValue(ctx, util.APRequestingPublicKeyVerifier, verifier) + } + + user, err := m.processor.GetFediUser(ctx, requestedUsername, c.Request.URL) // GetFediUser handles auth as well if err != nil { l.Info(err.Error()) c.JSON(err.Code(), gin.H{"error": err.Safe()}) diff --git a/internal/api/s2s/user/statusget.go b/internal/api/s2s/user/statusget.go @@ -1,10 +1,12 @@ package user import ( + "context" "net/http" "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/util" ) // StatusGETHandler serves the target status as an activitystreams NOTE so that other AP servers can parse it. @@ -34,9 +36,14 @@ func (m *Module) StatusGETHandler(c *gin.Context) { } l.Tracef("negotiated format: %s", format) - // make a copy of the context to pass along so we don't break anything - cp := c.Copy() - status, err := m.processor.GetFediStatus(requestedUsername, requestedStatusID, cp.Request) // handles auth as well + // transfer the signature verifier from the gin context to the request context + ctx := c.Request.Context() + verifier, signed := c.Get(string(util.APRequestingPublicKeyVerifier)) + if signed { + ctx = context.WithValue(ctx, util.APRequestingPublicKeyVerifier, verifier) + } + + status, err := m.processor.GetFediStatus(ctx, requestedUsername, requestedStatusID, c.Request.URL) // handles auth as well if err != nil { l.Info(err.Error()) c.JSON(err.Code(), gin.H{"error": err.Safe()}) diff --git a/internal/api/s2s/user/userget.go b/internal/api/s2s/user/userget.go @@ -19,10 +19,12 @@ package user import ( + "context" "net/http" "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/util" ) // UsersGETHandler should be served at https://example.org/users/:username. @@ -54,9 +56,14 @@ func (m *Module) UsersGETHandler(c *gin.Context) { } l.Tracef("negotiated format: %s", format) - // make a copy of the context to pass along so we don't break anything - cp := c.Copy() - user, err := m.processor.GetFediUser(requestedUsername, cp.Request) // GetFediUser handles auth as well + // transfer the signature verifier from the gin context to the request context + ctx := c.Request.Context() + verifier, signed := c.Get(string(util.APRequestingPublicKeyVerifier)) + if signed { + ctx = context.WithValue(ctx, util.APRequestingPublicKeyVerifier, verifier) + } + + user, err := m.processor.GetFediUser(ctx, requestedUsername, c.Request.URL) // GetFediUser handles auth as well if err != nil { l.Info(err.Error()) c.JSON(err.Code(), gin.H{"error": err.Safe()}) diff --git a/internal/api/s2s/user/userget_test.go b/internal/api/s2s/user/userget_test.go @@ -42,7 +42,7 @@ func (suite *UserGetTestSuite) SetupTest() { suite.tc = testrig.NewTestTypeConverter(suite.db) suite.storage = testrig.NewTestStorage() suite.log = testrig.NewTestLog() - suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil))) + suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil)), suite.storage) suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator) suite.userModule = user.New(suite.config, suite.processor, suite.log).(*user.Module) testrig.StandardDBSetup(suite.db) @@ -98,7 +98,7 @@ func (suite *UserGetTestSuite) TestGetUser() { }, nil })) // get this transport controller embedded right in the user module we're testing - federator := testrig.NewTestFederator(suite.db, tc) + federator := testrig.NewTestFederator(suite.db, tc, suite.storage) processor := testrig.NewTestProcessor(suite.db, suite.storage, federator) userModule := user.New(suite.config, processor, suite.log).(*user.Module) diff --git a/internal/api/s2s/webfinger/webfingerget.go b/internal/api/s2s/webfinger/webfingerget.go @@ -19,12 +19,14 @@ package webfinger import ( + "context" "fmt" "net/http" "strings" "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/util" ) // WebfingerGETRequest handles requests to, for example, https://example.org/.well-known/webfinger?resource=acct:some_user@example.org @@ -68,7 +70,14 @@ func (m *Module) WebfingerGETRequest(c *gin.Context) { return } - resp, err := m.processor.GetWebfingerAccount(username, c.Request) + // transfer the signature verifier from the gin context to the request context + ctx := c.Request.Context() + verifier, signed := c.Get(string(util.APRequestingPublicKeyVerifier)) + if signed { + ctx = context.WithValue(ctx, util.APRequestingPublicKeyVerifier, verifier) + } + + resp, err := m.processor.GetWebfingerAccount(ctx, username, c.Request.URL) if err != nil { l.Debugf("aborting request with an error: %s", err.Error()) c.JSON(err.Code(), gin.H{"error": err.Safe()}) diff --git a/internal/api/security/security.go b/internal/api/security/security.go @@ -24,6 +24,7 @@ import ( "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/api" "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/router" ) @@ -33,18 +34,21 @@ const robotsPath = "/robots.txt" type Module struct { config *config.Config log *logrus.Logger + db db.DB } // New returns a new security module -func New(config *config.Config, log *logrus.Logger) api.ClientModule { +func New(config *config.Config, db db.DB, log *logrus.Logger) api.ClientModule { return &Module{ config: config, log: log, + db: db, } } // Route attaches security middleware to the given router func (m *Module) Route(s router.Router) error { + s.AttachMiddleware(m.SignatureCheck) s.AttachMiddleware(m.FlocBlock) s.AttachMiddleware(m.ExtraHeaders) s.AttachMiddleware(m.UserAgentBlock) diff --git a/internal/api/security/signaturecheck.go b/internal/api/security/signaturecheck.go @@ -0,0 +1,69 @@ +package security + +import ( + "net/http" + "net/url" + + "github.com/gin-gonic/gin" + "github.com/go-fed/httpsig" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +// SignatureCheck checks whether an incoming http request has been signed. If so, it will check if the domain +// that signed the request is permitted to access the server. If it is permitted, the handler will set the key +// verifier in the gin context for use down the line. +func (m *Module) SignatureCheck(c *gin.Context) { + l := m.log.WithField("func", "DomainBlockChecker") + + // set this extra field for signature validation + c.Request.Header.Set("host", m.config.Host) + + // create the verifier from the request + // if the request is signed, it will have a signature header + verifier, err := httpsig.NewVerifier(c.Request) + if err == nil { + // the request was signed! + + // The key ID should be given in the signature so that we know where to fetch it from the remote server. + // This will be something like https://example.org/users/whatever_requesting_user#main-key + requestingPublicKeyID, err := url.Parse(verifier.KeyId()) + if err == nil && requestingPublicKeyID != nil { + // we managed to parse the url! + + // if the domain is blocked we want to bail as early as possible + blockedDomain, err := m.blockedDomain(requestingPublicKeyID.Host) + if err != nil { + l.Errorf("could not tell if domain %s was blocked or not: %s", requestingPublicKeyID.Host, err) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + if blockedDomain { + l.Infof("domain %s is blocked", requestingPublicKeyID.Host) + c.AbortWithStatus(http.StatusForbidden) + return + } + + // set the verifier on the context here to save some work further down the line + c.Set(string(util.APRequestingPublicKeyVerifier), verifier) + } + } +} + +func (m *Module) blockedDomain(host string) (bool, error) { + b := &gtsmodel.DomainBlock{} + err := m.db.GetWhere([]db.Where{{Key: "domain", Value: host, CaseInsensitive: true}}, b) + if err == nil { + // block exists + return true, nil + } + + if _, ok := err.(db.ErrNoEntries); ok { + // there are no entries so there's no block + return false, nil + } + + // there's an actual error + return false, err +} diff --git a/internal/cliactions/server/server.go b/internal/cliactions/server/server.go @@ -112,7 +112,7 @@ var Start cliactions.GTSAction = func(ctx context.Context, c *config.Config, log mediaHandler := media.New(c, dbService, storageBackend, log) oauthServer := oauth.New(dbService, log) transportController := transport.NewController(c, &federation.Clock{}, http.DefaultClient, log) - federator := federation.NewFederator(dbService, federatingDB, transportController, c, log, typeConverter) + federator := federation.NewFederator(dbService, federatingDB, transportController, c, log, typeConverter, mediaHandler) processor := processing.NewProcessor(c, typeConverter, federator, oauthServer, mediaHandler, storageBackend, timelineManager, dbService, log) if err := processor.Start(); err != nil { return fmt.Errorf("error starting processor: %s", err) @@ -138,7 +138,7 @@ var Start cliactions.GTSAction = func(ctx context.Context, c *config.Config, log fileServerModule := fileserver.New(c, processor, log) adminModule := admin.New(c, processor, log) statusModule := status.New(c, processor, log) - securityModule := security.New(c, log) + securityModule := security.New(c, dbService, log) streamingModule := streaming.New(c, processor, log) apis := []api.ClientModule{ diff --git a/internal/cliactions/testrig/testrig.go b/internal/cliactions/testrig/testrig.go @@ -57,7 +57,7 @@ var Start cliactions.GTSAction = func(ctx context.Context, _ *config.Config, log Body: r, }, nil })) - federator := testrig.NewTestFederator(dbService, transportController) + federator := testrig.NewTestFederator(dbService, transportController, storageBackend) processor := testrig.NewTestProcessor(dbService, storageBackend, federator) if err := processor.Start(); err != nil { @@ -84,7 +84,7 @@ var Start cliactions.GTSAction = func(ctx context.Context, _ *config.Config, log fileServerModule := fileserver.New(c, processor, log) adminModule := admin.New(c, processor, log) statusModule := status.New(c, processor, log) - securityModule := security.New(c, log) + securityModule := security.New(c, dbService, log) streamingModule := streaming.New(c, processor, log) apis := []api.ClientModule{ diff --git a/internal/db/db.go b/internal/db/db.go @@ -65,11 +65,6 @@ type DB interface { // In case of no entries, a 'no entries' error will be returned GetWhere(where []Where, i interface{}) error - // // GetWhereMany gets one entry where key = value for *ALL* parameters passed as "where". - // // That is, if you pass 2 'where' entries, with 1 being Key username and Value test, and the second - // // being Key domain and Value example.org, only entries will be returned where BOTH conditions are true. - // GetWhereMany(i interface{}, where ...model.Where) error - // GetAll will try to get all entries of type i. // The given interface i will be set to the result of the query, whatever it is. Use a pointer or a slice. // In case of no entries, a 'no entries' error will be returned @@ -155,11 +150,11 @@ type DB interface { // CountStatusesByAccountID is a shortcut for the common action of counting statuses produced by accountID. CountStatusesByAccountID(accountID string) (int, error) - // GetStatusesByTimeDescending is a shortcut for getting the most recent statuses. accountID is optional, if not provided + // GetStatusesForAccount is a shortcut for getting the most recent statuses. accountID is optional, if not provided // then all statuses will be returned. If limit is set to 0, the size of the returned slice will not be limited. This can // be very memory intensive so you probably shouldn't do this! // In case of no entries, a 'no entries' error will be returned - GetStatusesByTimeDescending(accountID string, statuses *[]gtsmodel.Status, limit int, excludeReplies bool, maxID string, pinned bool, mediaOnly bool) error + GetStatusesForAccount(accountID string, limit int, excludeReplies bool, maxID string, pinnedOnly bool, mediaOnly bool) ([]*gtsmodel.Status, error) // GetLastStatusForAccountID simply gets the most recent status by the given account. // The given slice 'status' pointer will be set to the result of the query, whatever it is. @@ -261,6 +256,10 @@ type DB interface { // GetDomainCountForInstance returns the number of known instances known that the given domain federates with. GetDomainCountForInstance(domain string) (int, error) + + // GetAccountsForInstance returns a slice of accounts from the given instance, arranged by ID. + GetAccountsForInstance(domain string, maxID string, limit int) ([]*gtsmodel.Account, error) + /* USEFUL CONVERSION FUNCTIONS */ diff --git a/internal/db/pg/instance.go b/internal/db/pg/instance.go @@ -0,0 +1,83 @@ +package pg + +import ( + "github.com/go-pg/pg/v10" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +func (ps *postgresService) GetUserCountForInstance(domain string) (int, error) { + q := ps.conn.Model(&[]*gtsmodel.Account{}) + + if domain == ps.config.Host { + // if the domain is *this* domain, just count where the domain field is null + q = q.Where("? IS NULL", pg.Ident("domain")) + } else { + q = q.Where("domain = ?", domain) + } + + // don't count the instance account or suspended users + q = q.Where("username != ?", domain).Where("? IS NULL", pg.Ident("suspended_at")) + + return q.Count() +} + +func (ps *postgresService) GetStatusCountForInstance(domain string) (int, error) { + q := ps.conn.Model(&[]*gtsmodel.Status{}) + + if domain == ps.config.Host { + // if the domain is *this* domain, just count where local is true + q = q.Where("local = ?", true) + } else { + // join on the domain of the account + q = q.Join("JOIN accounts AS account ON account.id = status.account_id"). + Where("account.domain = ?", domain) + } + + return q.Count() +} + +func (ps *postgresService) GetDomainCountForInstance(domain string) (int, error) { + q := ps.conn.Model(&[]*gtsmodel.Instance{}) + + if domain == ps.config.Host { + // if the domain is *this* domain, just count other instances it knows about + // TODO: exclude domains that are blocked or silenced + q = q.Where("domain != ?", domain) + } else { + // TODO: implement federated domain counting properly for remote domains + return 0, nil + } + + return q.Count() +} + +func (ps *postgresService) GetAccountsForInstance(domain string, maxID string, limit int) ([]*gtsmodel.Account, error) { + ps.log.Debug("GetAccountsForInstance") + + accounts := []*gtsmodel.Account{} + + q := ps.conn.Model(&accounts).Where("domain = ?", domain).Order("id DESC") + + if maxID != "" { + q = q.Where("id < ?", maxID) + } + + if limit > 0 { + q = q.Limit(limit) + } + + err := q.Select() + if err != nil { + if err == pg.ErrNoRows { + return nil, db.ErrNoEntries{} + } + return nil, err + } + + if len(accounts) == 0 { + return nil, db.ErrNoEntries{} + } + + return accounts, nil +} diff --git a/internal/db/pg/instancestats.go b/internal/db/pg/instancestats.go @@ -1,52 +0,0 @@ -package pg - -import ( - "github.com/go-pg/pg/v10" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" -) - -func (ps *postgresService) GetUserCountForInstance(domain string) (int, error) { - q := ps.conn.Model(&[]*gtsmodel.Account{}) - - if domain == ps.config.Host { - // if the domain is *this* domain, just count where the domain field is null - q = q.Where("? IS NULL", pg.Ident("domain")) - } else { - q = q.Where("domain = ?", domain) - } - - // don't count the instance account or suspended users - q = q.Where("username != ?", domain).Where("? IS NULL", pg.Ident("suspended_at")) - - return q.Count() -} - -func (ps *postgresService) GetStatusCountForInstance(domain string) (int, error) { - q := ps.conn.Model(&[]*gtsmodel.Status{}) - - if domain == ps.config.Host { - // if the domain is *this* domain, just count where local is true - q = q.Where("local = ?", true) - } else { - // join on the domain of the account - q = q.Join("JOIN accounts AS account ON account.id = status.account_id"). - Where("account.domain = ?", domain) - } - - return q.Count() -} - -func (ps *postgresService) GetDomainCountForInstance(domain string) (int, error) { - q := ps.conn.Model(&[]*gtsmodel.Instance{}) - - if domain == ps.config.Host { - // if the domain is *this* domain, just count other instances it knows about - // TODO: exclude domains that are blocked or silenced - q = q.Where("domain != ?", domain) - } else { - // TODO: implement federated domain counting properly for remote domains - return 0, nil - } - - return q.Count() -} diff --git a/internal/db/pg/pg.go b/internal/db/pg/pg.go @@ -511,39 +511,50 @@ func (ps *postgresService) CountStatusesByAccountID(accountID string) (int, erro return count, nil } -func (ps *postgresService) GetStatusesByTimeDescending(accountID string, statuses *[]gtsmodel.Status, limit int, excludeReplies bool, maxID string, pinned bool, mediaOnly bool) error { - q := ps.conn.Model(statuses).Order("created_at DESC") +func (ps *postgresService) GetStatusesForAccount(accountID string, limit int, excludeReplies bool, maxID string, pinnedOnly bool, mediaOnly bool) ([]*gtsmodel.Status, error) { + ps.log.Debugf("getting statuses for account %s", accountID) + statuses := []*gtsmodel.Status{} + + q := ps.conn.Model(&statuses).Order("id DESC") if accountID != "" { q = q.Where("account_id = ?", accountID) } + if limit != 0 { q = q.Limit(limit) } + if excludeReplies { q = q.Where("? IS NULL", pg.Ident("in_reply_to_id")) } - if pinned { + + if pinnedOnly { q = q.Where("pinned = ?", true) } + if mediaOnly { q = q.WhereGroup(func(q *pg.Query) (*pg.Query, error) { return q.Where("? IS NOT NULL", pg.Ident("attachments")).Where("attachments != '{}'"), nil }) } + if maxID != "" { - s := &gtsmodel.Status{} - if err := ps.conn.Model(s).Where("id = ?", maxID).Select(); err != nil { - return err - } - q = q.Where("status.created_at < ?", s.CreatedAt) + q = q.Where("id < ?", maxID) } + if err := q.Select(); err != nil { if err == pg.ErrNoRows { - return db.ErrNoEntries{} + return nil, db.ErrNoEntries{} } - return err + return nil, err } - return nil + + if len(statuses) == 0 { + return nil, db.ErrNoEntries{} + } + + ps.log.Debugf("returning statuses for account %s", accountID) + return statuses, nil } func (ps *postgresService) GetLastStatusForAccountID(accountID string, status *gtsmodel.Status) error { diff --git a/internal/federation/authenticate.go b/internal/federation/authenticate.go @@ -25,7 +25,6 @@ import ( "encoding/pem" "errors" "fmt" - "net/http" "net/url" "strings" @@ -35,6 +34,7 @@ import ( "github.com/go-fed/httpsig" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/util" ) /* @@ -115,34 +115,30 @@ func getPublicKeyFromResponse(c context.Context, b []byte, keyID *url.URL) (voca // // Also note that this function *does not* dereference the remote account that the signature key is associated with. // Other functions should use the returned URL to dereference the remote account, if required. -func (f *federator) AuthenticateFederatedRequest(requestedUsername string, r *http.Request) (*url.URL, error) { +func (f *federator) AuthenticateFederatedRequest(ctx context.Context, requestedUsername string) (*url.URL, bool, error) { + l := f.log.WithField("func", "AuthenticateFederatedRequest") var publicKey interface{} var pkOwnerURI *url.URL var err error - // set this extra field for signature validation - r.Header.Set("host", f.config.Host) - - verifier, err := httpsig.NewVerifier(r) - if err != nil { - return nil, fmt.Errorf("could not create http sig verifier: %s", err) + // thanks to signaturecheck.go in the security package, we should already have a signature verifier set on the context + vi := ctx.Value(util.APRequestingPublicKeyVerifier) + if vi == nil { + l.Debug("request wasn't signed") + return nil, false, nil // request wasn't signed } - // The key ID should be given in the signature so that we know where to fetch it from the remote server. - // This will be something like https://example.org/users/whatever_requesting_user#main-key - requestingPublicKeyID, err := url.Parse(verifier.KeyId()) - if err != nil { - return nil, fmt.Errorf("could not parse key id into a url: %s", err) + verifier, ok := vi.(httpsig.Verifier) + if !ok { + l.Debug("couldn't extract sig verifier") + return nil, false, nil // couldn't extract the verifier } - // if the domain is blocked we want to make as few calls towards it as possible, so already bail here if that's the case! - blockedDomain, err := f.blockedDomain(requestingPublicKeyID.Host) + requestingPublicKeyID, err := url.Parse(verifier.KeyId()) if err != nil { - return nil, fmt.Errorf("could not tell if domain %s was blocked or not: %s", requestingPublicKeyID.Host, err) - } - if blockedDomain { - return nil, fmt.Errorf("host %s was domain blocked, aborting auth", requestingPublicKeyID.Host) + l.Debug("couldn't parse public key URL") + return nil, false, nil // couldn't parse the public key ID url } requestingRemoteAccount := &gtsmodel.Account{} @@ -152,12 +148,12 @@ func (f *federator) AuthenticateFederatedRequest(requestedUsername string, r *ht // LOCAL ACCOUNT REQUEST // the request is coming from INSIDE THE HOUSE so skip the remote dereferencing if err := f.db.GetWhere([]db.Where{{Key: "public_key_uri", Value: requestingPublicKeyID.String()}}, requestingLocalAccount); err != nil { - return nil, fmt.Errorf("couldn't get local account with public key uri %s from the database: %s", requestingPublicKeyID.String(), err) + return nil, false, fmt.Errorf("couldn't get local account with public key uri %s from the database: %s", requestingPublicKeyID.String(), err) } publicKey = requestingLocalAccount.PublicKey pkOwnerURI, err = url.Parse(requestingLocalAccount.URI) if err != nil { - return nil, fmt.Errorf("error parsing url %s: %s", requestingLocalAccount.URI, err) + return nil, false, fmt.Errorf("error parsing url %s: %s", requestingLocalAccount.URI, err) } } else if err := f.db.GetWhere([]db.Where{{Key: "public_key_uri", Value: requestingPublicKeyID.String()}}, requestingRemoteAccount); err == nil { // REMOTE ACCOUNT REQUEST WITH KEY CACHED LOCALLY @@ -165,7 +161,7 @@ func (f *federator) AuthenticateFederatedRequest(requestedUsername string, r *ht publicKey = requestingRemoteAccount.PublicKey pkOwnerURI, err = url.Parse(requestingRemoteAccount.URI) if err != nil { - return nil, fmt.Errorf("error parsing url %s: %s", requestingRemoteAccount.URI, err) + return nil, false, fmt.Errorf("error parsing url %s: %s", requestingRemoteAccount.URI, err) } } else { // REMOTE ACCOUNT REQUEST WITHOUT KEY CACHED LOCALLY @@ -173,72 +169,55 @@ func (f *federator) AuthenticateFederatedRequest(requestedUsername string, r *ht // so we need to authenticate the request properly by dereferencing the remote key transport, err := f.GetTransportForUser(requestedUsername) if err != nil { - return nil, fmt.Errorf("transport err: %s", err) + return nil, false, fmt.Errorf("transport err: %s", err) } // The actual http call to the remote server is made right here in the Dereference function. b, err := transport.Dereference(context.Background(), requestingPublicKeyID) if err != nil { - return nil, fmt.Errorf("error deferencing key %s: %s", requestingPublicKeyID.String(), err) + return nil, false, fmt.Errorf("error deferencing key %s: %s", requestingPublicKeyID.String(), err) } // if the key isn't in the response, we can't authenticate the request requestingPublicKey, err := getPublicKeyFromResponse(context.Background(), b, requestingPublicKeyID) if err != nil { - return nil, fmt.Errorf("error getting key %s from response %s: %s", requestingPublicKeyID.String(), string(b), err) + return nil, false, fmt.Errorf("error getting key %s from response %s: %s", requestingPublicKeyID.String(), string(b), err) } // we should be able to get the actual key embedded in the vocab.W3IDSecurityV1PublicKey pkPemProp := requestingPublicKey.GetW3IDSecurityV1PublicKeyPem() if pkPemProp == nil || !pkPemProp.IsXMLSchemaString() { - return nil, errors.New("publicKeyPem property is not provided or it is not embedded as a value") + return nil, false, errors.New("publicKeyPem property is not provided or it is not embedded as a value") } // and decode the PEM so that we can parse it as a golang public key pubKeyPem := pkPemProp.Get() block, _ := pem.Decode([]byte(pubKeyPem)) if block == nil || block.Type != "PUBLIC KEY" { - return nil, errors.New("could not decode publicKeyPem to PUBLIC KEY pem block type") + return nil, false, errors.New("could not decode publicKeyPem to PUBLIC KEY pem block type") } publicKey, err = x509.ParsePKIXPublicKey(block.Bytes) if err != nil { - return nil, fmt.Errorf("could not parse public key from block bytes: %s", err) + return nil, false, fmt.Errorf("could not parse public key from block bytes: %s", err) } // all good! we just need the URI of the key owner to return pkOwnerProp := requestingPublicKey.GetW3IDSecurityV1Owner() if pkOwnerProp == nil || !pkOwnerProp.IsIRI() { - return nil, errors.New("publicKeyOwner property is not provided or it is not embedded as a value") + return nil, false, errors.New("publicKeyOwner property is not provided or it is not embedded as a value") } pkOwnerURI = pkOwnerProp.GetIRI() } if publicKey == nil { - return nil, errors.New("returned public key was empty") + return nil, false, errors.New("returned public key was empty") } // do the actual authentication here! algo := httpsig.RSA_SHA256 // TODO: make this more robust if err := verifier.Verify(publicKey, algo); err != nil { - return nil, fmt.Errorf("error verifying key %s: %s", requestingPublicKeyID.String(), err) - } - - return pkOwnerURI, nil -} - -func (f *federator) blockedDomain(host string) (bool, error) { - b := &gtsmodel.DomainBlock{} - err := f.db.GetWhere([]db.Where{{Key: "domain", Value: host, CaseInsensitive: true}}, b) - if err == nil { - // block exists - return true, nil - } - - if _, ok := err.(db.ErrNoEntries); ok { - // there are no entries so there's no block - return false, nil + return nil, false, nil } - // there's an actual error - return false, err + return pkOwnerURI, true, nil } diff --git a/internal/federation/dereference.go b/internal/federation/dereference.go @@ -9,7 +9,11 @@ import ( "github.com/go-fed/activity/streams" "github.com/go-fed/activity/streams/vocab" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" + "github.com/superseriousbusiness/gotosocial/internal/transport" "github.com/superseriousbusiness/gotosocial/internal/typeutils" ) @@ -17,6 +21,10 @@ func (f *federator) DereferenceRemoteAccount(username string, remoteAccountID *u f.startHandshake(username, remoteAccountID) defer f.stopHandshake(username, remoteAccountID) + if blocked, err := f.blockedDomain(remoteAccountID.Host); blocked || err != nil { + return nil, fmt.Errorf("DereferenceRemoteAccount: domain %s is blocked", remoteAccountID.Host) + } + transport, err := f.GetTransportForUser(username) if err != nil { return nil, fmt.Errorf("transport err: %s", err) @@ -62,6 +70,10 @@ func (f *federator) DereferenceRemoteAccount(username string, remoteAccountID *u } func (f *federator) DereferenceRemoteStatus(username string, remoteStatusID *url.URL) (typeutils.Statusable, error) { + if blocked, err := f.blockedDomain(remoteStatusID.Host); blocked || err != nil { + return nil, fmt.Errorf("DereferenceRemoteStatus: domain %s is blocked", remoteStatusID.Host) + } + transport, err := f.GetTransportForUser(username) if err != nil { return nil, fmt.Errorf("transport err: %s", err) @@ -144,6 +156,10 @@ func (f *federator) DereferenceRemoteStatus(username string, remoteStatusID *url } func (f *federator) DereferenceRemoteInstance(username string, remoteInstanceURI *url.URL) (*gtsmodel.Instance, error) { + if blocked, err := f.blockedDomain(remoteInstanceURI.Host); blocked || err != nil { + return nil, fmt.Errorf("DereferenceRemoteInstance: domain %s is blocked", remoteInstanceURI.Host) + } + transport, err := f.GetTransportForUser(username) if err != nil { return nil, fmt.Errorf("transport err: %s", err) @@ -151,3 +167,358 @@ func (f *federator) DereferenceRemoteInstance(username string, remoteInstanceURI return transport.DereferenceInstance(context.Background(), remoteInstanceURI) } + +// dereferenceStatusFields fetches all the information we temporarily pinned to an incoming +// federated status, back in the federating db's Create function. +// +// When a status comes in from the federation API, there are certain fields that +// haven't been dereferenced yet, because we needed to provide a snappy synchronous +// response to the caller. By the time it reaches this function though, it's being +// processed asynchronously, so we have all the time in the world to fetch the various +// bits and bobs that are attached to the status, and properly flesh it out, before we +// send the status to any timelines and notify people. +// +// Things to dereference and fetch here: +// +// 1. Media attachments. +// 2. Hashtags. +// 3. Emojis. +// 4. Mentions. +// 5. Posting account. +// 6. Replied-to-status. +// +// SIDE EFFECTS: +// This function will deference all of the above, insert them in the database as necessary, +// and attach them to the status. The status itself will not be added to the database yet, +// that's up the caller to do. +func (f *federator) DereferenceStatusFields(status *gtsmodel.Status, requestingUsername string) error { + l := f.log.WithFields(logrus.Fields{ + "func": "dereferenceStatusFields", + "status": fmt.Sprintf("%+v", status), + }) + l.Debug("entering function") + + statusURI, err := url.Parse(status.URI) + if err != nil { + return fmt.Errorf("DereferenceStatusFields: couldn't parse status URI %s: %s", status.URI, err) + } + if blocked, err := f.blockedDomain(statusURI.Host); blocked || err != nil { + return fmt.Errorf("DereferenceStatusFields: domain %s is blocked", statusURI.Host) + } + + t, err := f.GetTransportForUser(requestingUsername) + if err != nil { + return fmt.Errorf("error creating transport: %s", err) + } + + // the status should have an ID by now, but just in case it doesn't let's generate one here + // because we'll need it further down + if status.ID == "" { + newID, err := id.NewULIDFromTime(status.CreatedAt) + if err != nil { + return err + } + status.ID = newID + } + + // 1. Media attachments. + // + // At this point we should know: + // * the media type of the file we're looking for (a.File.ContentType) + // * the blurhash (a.Blurhash) + // * the file type (a.Type) + // * the remote URL (a.RemoteURL) + // This should be enough to pass along to the media processor. + attachmentIDs := []string{} + for _, a := range status.GTSMediaAttachments { + l.Debugf("dereferencing attachment: %+v", a) + + // it might have been processed elsewhere so check first if it's already in the database or not + maybeAttachment := &gtsmodel.MediaAttachment{} + err := f.db.GetWhere([]db.Where{{Key: "remote_url", Value: a.RemoteURL}}, maybeAttachment) + if err == nil { + // we already have it in the db, dereferenced, no need to do it again + l.Debugf("attachment already exists with id %s", maybeAttachment.ID) + attachmentIDs = append(attachmentIDs, maybeAttachment.ID) + continue + } + if _, ok := err.(db.ErrNoEntries); !ok { + // we have a real error + return fmt.Errorf("error checking db for existence of attachment with remote url %s: %s", a.RemoteURL, err) + } + // it just doesn't exist yet so carry on + l.Debug("attachment doesn't exist yet, calling ProcessRemoteAttachment", a) + deferencedAttachment, err := f.mediaHandler.ProcessRemoteAttachment(t, a, status.AccountID) + if err != nil { + l.Errorf("error dereferencing status attachment: %s", err) + continue + } + l.Debugf("dereferenced attachment: %+v", deferencedAttachment) + deferencedAttachment.StatusID = status.ID + deferencedAttachment.Description = a.Description + if err := f.db.Put(deferencedAttachment); err != nil { + return fmt.Errorf("error inserting dereferenced attachment with remote url %s: %s", a.RemoteURL, err) + } + attachmentIDs = append(attachmentIDs, deferencedAttachment.ID) + } + status.Attachments = attachmentIDs + + // 2. Hashtags + + // 3. Emojis + + // 4. Mentions + // At this point, mentions should have the namestring and mentionedAccountURI set on them. + // + // We should dereference any accounts mentioned here which we don't have in our db yet, by their URI. + mentions := []string{} + for _, m := range status.GTSMentions { + if m.ID == "" { + mID, err := id.NewRandomULID() + if err != nil { + return err + } + m.ID = mID + } + + uri, err := url.Parse(m.MentionedAccountURI) + if err != nil { + l.Debugf("error parsing mentioned account uri %s: %s", m.MentionedAccountURI, err) + continue + } + + m.StatusID = status.ID + m.OriginAccountID = status.GTSAuthorAccount.ID + m.OriginAccountURI = status.GTSAuthorAccount.URI + + targetAccount := &gtsmodel.Account{} + if err := f.db.GetWhere([]db.Where{{Key: "uri", Value: uri.String()}}, targetAccount); err != nil { + // proper error + if _, ok := err.(db.ErrNoEntries); !ok { + return fmt.Errorf("db error checking for account with uri %s", uri.String()) + } + + // we just don't have it yet, so we should go get it.... + accountable, err := f.DereferenceRemoteAccount(requestingUsername, uri) + if err != nil { + // we can't dereference it so just skip it + l.Debugf("error dereferencing remote account with uri %s: %s", uri.String(), err) + continue + } + + targetAccount, err = f.typeConverter.ASRepresentationToAccount(accountable, false) + if err != nil { + l.Debugf("error converting remote account with uri %s into gts model: %s", uri.String(), err) + continue + } + + targetAccountID, err := id.NewRandomULID() + if err != nil { + return err + } + targetAccount.ID = targetAccountID + + if err := f.db.Put(targetAccount); err != nil { + return fmt.Errorf("db error inserting account with uri %s", uri.String()) + } + } + + // by this point, we know the targetAccount exists in our database with an ID :) + m.TargetAccountID = targetAccount.ID + if err := f.db.Put(m); err != nil { + return fmt.Errorf("error creating mention: %s", err) + } + mentions = append(mentions, m.ID) + } + status.Mentions = mentions + + return nil +} + +func (f *federator) DereferenceAccountFields(account *gtsmodel.Account, requestingUsername string, refresh bool) error { + l := f.log.WithFields(logrus.Fields{ + "func": "dereferenceAccountFields", + "requestingUsername": requestingUsername, + }) + + accountURI, err := url.Parse(account.URI) + if err != nil { + return fmt.Errorf("DereferenceAccountFields: couldn't parse account URI %s: %s", account.URI, err) + } + if blocked, err := f.blockedDomain(accountURI.Host); blocked || err != nil { + return fmt.Errorf("DereferenceAccountFields: domain %s is blocked", accountURI.Host) + } + + t, err := f.GetTransportForUser(requestingUsername) + if err != nil { + return fmt.Errorf("error getting transport for user: %s", err) + } + + // fetch the header and avatar + if err := f.fetchHeaderAndAviForAccount(account, t, refresh); err != nil { + // if this doesn't work, just skip it -- we can do it later + l.Debugf("error fetching header/avi for account: %s", err) + } + + if err := f.db.UpdateByID(account.ID, account); err != nil { + return fmt.Errorf("error updating account in database: %s", err) + } + + return nil +} + +func (f *federator) DereferenceAnnounce(announce *gtsmodel.Status, requestingUsername string) error { + if announce.GTSBoostedStatus == nil || announce.GTSBoostedStatus.URI == "" { + // we can't do anything unfortunately + return errors.New("DereferenceAnnounce: no URI to dereference") + } + + boostedStatusURI, err := url.Parse(announce.GTSBoostedStatus.URI) + if err != nil { + return fmt.Errorf("DereferenceAnnounce: couldn't parse boosted status URI %s: %s", announce.GTSBoostedStatus.URI, err) + } + if blocked, err := f.blockedDomain(boostedStatusURI.Host); blocked || err != nil { + return fmt.Errorf("DereferenceAnnounce: domain %s is blocked", boostedStatusURI.Host) + } + + // check if we already have the boosted status in the database + boostedStatus := &gtsmodel.Status{} + err = f.db.GetWhere([]db.Where{{Key: "uri", Value: announce.GTSBoostedStatus.URI}}, boostedStatus) + if err == nil { + // nice, we already have it so we don't actually need to dereference it from remote + announce.Content = boostedStatus.Content + announce.ContentWarning = boostedStatus.ContentWarning + announce.ActivityStreamsType = boostedStatus.ActivityStreamsType + announce.Sensitive = boostedStatus.Sensitive + announce.Language = boostedStatus.Language + announce.Text = boostedStatus.Text + announce.BoostOfID = boostedStatus.ID + announce.Visibility = boostedStatus.Visibility + announce.VisibilityAdvanced = boostedStatus.VisibilityAdvanced + announce.GTSBoostedStatus = boostedStatus + return nil + } + + // we don't have it so we need to dereference it + statusable, err := f.DereferenceRemoteStatus(requestingUsername, boostedStatusURI) + if err != nil { + return fmt.Errorf("dereferenceAnnounce: error dereferencing remote status with id %s: %s", announce.GTSBoostedStatus.URI, err) + } + + // make sure we have the author account in the db + attributedToProp := statusable.GetActivityStreamsAttributedTo() + for iter := attributedToProp.Begin(); iter != attributedToProp.End(); iter = iter.Next() { + accountURI := iter.GetIRI() + if accountURI == nil { + continue + } + + if err := f.db.GetWhere([]db.Where{{Key: "uri", Value: accountURI.String()}}, &gtsmodel.Account{}); err == nil { + // we already have it, fine + continue + } + + // we don't have the boosted status author account yet so dereference it + accountable, err := f.DereferenceRemoteAccount(requestingUsername, accountURI) + if err != nil { + return fmt.Errorf("dereferenceAnnounce: error dereferencing remote account with id %s: %s", accountURI.String(), err) + } + account, err := f.typeConverter.ASRepresentationToAccount(accountable, false) + if err != nil { + return fmt.Errorf("dereferenceAnnounce: error converting dereferenced account with id %s into account : %s", accountURI.String(), err) + } + + accountID, err := id.NewRandomULID() + if err != nil { + return err + } + account.ID = accountID + + if err := f.db.Put(account); err != nil { + return fmt.Errorf("dereferenceAnnounce: error putting dereferenced account with id %s into database : %s", accountURI.String(), err) + } + + if err := f.DereferenceAccountFields(account, requestingUsername, false); err != nil { + return fmt.Errorf("dereferenceAnnounce: error dereferencing fields on account with id %s : %s", accountURI.String(), err) + } + } + + // now convert the statusable into something we can understand + boostedStatus, err = f.typeConverter.ASStatusToStatus(statusable) + if err != nil { + return fmt.Errorf("dereferenceAnnounce: error converting dereferenced statusable with id %s into status : %s", announce.GTSBoostedStatus.URI, err) + } + + boostedStatusID, err := id.NewULIDFromTime(boostedStatus.CreatedAt) + if err != nil { + return nil + } + boostedStatus.ID = boostedStatusID + + if err := f.db.Put(boostedStatus); err != nil { + return fmt.Errorf("dereferenceAnnounce: error putting dereferenced status with id %s into the db: %s", announce.GTSBoostedStatus.URI, err) + } + + // now dereference additional fields straight away (we're already async here so we have time) + if err := f.DereferenceStatusFields(boostedStatus, requestingUsername); err != nil { + return fmt.Errorf("dereferenceAnnounce: error dereferencing status fields for status with id %s: %s", announce.GTSBoostedStatus.URI, err) + } + + // update with the newly dereferenced fields + if err := f.db.UpdateByID(boostedStatus.ID, boostedStatus); err != nil { + return fmt.Errorf("dereferenceAnnounce: error updating dereferenced status in the db: %s", err) + } + + // we have everything we need! + announce.Content = boostedStatus.Content + announce.ContentWarning = boostedStatus.ContentWarning + announce.ActivityStreamsType = boostedStatus.ActivityStreamsType + announce.Sensitive = boostedStatus.Sensitive + announce.Language = boostedStatus.Language + announce.Text = boostedStatus.Text + announce.BoostOfID = boostedStatus.ID + announce.Visibility = boostedStatus.Visibility + announce.VisibilityAdvanced = boostedStatus.VisibilityAdvanced + announce.GTSBoostedStatus = boostedStatus + return nil +} + +// fetchHeaderAndAviForAccount fetches the header and avatar for a remote account, using a transport +// on behalf of requestingUsername. +// +// targetAccount's AvatarMediaAttachmentID and HeaderMediaAttachmentID will be updated as necessary. +// +// SIDE EFFECTS: remote header and avatar will be stored in local storage, and the database will be updated +// to reflect the creation of these new attachments. +func (f *federator) fetchHeaderAndAviForAccount(targetAccount *gtsmodel.Account, t transport.Transport, refresh bool) error { + accountURI, err := url.Parse(targetAccount.URI) + if err != nil { + return fmt.Errorf("fetchHeaderAndAviForAccount: couldn't parse account URI %s: %s", targetAccount.URI, err) + } + if blocked, err := f.blockedDomain(accountURI.Host); blocked || err != nil { + return fmt.Errorf("fetchHeaderAndAviForAccount: domain %s is blocked", accountURI.Host) + } + + if targetAccount.AvatarRemoteURL != "" && (targetAccount.AvatarMediaAttachmentID == "" || refresh) { + a, err := f.mediaHandler.ProcessRemoteHeaderOrAvatar(t, &gtsmodel.MediaAttachment{ + RemoteURL: targetAccount.AvatarRemoteURL, + Avatar: true, + }, targetAccount.ID) + if err != nil { + return fmt.Errorf("error processing avatar for user: %s", err) + } + targetAccount.AvatarMediaAttachmentID = a.ID + } + + if targetAccount.HeaderRemoteURL != "" && (targetAccount.HeaderMediaAttachmentID == "" || refresh) { + a, err := f.mediaHandler.ProcessRemoteHeaderOrAvatar(t, &gtsmodel.MediaAttachment{ + RemoteURL: targetAccount.HeaderRemoteURL, + Header: true, + }, targetAccount.ID) + if err != nil { + return fmt.Errorf("error processing header for user: %s", err) + } + targetAccount.HeaderMediaAttachmentID = a.ID + } + return nil +} diff --git a/internal/federation/federatingprotocol.go b/internal/federation/federatingprotocol.go @@ -119,10 +119,15 @@ func (f *federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWr return nil, false, fmt.Errorf("could not fetch requested account with username %s: %s", username, err) } - publicKeyOwnerURI, err := f.AuthenticateFederatedRequest(requestedAccount.Username, r) + publicKeyOwnerURI, authenticated, err := f.AuthenticateFederatedRequest(ctx, requestedAccount.Username) if err != nil { l.Debugf("request not authenticated: %s", err) - return ctx, false, fmt.Errorf("not authenticated: %s", err) + return ctx, false, err + } + + if !authenticated { + w.WriteHeader(http.StatusForbidden) + return ctx, false, nil } // authentication has passed, so add an instance entry for this instance if it hasn't been done already @@ -230,6 +235,14 @@ func (f *federator) Blocked(ctx context.Context, actorIRIs []*url.URL) (bool, er } for _, uri := range actorIRIs { + blockedDomain, err := f.blockedDomain(uri.Host) + if err != nil { + return false, fmt.Errorf("error checking domain block: %s", err) + } + if blockedDomain { + return true, nil + } + a := &gtsmodel.Account{} if err := f.db.GetWhere([]db.Where{{Key: "uri", Value: uri.String()}}, a); err != nil { _, ok := err.(db.ErrNoEntries) diff --git a/internal/federation/federator.go b/internal/federation/federator.go @@ -19,7 +19,7 @@ package federation import ( - "net/http" + "context" "net/url" "sync" @@ -29,6 +29,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/federation/federatingdb" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/transport" "github.com/superseriousbusiness/gotosocial/internal/typeutils" ) @@ -41,7 +42,13 @@ type Federator interface { FederatingDB() federatingdb.DB // AuthenticateFederatedRequest can be used to check the authenticity of incoming http-signed requests for federating resources. // The given username will be used to create a transport for making outgoing requests. See the implementation for more detailed comments. - AuthenticateFederatedRequest(username string, r *http.Request) (*url.URL, error) + // + // If the request is valid and passes authentication, the URL of the key owner ID will be returned, as well as true, and nil. + // + // If the request does not pass authentication, or there's a domain block, nil, false, nil will be returned. + // + // If something goes wrong during authentication, nil, false, and an error will be returned. + AuthenticateFederatedRequest(ctx context.Context, username string) (*url.URL, bool, error) // FingerRemoteAccount performs a webfinger lookup for a remote account, using the .well-known path. It will return the ActivityPub URI for that // account, or an error if it doesn't exist or can't be retrieved. FingerRemoteAccount(requestingUsername string, targetUsername string, targetDomain string) (*url.URL, error) @@ -54,6 +61,12 @@ type Federator interface { // DereferenceRemoteInstance takes the URL of a remote instance, and a username (optional) to spin up a transport with. It then // does its damnedest to get some kind of information back about the instance, trying /api/v1/instance, then /.well-known/nodeinfo DereferenceRemoteInstance(username string, remoteInstanceURI *url.URL) (*gtsmodel.Instance, error) + // DereferenceStatusFields does further dereferencing on a status. + DereferenceStatusFields(status *gtsmodel.Status, requestingUsername string) error + // DereferenceAccountFields does further dereferencing on an account. + DereferenceAccountFields(account *gtsmodel.Account, requestingUsername string, refresh bool) error + // DereferenceAnnounce does further dereferencing on an announce. + DereferenceAnnounce(announce *gtsmodel.Status, requestingUsername string) error // GetTransportForUser returns a new transport initialized with the key credentials belonging to the given username. // This can be used for making signed http requests. // @@ -72,6 +85,7 @@ type federator struct { clock pub.Clock typeConverter typeutils.TypeConverter transportController transport.Controller + mediaHandler media.Handler actor pub.FederatingActor log *logrus.Logger handshakes map[string][]*url.URL @@ -79,7 +93,7 @@ type federator struct { } // NewFederator returns a new federator -func NewFederator(db db.DB, federatingDB federatingdb.DB, transportController transport.Controller, config *config.Config, log *logrus.Logger, typeConverter typeutils.TypeConverter) Federator { +func NewFederator(db db.DB, federatingDB federatingdb.DB, transportController transport.Controller, config *config.Config, log *logrus.Logger, typeConverter typeutils.TypeConverter, mediaHandler media.Handler) Federator { clock := &Clock{} f := &federator{ @@ -89,6 +103,7 @@ func NewFederator(db db.DB, federatingDB federatingdb.DB, transportController tr clock: &Clock{}, typeConverter: typeConverter, transportController: transportController, + mediaHandler: mediaHandler, log: log, handshakeSync: &sync.Mutex{}, } diff --git a/internal/federation/federator_test.go b/internal/federation/federator_test.go @@ -89,7 +89,7 @@ func (suite *ProtocolTestSuite) TestPostInboxRequestBodyHook() { return nil, nil })) // setup module being tested - federator := federation.NewFederator(suite.db, testrig.NewTestFederatingDB(suite.db), tc, suite.config, suite.log, suite.typeConverter) + federator := federation.NewFederator(suite.db, testrig.NewTestFederatingDB(suite.db), tc, suite.config, suite.log, suite.typeConverter, testrig.NewTestMediaHandler(suite.db, suite.storage)) // setup request ctx := context.Background() @@ -155,7 +155,7 @@ func (suite *ProtocolTestSuite) TestAuthenticatePostInbox() { })) // now setup module being tested, with the mock transport controller - federator := federation.NewFederator(suite.db, testrig.NewTestFederatingDB(suite.db), tc, suite.config, suite.log, suite.typeConverter) + federator := federation.NewFederator(suite.db, testrig.NewTestFederatingDB(suite.db), tc, suite.config, suite.log, suite.typeConverter, testrig.NewTestMediaHandler(suite.db, suite.storage)) // setup request ctx := context.Background() diff --git a/internal/federation/finger.go b/internal/federation/finger.go @@ -30,6 +30,9 @@ import ( ) func (f *federator) FingerRemoteAccount(requestingUsername string, targetUsername string, targetDomain string) (*url.URL, error) { + if blocked, err := f.blockedDomain(targetDomain); blocked || err != nil { + return nil, fmt.Errorf("FingerRemoteAccount: domain %s is blocked", targetDomain) + } t, err := f.GetTransportForUser(requestingUsername) if err != nil { diff --git a/internal/federation/util.go b/internal/federation/util.go @@ -0,0 +1,23 @@ +package federation + +import ( + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +func (f *federator) blockedDomain(host string) (bool, error) { + b := &gtsmodel.DomainBlock{} + err := f.db.GetWhere([]db.Where{{Key: "domain", Value: host, CaseInsensitive: true}}, b) + if err == nil { + // block exists + return true, nil + } + + if _, ok := err.(db.ErrNoEntries); ok { + // there are no entries so there's no block + return false, nil + } + + // there's an actual error + return false, err +} diff --git a/internal/gtsmodel/domainblock.go b/internal/gtsmodel/domainblock.go @@ -20,28 +20,24 @@ package gtsmodel import "time" -// DomainBlock represents a federation block against a particular domain, of varying severity. +// DomainBlock represents a federation block against a particular domain type DomainBlock struct { // ID of this block in the database ID string `pg:"type:CHAR(26),pk,notnull,unique"` - // Domain to block. If ANY PART of the candidate domain contains this string, it will be blocked. - // For example: 'example.org' also blocks 'gts.example.org'. '.com' blocks *any* '.com' domains. - // TODO: implement wildcards here - Domain string `pg:",notnull"` + // blocked domain + Domain string `pg:",pk,notnull,unique"` // When was this block created CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` // When was this block updated UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` // Account ID of the creator of this block CreatedByAccountID string `pg:"type:CHAR(26),notnull"` - // TODO: define this - Severity int - // Reject media from this domain? - RejectMedia bool - // Reject reports from this domain? - RejectReports bool // Private comment on this block, viewable to admins PrivateComment string // Public comment on this block, viewable (optionally) by everyone PublicComment string + // whether the domain name should appear obfuscated when displaying it publicly + Obfuscate bool + // if this block was created through a subscription, what's the subscription ID? + SubscriptionID string `pg:"type:CHAR(26)"` } diff --git a/internal/gtsmodel/instance.go b/internal/gtsmodel/instance.go @@ -7,7 +7,7 @@ type Instance struct { // ID of this instance in the database ID string `pg:"type:CHAR(26),pk,notnull,unique"` // Instance domain eg example.org - Domain string `pg:",notnull,unique"` + Domain string `pg:",pk,notnull,unique"` // Title of this instance as it would like to be displayed. Title string // base URI of this instance eg https://example.org diff --git a/internal/oauth/util.go b/internal/oauth/util.go @@ -73,14 +73,28 @@ func Authed(c *gin.Context, requireToken bool, requireApp bool, requireUser bool if requireToken && a.Token == nil { return nil, errors.New("token not supplied") } + if requireApp && a.Application == nil { return nil, errors.New("application not supplied") } - if requireUser && a.User == nil { - return nil, errors.New("user not supplied") + + if requireUser { + if a.User == nil { + return nil, errors.New("user not supplied") + } + if a.User.Disabled || !a.User.Approved { + return nil, errors.New("user disabled or not approved") + } } - if requireAccount && a.Account == nil { - return nil, errors.New("account not supplied") + + if requireAccount { + if a.Account == nil { + return nil, errors.New("account not supplied") + } + if !a.Account.SuspendedAt.IsZero() { + return nil, errors.New("account suspended") + } } + return a, nil } diff --git a/internal/processing/account.go b/internal/processing/account.go @@ -19,512 +19,43 @@ package processing import ( - "errors" - "fmt" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/util" ) -// accountCreate does the dirty work of making an account and user in the database. -// It then returns a token to the caller, for use with the new account, as per the -// spec here: https://docs.joinmastodon.org/methods/accounts/ func (p *processor) AccountCreate(authed *oauth.Auth, form *apimodel.AccountCreateRequest) (*apimodel.Token, error) { - l := p.log.WithField("func", "accountCreate") - - if err := p.db.IsEmailAvailable(form.Email); err != nil { - return nil, err - } - - if err := p.db.IsUsernameAvailable(form.Username); err != nil { - return nil, err - } - - // don't store a reason if we don't require one - reason := form.Reason - if !p.config.AccountsConfig.ReasonRequired { - reason = "" - } - - l.Trace("creating new username and account") - user, err := p.db.NewSignup(form.Username, reason, p.config.AccountsConfig.RequireApproval, form.Email, form.Password, form.IP, form.Locale, authed.Application.ID) - if err != nil { - return nil, fmt.Errorf("error creating new signup in the database: %s", err) - } - - l.Tracef("generating a token for user %s with account %s and application %s", user.ID, user.AccountID, authed.Application.ID) - accessToken, err := p.oauthServer.GenerateUserAccessToken(authed.Token, authed.Application.ClientSecret, user.ID) - if err != nil { - return nil, fmt.Errorf("error creating new access token for user %s: %s", user.ID, err) - } - - return &apimodel.Token{ - AccessToken: accessToken.GetAccess(), - TokenType: "Bearer", - Scope: accessToken.GetScope(), - CreatedAt: accessToken.GetAccessCreateAt().Unix(), - }, nil + return p.accountProcessor.Create(authed.Token, authed.Application, form) } func (p *processor) AccountGet(authed *oauth.Auth, targetAccountID string) (*apimodel.Account, error) { - targetAccount := &gtsmodel.Account{} - if err := p.db.GetByID(targetAccountID, targetAccount); err != nil { - if _, ok := err.(db.ErrNoEntries); ok { - return nil, errors.New("account not found") - } - return nil, fmt.Errorf("db error: %s", err) - } - - // lazily dereference things on the account if it hasn't been done yet - var requestingUsername string - if authed.Account != nil { - requestingUsername = authed.Account.Username - } - if err := p.dereferenceAccountFields(targetAccount, requestingUsername, false); err != nil { - p.log.WithField("func", "AccountGet").Debugf("dereferencing account: %s", err) - } - - var mastoAccount *apimodel.Account - var err error - if authed.Account != nil && targetAccount.ID == authed.Account.ID { - mastoAccount, err = p.tc.AccountToMastoSensitive(targetAccount) - } else { - mastoAccount, err = p.tc.AccountToMastoPublic(targetAccount) - } - if err != nil { - return nil, fmt.Errorf("error converting account: %s", err) - } - return mastoAccount, nil + return p.accountProcessor.Get(authed.Account, targetAccountID) } func (p *processor) AccountUpdate(authed *oauth.Auth, form *apimodel.UpdateCredentialsRequest) (*apimodel.Account, error) { - l := p.log.WithField("func", "AccountUpdate") - - if form.Discoverable != nil { - if err := p.db.UpdateOneByID(authed.Account.ID, "discoverable", *form.Discoverable, &gtsmodel.Account{}); err != nil { - return nil, fmt.Errorf("error updating discoverable: %s", err) - } - } - - if form.Bot != nil { - if err := p.db.UpdateOneByID(authed.Account.ID, "bot", *form.Bot, &gtsmodel.Account{}); err != nil { - return nil, fmt.Errorf("error updating bot: %s", err) - } - } - - if form.DisplayName != nil { - if err := util.ValidateDisplayName(*form.DisplayName); err != nil { - return nil, err - } - if err := p.db.UpdateOneByID(authed.Account.ID, "display_name", *form.DisplayName, &gtsmodel.Account{}); err != nil { - return nil, err - } - } - - if form.Note != nil { - if err := util.ValidateNote(*form.Note); err != nil { - return nil, err - } - if err := p.db.UpdateOneByID(authed.Account.ID, "note", *form.Note, &gtsmodel.Account{}); err != nil { - return nil, err - } - } - - if form.Avatar != nil && form.Avatar.Size != 0 { - avatarInfo, err := p.updateAccountAvatar(form.Avatar, authed.Account.ID) - if err != nil { - return nil, err - } - l.Tracef("new avatar info for account %s is %+v", authed.Account.ID, avatarInfo) - } - - if form.Header != nil && form.Header.Size != 0 { - headerInfo, err := p.updateAccountHeader(form.Header, authed.Account.ID) - if err != nil { - return nil, err - } - l.Tracef("new header info for account %s is %+v", authed.Account.ID, headerInfo) - } - - if form.Locked != nil { - if err := p.db.UpdateOneByID(authed.Account.ID, "locked", *form.Locked, &gtsmodel.Account{}); err != nil { - return nil, err - } - } - - if form.Source != nil { - if form.Source.Language != nil { - if err := util.ValidateLanguage(*form.Source.Language); err != nil { - return nil, err - } - if err := p.db.UpdateOneByID(authed.Account.ID, "language", *form.Source.Language, &gtsmodel.Account{}); err != nil { - return nil, err - } - } - - if form.Source.Sensitive != nil { - if err := p.db.UpdateOneByID(authed.Account.ID, "locked", *form.Locked, &gtsmodel.Account{}); err != nil { - return nil, err - } - } - - if form.Source.Privacy != nil { - if err := util.ValidatePrivacy(*form.Source.Privacy); err != nil { - return nil, err - } - if err := p.db.UpdateOneByID(authed.Account.ID, "privacy", *form.Source.Privacy, &gtsmodel.Account{}); err != nil { - return nil, err - } - } - } - - // fetch the account with all updated values set - updatedAccount := &gtsmodel.Account{} - if err := p.db.GetByID(authed.Account.ID, updatedAccount); err != nil { - return nil, fmt.Errorf("could not fetch updated account %s: %s", authed.Account.ID, err) - } - - p.fromClientAPI <- gtsmodel.FromClientAPI{ - APObjectType: gtsmodel.ActivityStreamsProfile, - APActivityType: gtsmodel.ActivityStreamsUpdate, - GTSModel: updatedAccount, - OriginAccount: updatedAccount, - } - - acctSensitive, err := p.tc.AccountToMastoSensitive(updatedAccount) - if err != nil { - return nil, fmt.Errorf("could not convert account into mastosensitive account: %s", err) - } - return acctSensitive, nil + return p.accountProcessor.Update(authed.Account, form) } -func (p *processor) AccountStatusesGet(authed *oauth.Auth, targetAccountID string, limit int, excludeReplies bool, maxID string, pinned bool, mediaOnly bool) ([]apimodel.Status, gtserror.WithCode) { - targetAccount := &gtsmodel.Account{} - if err := p.db.GetByID(targetAccountID, targetAccount); err != nil { - if _, ok := err.(db.ErrNoEntries); ok { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("no entry found for account id %s", targetAccountID)) - } - return nil, gtserror.NewErrorInternalError(err) - } - - statuses := []gtsmodel.Status{} - apiStatuses := []apimodel.Status{} - if err := p.db.GetStatusesByTimeDescending(targetAccountID, &statuses, limit, excludeReplies, maxID, pinned, mediaOnly); err != nil { - if _, ok := err.(db.ErrNoEntries); ok { - return apiStatuses, nil - } - return nil, gtserror.NewErrorInternalError(err) - } - - for _, s := range statuses { - visible, err := p.filter.StatusVisible(&s, authed.Account) - if err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("error checking status visibility: %s", err)) - } - if !visible { - continue - } - - apiStatus, err := p.tc.StatusToMasto(&s, authed.Account) - if err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status to masto: %s", err)) - } - - apiStatuses = append(apiStatuses, *apiStatus) - } - - return apiStatuses, nil +func (p *processor) AccountStatusesGet(authed *oauth.Auth, targetAccountID string, limit int, excludeReplies bool, maxID string, pinnedOnly bool, mediaOnly bool) ([]apimodel.Status, gtserror.WithCode) { + return p.accountProcessor.StatusesGet(authed.Account, targetAccountID, limit, excludeReplies, maxID, pinnedOnly, mediaOnly) } func (p *processor) AccountFollowersGet(authed *oauth.Auth, targetAccountID string) ([]apimodel.Account, gtserror.WithCode) { - blocked, err := p.db.Blocked(authed.Account.ID, targetAccountID) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - - if blocked { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("block exists between accounts")) - } - - followers := []gtsmodel.Follow{} - accounts := []apimodel.Account{} - if err := p.db.GetFollowersByAccountID(targetAccountID, &followers, false); err != nil { - if _, ok := err.(db.ErrNoEntries); ok { - return accounts, nil - } - return nil, gtserror.NewErrorInternalError(err) - } - - for _, f := range followers { - blocked, err := p.db.Blocked(authed.Account.ID, f.AccountID) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - if blocked { - continue - } - - a := &gtsmodel.Account{} - if err := p.db.GetByID(f.AccountID, a); err != nil { - if _, ok := err.(db.ErrNoEntries); ok { - continue - } - return nil, gtserror.NewErrorInternalError(err) - } - - // derefence account fields in case we haven't done it already - if err := p.dereferenceAccountFields(a, authed.Account.Username, false); err != nil { - // don't bail if we can't fetch them, we'll try another time - p.log.WithField("func", "AccountFollowersGet").Debugf("error dereferencing account fields: %s", err) - } - - account, err := p.tc.AccountToMastoPublic(a) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - accounts = append(accounts, *account) - } - return accounts, nil + return p.accountProcessor.FollowersGet(authed.Account, targetAccountID) } func (p *processor) AccountFollowingGet(authed *oauth.Auth, targetAccountID string) ([]apimodel.Account, gtserror.WithCode) { - blocked, err := p.db.Blocked(authed.Account.ID, targetAccountID) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - - if blocked { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("block exists between accounts")) - } - - following := []gtsmodel.Follow{} - accounts := []apimodel.Account{} - if err := p.db.GetFollowingByAccountID(targetAccountID, &following); err != nil { - if _, ok := err.(db.ErrNoEntries); ok { - return accounts, nil - } - return nil, gtserror.NewErrorInternalError(err) - } - - for _, f := range following { - blocked, err := p.db.Blocked(authed.Account.ID, f.AccountID) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - if blocked { - continue - } - - a := &gtsmodel.Account{} - if err := p.db.GetByID(f.TargetAccountID, a); err != nil { - if _, ok := err.(db.ErrNoEntries); ok { - continue - } - return nil, gtserror.NewErrorInternalError(err) - } - - // derefence account fields in case we haven't done it already - if err := p.dereferenceAccountFields(a, authed.Account.Username, false); err != nil { - // don't bail if we can't fetch them, we'll try another time - p.log.WithField("func", "AccountFollowingGet").Debugf("error dereferencing account fields: %s", err) - } - - account, err := p.tc.AccountToMastoPublic(a) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - accounts = append(accounts, *account) - } - return accounts, nil + return p.accountProcessor.FollowingGet(authed.Account, targetAccountID) } func (p *processor) AccountRelationshipGet(authed *oauth.Auth, targetAccountID string) (*apimodel.Relationship, gtserror.WithCode) { - if authed == nil || authed.Account == nil { - return nil, gtserror.NewErrorForbidden(errors.New("not authed")) - } - - gtsR, err := p.db.GetRelationship(authed.Account.ID, targetAccountID) - if err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("error getting relationship: %s", err)) - } - - r, err := p.tc.RelationshipToMasto(gtsR) - if err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting relationship: %s", err)) - } - - return r, nil + return p.accountProcessor.RelationshipGet(authed.Account, targetAccountID) } func (p *processor) AccountFollowCreate(authed *oauth.Auth, form *apimodel.AccountFollowRequest) (*apimodel.Relationship, gtserror.WithCode) { - // if there's a block between the accounts we shouldn't create the request ofc - blocked, err := p.db.Blocked(authed.Account.ID, form.TargetAccountID) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - if blocked { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("accountfollowcreate: block exists between accounts")) - } - - // make sure the target account actually exists in our db - targetAcct := &gtsmodel.Account{} - if err := p.db.GetByID(form.TargetAccountID, targetAcct); err != nil { - if _, ok := err.(db.ErrNoEntries); ok { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("accountfollowcreate: account %s not found in the db: %s", form.TargetAccountID, err)) - } - } - - // check if a follow exists already - follows, err := p.db.Follows(authed.Account, targetAcct) - if err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("accountfollowcreate: error checking follow in db: %s", err)) - } - if follows { - // already follows so just return the relationship - return p.AccountRelationshipGet(authed, form.TargetAccountID) - } - - // check if a follow exists already - followRequested, err := p.db.FollowRequested(authed.Account, targetAcct) - if err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("accountfollowcreate: error checking follow request in db: %s", err)) - } - if followRequested { - // already follow requested so just return the relationship - return p.AccountRelationshipGet(authed, form.TargetAccountID) - } - - // make the follow request - newFollowID, err := id.NewRandomULID() - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - - fr := &gtsmodel.FollowRequest{ - ID: newFollowID, - AccountID: authed.Account.ID, - TargetAccountID: form.TargetAccountID, - ShowReblogs: true, - URI: util.GenerateURIForFollow(authed.Account.Username, p.config.Protocol, p.config.Host, newFollowID), - Notify: false, - } - if form.Reblogs != nil { - fr.ShowReblogs = *form.Reblogs - } - if form.Notify != nil { - fr.Notify = *form.Notify - } - - // whack it in the database - if err := p.db.Put(fr); err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("accountfollowcreate: error creating follow request in db: %s", err)) - } - - // if it's a local account that's not locked we can just straight up accept the follow request - if !targetAcct.Locked && targetAcct.Domain == "" { - if _, err := p.db.AcceptFollowRequest(authed.Account.ID, form.TargetAccountID); err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("accountfollowcreate: error accepting folow request for local unlocked account: %s", err)) - } - // return the new relationship - return p.AccountRelationshipGet(authed, form.TargetAccountID) - } - - // otherwise we leave the follow request as it is and we handle the rest of the process asynchronously - p.fromClientAPI <- gtsmodel.FromClientAPI{ - APObjectType: gtsmodel.ActivityStreamsFollow, - APActivityType: gtsmodel.ActivityStreamsCreate, - GTSModel: fr, - OriginAccount: authed.Account, - TargetAccount: targetAcct, - } - - // return whatever relationship results from this - return p.AccountRelationshipGet(authed, form.TargetAccountID) + return p.accountProcessor.FollowCreate(authed.Account, form) } func (p *processor) AccountFollowRemove(authed *oauth.Auth, targetAccountID string) (*apimodel.Relationship, gtserror.WithCode) { - // if there's a block between the accounts we shouldn't do anything - blocked, err := p.db.Blocked(authed.Account.ID, targetAccountID) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - if blocked { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("AccountFollowRemove: block exists between accounts")) - } - - // make sure the target account actually exists in our db - targetAcct := &gtsmodel.Account{} - if err := p.db.GetByID(targetAccountID, targetAcct); err != nil { - if _, ok := err.(db.ErrNoEntries); ok { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("AccountFollowRemove: account %s not found in the db: %s", targetAccountID, err)) - } - } - - // check if a follow request exists, and remove it if it does (storing the URI for later) - var frChanged bool - var frURI string - fr := &gtsmodel.FollowRequest{} - if err := p.db.GetWhere([]db.Where{ - {Key: "account_id", Value: authed.Account.ID}, - {Key: "target_account_id", Value: targetAccountID}, - }, fr); err == nil { - frURI = fr.URI - if err := p.db.DeleteByID(fr.ID, fr); err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("AccountFollowRemove: error removing follow request from db: %s", err)) - } - frChanged = true - } - - // now do the same thing for any existing follow - var fChanged bool - var fURI string - f := &gtsmodel.Follow{} - if err := p.db.GetWhere([]db.Where{ - {Key: "account_id", Value: authed.Account.ID}, - {Key: "target_account_id", Value: targetAccountID}, - }, f); err == nil { - fURI = f.URI - if err := p.db.DeleteByID(f.ID, f); err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("AccountFollowRemove: error removing follow from db: %s", err)) - } - fChanged = true - } - - // follow request status changed so send the UNDO activity to the channel for async processing - if frChanged { - p.fromClientAPI <- gtsmodel.FromClientAPI{ - APObjectType: gtsmodel.ActivityStreamsFollow, - APActivityType: gtsmodel.ActivityStreamsUndo, - GTSModel: &gtsmodel.Follow{ - AccountID: authed.Account.ID, - TargetAccountID: targetAccountID, - URI: frURI, - }, - OriginAccount: authed.Account, - TargetAccount: targetAcct, - } - } - - // follow status changed so send the UNDO activity to the channel for async processing - if fChanged { - p.fromClientAPI <- gtsmodel.FromClientAPI{ - APObjectType: gtsmodel.ActivityStreamsFollow, - APActivityType: gtsmodel.ActivityStreamsUndo, - GTSModel: &gtsmodel.Follow{ - AccountID: authed.Account.ID, - TargetAccountID: targetAccountID, - URI: fURI, - }, - OriginAccount: authed.Account, - TargetAccount: targetAcct, - } - } - - // return whatever relationship results from all this - return p.AccountRelationshipGet(authed, targetAccountID) + return p.accountProcessor.FollowRemove(authed.Account, targetAccountID) } diff --git a/internal/processing/account/account.go b/internal/processing/account/account.go @@ -0,0 +1,96 @@ +/* + 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 <http://www.gnu.org/licenses/>. +*/ + +package account + +import ( + "mime/multipart" + + "github.com/sirupsen/logrus" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "github.com/superseriousbusiness/gotosocial/internal/visibility" + "github.com/superseriousbusiness/oauth2/v4" +) + +// Processor wraps a bunch of functions for processing account actions. +type Processor interface { + // Create processes the given form for creating a new account, returning an oauth token for that account if successful. + Create(applicationToken oauth2.TokenInfo, application *gtsmodel.Application, form *apimodel.AccountCreateRequest) (*apimodel.Token, error) + // Delete deletes an account, and all of that account's statuses, media, follows, notifications, etc etc etc. + Delete(account *gtsmodel.Account, deletedBy string) error + // Get processes the given request for account information. + Get(requestingAccount *gtsmodel.Account, targetAccountID string) (*apimodel.Account, error) + // Update processes the update of an account with the given form + Update(account *gtsmodel.Account, form *apimodel.UpdateCredentialsRequest) (*apimodel.Account, error) + // StatusesGet fetches a number of statuses (in time descending order) from the given account, filtered by visibility for + // the account given in authed. + StatusesGet(requestingAccount *gtsmodel.Account, targetAccountID string, limit int, excludeReplies bool, maxID string, pinned bool, mediaOnly bool) ([]apimodel.Status, gtserror.WithCode) + // FollowersGet fetches a list of the target account's followers. + FollowersGet(requestingAccount *gtsmodel.Account, targetAccountID string) ([]apimodel.Account, gtserror.WithCode) + // FollowingGet fetches a list of the accounts that target account is following. + FollowingGet(requestingAccount *gtsmodel.Account, targetAccountID string) ([]apimodel.Account, gtserror.WithCode) + // RelationshipGet returns a relationship model describing the relationship of the targetAccount to the Authed account. + RelationshipGet(requestingAccount *gtsmodel.Account, targetAccountID string) (*apimodel.Relationship, gtserror.WithCode) + // FollowCreate handles a follow request to an account, either remote or local. + FollowCreate(requestingAccount *gtsmodel.Account, form *apimodel.AccountFollowRequest) (*apimodel.Relationship, gtserror.WithCode) + // FollowRemove handles the removal of a follow/follow request to an account, either remote or local. + FollowRemove(requestingAccount *gtsmodel.Account, targetAccountID string) (*apimodel.Relationship, gtserror.WithCode) + // UpdateHeader does the dirty work of checking the header part of an account update form, + // parsing and checking the image, and doing the necessary updates in the database for this to become + // the account's new header image. + UpdateAvatar(avatar *multipart.FileHeader, accountID string) (*gtsmodel.MediaAttachment, error) + // UpdateAvatar does the dirty work of checking the avatar part of an account update form, + // parsing and checking the image, and doing the necessary updates in the database for this to become + // the account's new avatar image. + UpdateHeader(header *multipart.FileHeader, accountID string) (*gtsmodel.MediaAttachment, error) +} + +type processor struct { + tc typeutils.TypeConverter + config *config.Config + mediaHandler media.Handler + fromClientAPI chan gtsmodel.FromClientAPI + oauthServer oauth.Server + filter visibility.Filter + db db.DB + federator federation.Federator + log *logrus.Logger +} + +// New returns a new account processor. +func New(db db.DB, tc typeutils.TypeConverter, mediaHandler media.Handler, oauthServer oauth.Server, fromClientAPI chan gtsmodel.FromClientAPI, federator federation.Federator, config *config.Config, log *logrus.Logger) Processor { + return &processor{ + tc: tc, + config: config, + mediaHandler: mediaHandler, + fromClientAPI: fromClientAPI, + oauthServer: oauthServer, + filter: visibility.NewFilter(db, log), + db: db, + federator: federator, + log: log, + } +} diff --git a/internal/processing/account/create.go b/internal/processing/account/create.go @@ -0,0 +1,64 @@ +/* + 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 <http://www.gnu.org/licenses/>. +*/ + +package account + +import ( + "fmt" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/oauth2/v4" +) + +func (p *processor) Create(applicationToken oauth2.TokenInfo, application *gtsmodel.Application, form *apimodel.AccountCreateRequest) (*apimodel.Token, error) { + l := p.log.WithField("func", "accountCreate") + + if err := p.db.IsEmailAvailable(form.Email); err != nil { + return nil, err + } + + if err := p.db.IsUsernameAvailable(form.Username); err != nil { + return nil, err + } + + // don't store a reason if we don't require one + reason := form.Reason + if !p.config.AccountsConfig.ReasonRequired { + reason = "" + } + + l.Trace("creating new username and account") + user, err := p.db.NewSignup(form.Username, reason, p.config.AccountsConfig.RequireApproval, form.Email, form.Password, form.IP, form.Locale, application.ID) + if err != nil { + return nil, fmt.Errorf("error creating new signup in the database: %s", err) + } + + l.Tracef("generating a token for user %s with account %s and application %s", user.ID, user.AccountID, application.ID) + accessToken, err := p.oauthServer.GenerateUserAccessToken(applicationToken, application.ClientSecret, user.ID) + if err != nil { + return nil, fmt.Errorf("error creating new access token for user %s: %s", user.ID, err) + } + + return &apimodel.Token{ + AccessToken: accessToken.GetAccess(), + TokenType: "Bearer", + Scope: accessToken.GetScope(), + CreatedAt: accessToken.GetAccessCreateAt().Unix(), + }, nil +} diff --git a/internal/processing/account/createfollow.go b/internal/processing/account/createfollow.go @@ -0,0 +1,116 @@ +/* + 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 <http://www.gnu.org/licenses/>. +*/ + +package account + +import ( + "fmt" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +func (p *processor) FollowCreate(requestingAccount *gtsmodel.Account, form *apimodel.AccountFollowRequest) (*apimodel.Relationship, gtserror.WithCode) { + // if there's a block between the accounts we shouldn't create the request ofc + blocked, err := p.db.Blocked(requestingAccount.ID, form.TargetAccountID) + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + if blocked { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("accountfollowcreate: block exists between accounts")) + } + + // make sure the target account actually exists in our db + targetAcct := &gtsmodel.Account{} + if err := p.db.GetByID(form.TargetAccountID, targetAcct); err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("accountfollowcreate: account %s not found in the db: %s", form.TargetAccountID, err)) + } + } + + // check if a follow exists already + follows, err := p.db.Follows(requestingAccount, targetAcct) + if err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("accountfollowcreate: error checking follow in db: %s", err)) + } + if follows { + // already follows so just return the relationship + return p.RelationshipGet(requestingAccount, form.TargetAccountID) + } + + // check if a follow exists already + followRequested, err := p.db.FollowRequested(requestingAccount, targetAcct) + if err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("accountfollowcreate: error checking follow request in db: %s", err)) + } + if followRequested { + // already follow requested so just return the relationship + return p.RelationshipGet(requestingAccount, form.TargetAccountID) + } + + // make the follow request + newFollowID, err := id.NewRandomULID() + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + + fr := &gtsmodel.FollowRequest{ + ID: newFollowID, + AccountID: requestingAccount.ID, + TargetAccountID: form.TargetAccountID, + ShowReblogs: true, + URI: util.GenerateURIForFollow(requestingAccount.Username, p.config.Protocol, p.config.Host, newFollowID), + Notify: false, + } + if form.Reblogs != nil { + fr.ShowReblogs = *form.Reblogs + } + if form.Notify != nil { + fr.Notify = *form.Notify + } + + // whack it in the database + if err := p.db.Put(fr); err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("accountfollowcreate: error creating follow request in db: %s", err)) + } + + // if it's a local account that's not locked we can just straight up accept the follow request + if !targetAcct.Locked && targetAcct.Domain == "" { + if _, err := p.db.AcceptFollowRequest(requestingAccount.ID, form.TargetAccountID); err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("accountfollowcreate: error accepting folow request for local unlocked account: %s", err)) + } + // return the new relationship + return p.RelationshipGet(requestingAccount, form.TargetAccountID) + } + + // otherwise we leave the follow request as it is and we handle the rest of the process asynchronously + p.fromClientAPI <- gtsmodel.FromClientAPI{ + APObjectType: gtsmodel.ActivityStreamsFollow, + APActivityType: gtsmodel.ActivityStreamsCreate, + GTSModel: fr, + OriginAccount: requestingAccount, + TargetAccount: targetAcct, + } + + // return whatever relationship results from this + return p.RelationshipGet(requestingAccount, form.TargetAccountID) +} diff --git a/internal/processing/account/delete.go b/internal/processing/account/delete.go @@ -0,0 +1,270 @@ +/* + 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 <http://www.gnu.org/licenses/>. +*/ + +package account + +import ( + "time" + + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// Delete handles the complete deletion of an account. +// +// TODO in this function: +// 1. Delete account's application(s), clients, and oauth tokens +// 2. Delete account's blocks +// 3. Delete account's emoji +// 4. Delete account's follow requests +// 5. Delete account's follows +// 6. Delete account's statuses +// 7. Delete account's media attachments +// 8. Delete account's mentions +// 9. Delete account's polls +// 10. Delete account's notifications +// 11. Delete account's bookmarks +// 12. Delete account's faves +// 13. Delete account's mutes +// 14. Delete account's streams +// 15. Delete account's tags +// 16. Delete account's user +// 17. Delete account's timeline +// 18. Delete account itself +func (p *processor) Delete(account *gtsmodel.Account, deletedBy string) error { + l := p.log.WithFields(logrus.Fields{ + "func": "Delete", + "username": account.Username, + }) + + l.Debugf("beginning account delete process for username %s", account.Username) + + // 1. Delete account's application(s), clients, and oauth tokens + // we only need to do this step for local account since remote ones won't have any tokens or applications on our server + if account.Domain == "" { + // see if we can get a user for this account + u := &gtsmodel.User{} + if err := p.db.GetWhere([]db.Where{{Key: "account_id", Value: account.ID}}, u); err == nil { + // we got one! select all tokens with the user's ID + tokens := []*oauth.Token{} + if err := p.db.GetWhere([]db.Where{{Key: "user_id", Value: u.ID}}, &tokens); err == nil { + // we have some tokens to delete + for _, t := range tokens { + // delete client(s) associated with this token + if err := p.db.DeleteByID(t.ClientID, &oauth.Client{}); err != nil { + l.Errorf("error deleting oauth client: %s", err) + } + // delete application(s) associated with this token + if err := p.db.DeleteWhere([]db.Where{{Key: "client_id", Value: t.ClientID}}, &gtsmodel.Application{}); err != nil { + l.Errorf("error deleting application: %s", err) + } + // delete the token itself + if err := p.db.DeleteByID(t.ID, t); err != nil { + l.Errorf("error deleting oauth token: %s", err) + } + } + } + } + } + + // 2. Delete account's blocks + l.Debug("deleting account blocks") + // first delete any blocks that this account created + if err := p.db.DeleteWhere([]db.Where{{Key: "account_id", Value: account.ID}}, &[]*gtsmodel.Block{}); err != nil { + l.Errorf("error deleting blocks created by account: %s", err) + } + + // now delete any blocks that target this account + if err := p.db.DeleteWhere([]db.Where{{Key: "target_account_id", Value: account.ID}}, &[]*gtsmodel.Block{}); err != nil { + l.Errorf("error deleting blocks targeting account: %s", err) + } + + // 3. Delete account's emoji + // nothing to do here + + // 4. Delete account's follow requests + l.Debug("deleting account follow requests") + // first delete any follow requests that this account created + if err := p.db.DeleteWhere([]db.Where{{Key: "account_id", Value: account.ID}}, &[]*gtsmodel.FollowRequest{}); err != nil { + l.Errorf("error deleting follow requests created by account: %s", err) + } + + // now delete any follow requests that target this account + if err := p.db.DeleteWhere([]db.Where{{Key: "target_account_id", Value: account.ID}}, &[]*gtsmodel.FollowRequest{}); err != nil { + l.Errorf("error deleting follow requests targeting account: %s", err) + } + + // 5. Delete account's follows + l.Debug("deleting account follows") + // first delete any follows that this account created + if err := p.db.DeleteWhere([]db.Where{{Key: "account_id", Value: account.ID}}, &[]*gtsmodel.Follow{}); err != nil { + l.Errorf("error deleting follows created by account: %s", err) + } + + // now delete any follows that target this account + if err := p.db.DeleteWhere([]db.Where{{Key: "target_account_id", Value: account.ID}}, &[]*gtsmodel.Follow{}); err != nil { + l.Errorf("error deleting follows targeting account: %s", err) + } + + // 6. Delete account's statuses + l.Debug("deleting account statuses") + // we'll select statuses 20 at a time so we don't wreck the db, and pass them through to the client api channel + // Deleting the statuses in this way also handles 7. Delete account's media attachments, 8. Delete account's mentions, and 9. Delete account's polls, + // since these are all attached to statuses. + var maxID string +selectStatusesLoop: + for { + statuses, err := p.db.GetStatusesForAccount(account.ID, 20, false, maxID, false, false) + if err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + // no statuses left for this instance so we're done + l.Infof("Delete: done iterating through statuses for account %s", account.Username) + break selectStatusesLoop + } + // an actual error has occurred + l.Errorf("Delete: db error selecting statuses for account %s: %s", account.Username, err) + break selectStatusesLoop + } + + for i, s := range statuses { + // pass the status delete through the client api channel for processing + s.GTSAuthorAccount = account + l.Debug("putting status in the client api channel") + p.fromClientAPI <- gtsmodel.FromClientAPI{ + APObjectType: gtsmodel.ActivityStreamsNote, + APActivityType: gtsmodel.ActivityStreamsDelete, + GTSModel: s, + OriginAccount: account, + TargetAccount: account, + } + + if err := p.db.DeleteByID(s.ID, s); err != nil { + if _, ok := err.(db.ErrNoEntries); !ok { + // actual error has occurred + l.Errorf("Delete: db error status %s for account %s: %s", s.ID, account.Username, err) + break selectStatusesLoop + } + } + + // if there are any boosts of this status, delete them as well + boosts := []*gtsmodel.Status{} + if err := p.db.GetWhere([]db.Where{{Key: "boost_of_id", Value: s.ID}}, &boosts); err != nil { + if _, ok := err.(db.ErrNoEntries); !ok { + // an actual error has occurred + l.Errorf("Delete: db error selecting boosts of status %s for account %s: %s", s.ID, account.Username, err) + break selectStatusesLoop + } + } + + for _, b := range boosts { + oa := &gtsmodel.Account{} + if err := p.db.GetByID(b.AccountID, oa); err == nil { + + l.Debug("putting boost undo in the client api channel") + p.fromClientAPI <- gtsmodel.FromClientAPI{ + APObjectType: gtsmodel.ActivityStreamsAnnounce, + APActivityType: gtsmodel.ActivityStreamsUndo, + GTSModel: s, + OriginAccount: oa, + TargetAccount: account, + } + } + + if err := p.db.DeleteByID(b.ID, b); err != nil { + if _, ok := err.(db.ErrNoEntries); !ok { + // actual error has occurred + l.Errorf("Delete: db error deleting boost with id %s: %s", b.ID, err) + break selectStatusesLoop + } + } + } + + // if this is the last status in the slice, set the maxID appropriately for the next query + if i == len(statuses)-1 { + maxID = s.ID + } + } + } + l.Debug("done deleting statuses") + + // 10. Delete account's notifications + l.Debug("deleting account notifications") + if err := p.db.DeleteWhere([]db.Where{{Key: "origin_account_id", Value: account.ID}}, &[]*gtsmodel.Notification{}); err != nil { + l.Errorf("error deleting notifications created by account: %s", err) + } + + // 11. Delete account's bookmarks + l.Debug("deleting account bookmarks") + if err := p.db.DeleteWhere([]db.Where{{Key: "account_id", Value: account.ID}}, &[]*gtsmodel.StatusBookmark{}); err != nil { + l.Errorf("error deleting bookmarks created by account: %s", err) + } + + // 12. Delete account's faves + l.Debug("deleting account faves") + if err := p.db.DeleteWhere([]db.Where{{Key: "account_id", Value: account.ID}}, &[]*gtsmodel.StatusFave{}); err != nil { + l.Errorf("error deleting faves created by account: %s", err) + } + + // 13. Delete account's mutes + l.Debug("deleting account mutes") + if err := p.db.DeleteWhere([]db.Where{{Key: "account_id", Value: account.ID}}, &[]*gtsmodel.StatusMute{}); err != nil { + l.Errorf("error deleting status mutes created by account: %s", err) + } + + // 14. Delete account's streams + + // 15. Delete account's tags + // TODO + + // 16. Delete account's user + l.Debug("deleting account user") + if err := p.db.DeleteWhere([]db.Where{{Key: "account_id", Value: account.ID}}, &gtsmodel.User{}); err != nil { + return err + } + + // 17. Delete account's timeline + + // 18. Delete account itself + // to prevent the account being created again, set all these fields and update it in the db + // the account won't actually be *removed* from the database but it will be set to just a stub + + account.Note = "" + account.DisplayName = "" + account.AvatarMediaAttachmentID = "" + account.AvatarRemoteURL = "" + account.HeaderMediaAttachmentID = "" + account.HeaderRemoteURL = "" + account.Reason = "" + account.Fields = []gtsmodel.Field{} + account.HideCollections = true + account.Discoverable = false + + account.UpdatedAt = time.Now() + + account.SuspendedAt = time.Now() + account.SuspensionOrigin = deletedBy + + if err := p.db.UpdateByID(account.ID, account); err != nil { + return err + } + + l.Infof("deleted account with username %s from domain %s", account.Username, account.Domain) + return nil +} diff --git a/internal/processing/account/get.go b/internal/processing/account/get.go @@ -0,0 +1,59 @@ +/* + 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 <http://www.gnu.org/licenses/>. +*/ + +package account + +import ( + "errors" + "fmt" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +func (p *processor) Get(requestingAccount *gtsmodel.Account, targetAccountID string) (*apimodel.Account, error) { + targetAccount := &gtsmodel.Account{} + if err := p.db.GetByID(targetAccountID, targetAccount); err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + return nil, errors.New("account not found") + } + return nil, fmt.Errorf("db error: %s", err) + } + + // lazily dereference things on the account if it hasn't been done yet + var requestingUsername string + if requestingAccount != nil { + requestingUsername = requestingAccount.Username + } + if err := p.federator.DereferenceAccountFields(targetAccount, requestingUsername, false); err != nil { + p.log.WithField("func", "AccountGet").Debugf("dereferencing account: %s", err) + } + + var mastoAccount *apimodel.Account + var err error + if requestingAccount != nil && targetAccount.ID == requestingAccount.ID { + mastoAccount, err = p.tc.AccountToMastoSensitive(targetAccount) + } else { + mastoAccount, err = p.tc.AccountToMastoPublic(targetAccount) + } + if err != nil { + return nil, fmt.Errorf("error converting account: %s", err) + } + return mastoAccount, nil +} diff --git a/internal/processing/account/getfollowers.go b/internal/processing/account/getfollowers.go @@ -0,0 +1,79 @@ +/* + 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 <http://www.gnu.org/licenses/>. +*/ + +package account + +import ( + "fmt" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +func (p *processor) FollowersGet(requestingAccount *gtsmodel.Account, targetAccountID string) ([]apimodel.Account, gtserror.WithCode) { + blocked, err := p.db.Blocked(requestingAccount.ID, targetAccountID) + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + + if blocked { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("block exists between accounts")) + } + + followers := []gtsmodel.Follow{} + accounts := []apimodel.Account{} + if err := p.db.GetFollowersByAccountID(targetAccountID, &followers, false); err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + return accounts, nil + } + return nil, gtserror.NewErrorInternalError(err) + } + + for _, f := range followers { + blocked, err := p.db.Blocked(requestingAccount.ID, f.AccountID) + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + if blocked { + continue + } + + a := &gtsmodel.Account{} + if err := p.db.GetByID(f.AccountID, a); err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + continue + } + return nil, gtserror.NewErrorInternalError(err) + } + + // derefence account fields in case we haven't done it already + if err := p.federator.DereferenceAccountFields(a, requestingAccount.Username, false); err != nil { + // don't bail if we can't fetch them, we'll try another time + p.log.WithField("func", "AccountFollowersGet").Debugf("error dereferencing account fields: %s", err) + } + + account, err := p.tc.AccountToMastoPublic(a) + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + accounts = append(accounts, *account) + } + return accounts, nil +} diff --git a/internal/processing/account/getfollowing.go b/internal/processing/account/getfollowing.go @@ -0,0 +1,79 @@ +/* + 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 <http://www.gnu.org/licenses/>. +*/ + +package account + +import ( + "fmt" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +func (p *processor) FollowingGet(requestingAccount *gtsmodel.Account, targetAccountID string) ([]apimodel.Account, gtserror.WithCode) { + blocked, err := p.db.Blocked(requestingAccount.ID, targetAccountID) + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + + if blocked { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("block exists between accounts")) + } + + following := []gtsmodel.Follow{} + accounts := []apimodel.Account{} + if err := p.db.GetFollowingByAccountID(targetAccountID, &following); err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + return accounts, nil + } + return nil, gtserror.NewErrorInternalError(err) + } + + for _, f := range following { + blocked, err := p.db.Blocked(requestingAccount.ID, f.AccountID) + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + if blocked { + continue + } + + a := &gtsmodel.Account{} + if err := p.db.GetByID(f.TargetAccountID, a); err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + continue + } + return nil, gtserror.NewErrorInternalError(err) + } + + // derefence account fields in case we haven't done it already + if err := p.federator.DereferenceAccountFields(a, requestingAccount.Username, false); err != nil { + // don't bail if we can't fetch them, we'll try another time + p.log.WithField("func", "AccountFollowingGet").Debugf("error dereferencing account fields: %s", err) + } + + account, err := p.tc.AccountToMastoPublic(a) + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + accounts = append(accounts, *account) + } + return accounts, nil +} diff --git a/internal/processing/account/getrelationship.go b/internal/processing/account/getrelationship.go @@ -0,0 +1,46 @@ +/* + 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 <http://www.gnu.org/licenses/>. +*/ + +package account + +import ( + "errors" + "fmt" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +func (p *processor) RelationshipGet(requestingAccount *gtsmodel.Account, targetAccountID string) (*apimodel.Relationship, gtserror.WithCode) { + if requestingAccount == nil { + return nil, gtserror.NewErrorForbidden(errors.New("not authed")) + } + + gtsR, err := p.db.GetRelationship(requestingAccount.ID, targetAccountID) + if err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error getting relationship: %s", err)) + } + + r, err := p.tc.RelationshipToMasto(gtsR) + if err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting relationship: %s", err)) + } + + return r, nil +} diff --git a/internal/processing/account/getstatuses.go b/internal/processing/account/getstatuses.go @@ -0,0 +1,63 @@ +/* + 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 <http://www.gnu.org/licenses/>. +*/ + +package account + +import ( + "fmt" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +func (p *processor) StatusesGet(requestingAccount *gtsmodel.Account, targetAccountID string, limit int, excludeReplies bool, maxID string, pinnedOnly bool, mediaOnly bool) ([]apimodel.Status, gtserror.WithCode) { + targetAccount := &gtsmodel.Account{} + if err := p.db.GetByID(targetAccountID, targetAccount); err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("no entry found for account id %s", targetAccountID)) + } + return nil, gtserror.NewErrorInternalError(err) + } + + apiStatuses := []apimodel.Status{} + statuses, err := p.db.GetStatusesForAccount(targetAccountID, limit, excludeReplies, maxID, pinnedOnly, mediaOnly) + if err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + return apiStatuses, nil + } + return nil, gtserror.NewErrorInternalError(err) + } + + for _, s := range statuses { + visible, err := p.filter.StatusVisible(s, requestingAccount) + if err != nil || !visible { + continue + } + + apiStatus, err := p.tc.StatusToMasto(s, requestingAccount) + if err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status to masto: %s", err)) + } + + apiStatuses = append(apiStatuses, *apiStatus) + } + + return apiStatuses, nil +} diff --git a/internal/processing/account/removefollow.go b/internal/processing/account/removefollow.go @@ -0,0 +1,110 @@ +/* + 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 <http://www.gnu.org/licenses/>. +*/ + +package account + +import ( + "fmt" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +func (p *processor) FollowRemove(requestingAccount *gtsmodel.Account, targetAccountID string) (*apimodel.Relationship, gtserror.WithCode) { + // if there's a block between the accounts we shouldn't do anything + blocked, err := p.db.Blocked(requestingAccount.ID, targetAccountID) + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + if blocked { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("AccountFollowRemove: block exists between accounts")) + } + + // make sure the target account actually exists in our db + targetAcct := &gtsmodel.Account{} + if err := p.db.GetByID(targetAccountID, targetAcct); err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("AccountFollowRemove: account %s not found in the db: %s", targetAccountID, err)) + } + } + + // check if a follow request exists, and remove it if it does (storing the URI for later) + var frChanged bool + var frURI string + fr := &gtsmodel.FollowRequest{} + if err := p.db.GetWhere([]db.Where{ + {Key: "account_id", Value: requestingAccount.ID}, + {Key: "target_account_id", Value: targetAccountID}, + }, fr); err == nil { + frURI = fr.URI + if err := p.db.DeleteByID(fr.ID, fr); err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("AccountFollowRemove: error removing follow request from db: %s", err)) + } + frChanged = true + } + + // now do the same thing for any existing follow + var fChanged bool + var fURI string + f := &gtsmodel.Follow{} + if err := p.db.GetWhere([]db.Where{ + {Key: "account_id", Value: requestingAccount.ID}, + {Key: "target_account_id", Value: targetAccountID}, + }, f); err == nil { + fURI = f.URI + if err := p.db.DeleteByID(f.ID, f); err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("AccountFollowRemove: error removing follow from db: %s", err)) + } + fChanged = true + } + + // follow request status changed so send the UNDO activity to the channel for async processing + if frChanged { + p.fromClientAPI <- gtsmodel.FromClientAPI{ + APObjectType: gtsmodel.ActivityStreamsFollow, + APActivityType: gtsmodel.ActivityStreamsUndo, + GTSModel: &gtsmodel.Follow{ + AccountID: requestingAccount.ID, + TargetAccountID: targetAccountID, + URI: frURI, + }, + OriginAccount: requestingAccount, + TargetAccount: targetAcct, + } + } + + // follow status changed so send the UNDO activity to the channel for async processing + if fChanged { + p.fromClientAPI <- gtsmodel.FromClientAPI{ + APObjectType: gtsmodel.ActivityStreamsFollow, + APActivityType: gtsmodel.ActivityStreamsUndo, + GTSModel: &gtsmodel.Follow{ + AccountID: requestingAccount.ID, + TargetAccountID: targetAccountID, + URI: fURI, + }, + OriginAccount: requestingAccount, + TargetAccount: targetAcct, + } + } + + // return whatever relationship results from all this + return p.RelationshipGet(requestingAccount, targetAccountID) +} diff --git a/internal/processing/account/update.go b/internal/processing/account/update.go @@ -0,0 +1,199 @@ +/* + 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 <http://www.gnu.org/licenses/>. +*/ + +package account + +import ( + "bytes" + "errors" + "fmt" + "io" + "mime/multipart" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +func (p *processor) Update(account *gtsmodel.Account, form *apimodel.UpdateCredentialsRequest) (*apimodel.Account, error) { + l := p.log.WithField("func", "AccountUpdate") + + if form.Discoverable != nil { + if err := p.db.UpdateOneByID(account.ID, "discoverable", *form.Discoverable, &gtsmodel.Account{}); err != nil { + return nil, fmt.Errorf("error updating discoverable: %s", err) + } + } + + if form.Bot != nil { + if err := p.db.UpdateOneByID(account.ID, "bot", *form.Bot, &gtsmodel.Account{}); err != nil { + return nil, fmt.Errorf("error updating bot: %s", err) + } + } + + if form.DisplayName != nil { + if err := util.ValidateDisplayName(*form.DisplayName); err != nil { + return nil, err + } + if err := p.db.UpdateOneByID(account.ID, "display_name", *form.DisplayName, &gtsmodel.Account{}); err != nil { + return nil, err + } + } + + if form.Note != nil { + if err := util.ValidateNote(*form.Note); err != nil { + return nil, err + } + if err := p.db.UpdateOneByID(account.ID, "note", *form.Note, &gtsmodel.Account{}); err != nil { + return nil, err + } + } + + if form.Avatar != nil && form.Avatar.Size != 0 { + avatarInfo, err := p.UpdateAvatar(form.Avatar, account.ID) + if err != nil { + return nil, err + } + l.Tracef("new avatar info for account %s is %+v", account.ID, avatarInfo) + } + + if form.Header != nil && form.Header.Size != 0 { + headerInfo, err := p.UpdateHeader(form.Header, account.ID) + if err != nil { + return nil, err + } + l.Tracef("new header info for account %s is %+v", account.ID, headerInfo) + } + + if form.Locked != nil { + if err := p.db.UpdateOneByID(account.ID, "locked", *form.Locked, &gtsmodel.Account{}); err != nil { + return nil, err + } + } + + if form.Source != nil { + if form.Source.Language != nil { + if err := util.ValidateLanguage(*form.Source.Language); err != nil { + return nil, err + } + if err := p.db.UpdateOneByID(account.ID, "language", *form.Source.Language, &gtsmodel.Account{}); err != nil { + return nil, err + } + } + + if form.Source.Sensitive != nil { + if err := p.db.UpdateOneByID(account.ID, "locked", *form.Locked, &gtsmodel.Account{}); err != nil { + return nil, err + } + } + + if form.Source.Privacy != nil { + if err := util.ValidatePrivacy(*form.Source.Privacy); err != nil { + return nil, err + } + if err := p.db.UpdateOneByID(account.ID, "privacy", *form.Source.Privacy, &gtsmodel.Account{}); err != nil { + return nil, err + } + } + } + + // fetch the account with all updated values set + updatedAccount := &gtsmodel.Account{} + if err := p.db.GetByID(account.ID, updatedAccount); err != nil { + return nil, fmt.Errorf("could not fetch updated account %s: %s", account.ID, err) + } + + p.fromClientAPI <- gtsmodel.FromClientAPI{ + APObjectType: gtsmodel.ActivityStreamsProfile, + APActivityType: gtsmodel.ActivityStreamsUpdate, + GTSModel: updatedAccount, + OriginAccount: updatedAccount, + } + + acctSensitive, err := p.tc.AccountToMastoSensitive(updatedAccount) + if err != nil { + return nil, fmt.Errorf("could not convert account into mastosensitive account: %s", err) + } + return acctSensitive, nil +} + +// UpdateAvatar does the dirty work of checking the avatar part of an account update form, +// parsing and checking the image, and doing the necessary updates in the database for this to become +// the account's new avatar image. +func (p *processor) UpdateAvatar(avatar *multipart.FileHeader, accountID string) (*gtsmodel.MediaAttachment, error) { + var err error + if int(avatar.Size) > p.config.MediaConfig.MaxImageSize { + err = fmt.Errorf("avatar with size %d exceeded max image size of %d bytes", avatar.Size, p.config.MediaConfig.MaxImageSize) + return nil, err + } + f, err := avatar.Open() + if err != nil { + return nil, fmt.Errorf("could not read provided avatar: %s", err) + } + + // extract the bytes + buf := new(bytes.Buffer) + size, err := io.Copy(buf, f) + if err != nil { + return nil, fmt.Errorf("could not read provided avatar: %s", err) + } + if size == 0 { + return nil, errors.New("could not read provided avatar: size 0 bytes") + } + + // do the setting + avatarInfo, err := p.mediaHandler.ProcessHeaderOrAvatar(buf.Bytes(), accountID, media.Avatar, "") + if err != nil { + return nil, fmt.Errorf("error processing avatar: %s", err) + } + + return avatarInfo, f.Close() +} + +// UpdateHeader does the dirty work of checking the header part of an account update form, +// parsing and checking the image, and doing the necessary updates in the database for this to become +// the account's new header image. +func (p *processor) UpdateHeader(header *multipart.FileHeader, accountID string) (*gtsmodel.MediaAttachment, error) { + var err error + if int(header.Size) > p.config.MediaConfig.MaxImageSize { + err = fmt.Errorf("header with size %d exceeded max image size of %d bytes", header.Size, p.config.MediaConfig.MaxImageSize) + return nil, err + } + f, err := header.Open() + if err != nil { + return nil, fmt.Errorf("could not read provided header: %s", err) + } + + // extract the bytes + buf := new(bytes.Buffer) + size, err := io.Copy(buf, f) + if err != nil { + return nil, fmt.Errorf("could not read provided header: %s", err) + } + if size == 0 { + return nil, errors.New("could not read provided header: size 0 bytes") + } + + // do the setting + headerInfo, err := p.mediaHandler.ProcessHeaderOrAvatar(buf.Bytes(), accountID, media.Header, "") + if err != nil { + return nil, fmt.Errorf("error processing header: %s", err) + } + + return headerInfo, f.Close() +} diff --git a/internal/processing/admin.go b/internal/processing/admin.go @@ -19,55 +19,27 @@ package processing import ( - "bytes" - "errors" - "fmt" - "io" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/id" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) func (p *processor) AdminEmojiCreate(authed *oauth.Auth, form *apimodel.EmojiCreateRequest) (*apimodel.Emoji, error) { - if !authed.User.Admin { - return nil, fmt.Errorf("user %s not an admin", authed.User.ID) - } - - // open the emoji and extract the bytes from it - f, err := form.Image.Open() - if err != nil { - return nil, fmt.Errorf("error opening emoji: %s", err) - } - buf := new(bytes.Buffer) - size, err := io.Copy(buf, f) - if err != nil { - return nil, fmt.Errorf("error reading emoji: %s", err) - } - if size == 0 { - return nil, errors.New("could not read provided emoji: size 0 bytes") - } - - // allow the mediaHandler to work its magic of processing the emoji bytes, and putting them in whatever storage backend we're using - emoji, err := p.mediaHandler.ProcessLocalEmoji(buf.Bytes(), form.Shortcode) - if err != nil { - return nil, fmt.Errorf("error reading emoji: %s", err) - } + return p.adminProcessor.EmojiCreate(authed.Account, authed.User, form) +} - emojiID, err := id.NewULID() - if err != nil { - return nil, err - } - emoji.ID = emojiID +func (p *processor) AdminDomainBlockCreate(authed *oauth.Auth, form *apimodel.DomainBlockCreateRequest) (*apimodel.DomainBlock, gtserror.WithCode) { + return p.adminProcessor.DomainBlockCreate(authed.Account, form) +} - mastoEmoji, err := p.tc.EmojiToMasto(emoji) - if err != nil { - return nil, fmt.Errorf("error converting emoji to mastotype: %s", err) - } +func (p *processor) AdminDomainBlocksGet(authed *oauth.Auth, export bool) ([]*apimodel.DomainBlock, gtserror.WithCode) { + return p.adminProcessor.DomainBlocksGet(authed.Account, export) +} - if err := p.db.Put(emoji); err != nil { - return nil, fmt.Errorf("database error while processing emoji: %s", err) - } +func (p *processor) AdminDomainBlockGet(authed *oauth.Auth, id string, export bool) (*apimodel.DomainBlock, gtserror.WithCode) { + return p.adminProcessor.DomainBlockGet(authed.Account, id, export) +} - return &mastoEmoji, nil +func (p *processor) AdminDomainBlockDelete(authed *oauth.Auth, id string) (*apimodel.DomainBlock, gtserror.WithCode) { + return p.adminProcessor.DomainBlockDelete(authed.Account, id) } diff --git a/internal/processing/admin/admin.go b/internal/processing/admin/admin.go @@ -0,0 +1,60 @@ +/* + 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 <http://www.gnu.org/licenses/>. +*/ + +package admin + +import ( + "github.com/sirupsen/logrus" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" +) + +// Processor wraps a bunch of functions for processing admin actions. +type Processor interface { + DomainBlockCreate(account *gtsmodel.Account, form *apimodel.DomainBlockCreateRequest) (*apimodel.DomainBlock, gtserror.WithCode) + DomainBlocksGet(account *gtsmodel.Account, export bool) ([]*apimodel.DomainBlock, gtserror.WithCode) + DomainBlockGet(account *gtsmodel.Account, id string, export bool) (*apimodel.DomainBlock, gtserror.WithCode) + DomainBlockDelete(account *gtsmodel.Account, id string) (*apimodel.DomainBlock, gtserror.WithCode) + EmojiCreate(account *gtsmodel.Account, user *gtsmodel.User, form *apimodel.EmojiCreateRequest) (*apimodel.Emoji, error) +} + +type processor struct { + tc typeutils.TypeConverter + config *config.Config + mediaHandler media.Handler + fromClientAPI chan gtsmodel.FromClientAPI + db db.DB + log *logrus.Logger +} + +// New returns a new admin processor. +func New(db db.DB, tc typeutils.TypeConverter, mediaHandler media.Handler, fromClientAPI chan gtsmodel.FromClientAPI, config *config.Config, log *logrus.Logger) Processor { + return &processor{ + tc: tc, + config: config, + mediaHandler: mediaHandler, + fromClientAPI: fromClientAPI, + db: db, + log: log, + } +} diff --git a/internal/processing/admin/createdomainblock.go b/internal/processing/admin/createdomainblock.go @@ -0,0 +1,154 @@ +/* + 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 <http://www.gnu.org/licenses/>. +*/ + +package admin + +import ( + "fmt" + "time" + + "github.com/sirupsen/logrus" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" +) + +func (p *processor) DomainBlockCreate(account *gtsmodel.Account, form *apimodel.DomainBlockCreateRequest) (*apimodel.DomainBlock, gtserror.WithCode) { + // first check if we already have a block -- if err == nil we already had a block so we can skip a whole lot of work + domainBlock := &gtsmodel.DomainBlock{} + err := p.db.GetWhere([]db.Where{{Key: "domain", Value: form.Domain, CaseInsensitive: true}}, domainBlock) + if err != nil { + if _, ok := err.(db.ErrNoEntries); !ok { + // something went wrong in the DB + return nil, gtserror.NewErrorInternalError(fmt.Errorf("DomainBlockCreate: db error checking for existence of domain block %s: %s", form.Domain, err)) + } + + // there's no block for this domain yet so create one + // note: we take a new ulid from timestamp here in case we need to sort blocks + blockID, err := id.NewULID() + if err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("DomainBlockCreate: error creating id for new domain block %s: %s", form.Domain, err)) + } + + domainBlock = &gtsmodel.DomainBlock{ + ID: blockID, + Domain: form.Domain, + CreatedByAccountID: account.ID, + PrivateComment: form.PrivateComment, + PublicComment: form.PublicComment, + Obfuscate: form.Obfuscate, + } + + // put the new block in the database + if err := p.db.Put(domainBlock); err != nil { + if _, ok := err.(db.ErrAlreadyExists); !ok { + // there's a real error creating the block + return nil, gtserror.NewErrorInternalError(fmt.Errorf("DomainBlockCreate: db error putting new domain block %s: %s", form.Domain, err)) + } + } + + // process the side effects of the domain block asynchronously since it might take a while + go p.initiateDomainBlockSideEffects(account, domainBlock) // TODO: add this to a queuing system so it can retry/resume + } + + mastoDomainBlock, err := p.tc.DomainBlockToMasto(domainBlock, false) + if err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("DomainBlockCreate: error converting domain block to frontend/masto representation %s: %s", form.Domain, err)) + } + + return mastoDomainBlock, nil +} + +// initiateDomainBlockSideEffects should be called asynchronously, to process the side effects of a domain block: +// +// 1. Strip most info away from the instance entry for the domain. +// 2. Delete the instance account for that instance if it exists. +// 3. Select all accounts from this instance and pass them through the delete functionality of the processor. +func (p *processor) initiateDomainBlockSideEffects(account *gtsmodel.Account, block *gtsmodel.DomainBlock) { + l := p.log.WithFields(logrus.Fields{ + "func": "domainBlockProcessSideEffects", + "domain": block.Domain, + }) + + l.Debug("processing domain block side effects") + + // if we have an instance entry for this domain, update it with the new block ID and clear all fields + instance := &gtsmodel.Instance{} + if err := p.db.GetWhere([]db.Where{{Key: "domain", Value: block.Domain, CaseInsensitive: true}}, instance); err == nil { + instance.Title = "" + instance.UpdatedAt = time.Now() + instance.SuspendedAt = time.Now() + instance.DomainBlockID = block.ID + instance.ShortDescription = "" + instance.Description = "" + instance.Terms = "" + instance.ContactEmail = "" + instance.ContactAccountUsername = "" + instance.ContactAccountID = "" + instance.Version = "" + if err := p.db.UpdateByID(instance.ID, instance); err != nil { + l.Errorf("domainBlockProcessSideEffects: db error updating instance: %s", err) + } + l.Debug("domainBlockProcessSideEffects: instance entry updated") + } + + // if we have an instance account for this instance, delete it + if err := p.db.DeleteWhere([]db.Where{{Key: "username", Value: block.Domain, CaseInsensitive: true}}, &gtsmodel.Account{}); err != nil { + l.Errorf("domainBlockProcessSideEffects: db error removing instance account: %s", err) + } + + // delete accounts through the normal account deletion system (which should also delete media + posts + remove posts from timelines) + + limit := 20 // just select 20 accounts at a time so we don't nuke our DB/mem with one huge query + var maxID string // this is initially an empty string so we'll start at the top of accounts list (sorted by ID) + +selectAccountsLoop: + for { + accounts, err := p.db.GetAccountsForInstance(block.Domain, maxID, limit) + if err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + // no accounts left for this instance so we're done + l.Infof("domainBlockProcessSideEffects: done iterating through accounts for domain %s", block.Domain) + break selectAccountsLoop + } + // an actual error has occurred + l.Errorf("domainBlockProcessSideEffects: db error selecting accounts for domain %s: %s", block.Domain, err) + break selectAccountsLoop + } + + for i, a := range accounts { + l.Debugf("putting delete for account %s in the clientAPI channel", a.Username) + + // pass the account delete through the client api channel for processing + p.fromClientAPI <- gtsmodel.FromClientAPI{ + APObjectType: gtsmodel.ActivityStreamsPerson, + APActivityType: gtsmodel.ActivityStreamsDelete, + GTSModel: a, + OriginAccount: account, + TargetAccount: a, + } + + // if this is the last account in the slice, set the maxID appropriately for the next query + if i == len(accounts)-1 { + maxID = a.ID + } + } + } +} diff --git a/internal/processing/admin/deletedomainblock.go b/internal/processing/admin/deletedomainblock.go @@ -0,0 +1,36 @@ +package admin + +import ( + "fmt" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +func (p *processor) DomainBlockDelete(account *gtsmodel.Account, id string) (*apimodel.DomainBlock, gtserror.WithCode) { + domainBlock := &gtsmodel.DomainBlock{} + + if err := p.db.GetByID(id, domainBlock); err != nil { + if _, ok := err.(db.ErrNoEntries); !ok { + // something has gone really wrong + return nil, gtserror.NewErrorInternalError(err) + } + // there are no entries for this ID + return nil, gtserror.NewErrorNotFound(fmt.Errorf("no entry for ID %s", id)) + } + + // prepare the domain block to return + mastoDomainBlock, err := p.tc.DomainBlockToMasto(domainBlock, false) + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + + // delete the domain block + if err := p.db.DeleteByID(id, domainBlock); err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + + return mastoDomainBlock, nil +} diff --git a/internal/processing/admin/emoji.go b/internal/processing/admin/emoji.go @@ -0,0 +1,73 @@ +/* + 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 <http://www.gnu.org/licenses/>. +*/ + +package admin + +import ( + "bytes" + "errors" + "fmt" + "io" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" +) + +func (p *processor) EmojiCreate(account *gtsmodel.Account, user *gtsmodel.User, form *apimodel.EmojiCreateRequest) (*apimodel.Emoji, error) { + if user.Admin { + return nil, fmt.Errorf("user %s not an admin", user.ID) + } + + // open the emoji and extract the bytes from it + f, err := form.Image.Open() + if err != nil { + return nil, fmt.Errorf("error opening emoji: %s", err) + } + buf := new(bytes.Buffer) + size, err := io.Copy(buf, f) + if err != nil { + return nil, fmt.Errorf("error reading emoji: %s", err) + } + if size == 0 { + return nil, errors.New("could not read provided emoji: size 0 bytes") + } + + // allow the mediaHandler to work its magic of processing the emoji bytes, and putting them in whatever storage backend we're using + emoji, err := p.mediaHandler.ProcessLocalEmoji(buf.Bytes(), form.Shortcode) + if err != nil { + return nil, fmt.Errorf("error reading emoji: %s", err) + } + + emojiID, err := id.NewULID() + if err != nil { + return nil, err + } + emoji.ID = emojiID + + mastoEmoji, err := p.tc.EmojiToMasto(emoji) + if err != nil { + return nil, fmt.Errorf("error converting emoji to mastotype: %s", err) + } + + if err := p.db.Put(emoji); err != nil { + return nil, fmt.Errorf("database error while processing emoji: %s", err) + } + + return &mastoEmoji, nil +} diff --git a/internal/processing/admin/getdomainblock.go b/internal/processing/admin/getdomainblock.go @@ -0,0 +1,30 @@ +package admin + +import ( + "fmt" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +func (p *processor) DomainBlockGet(account *gtsmodel.Account, id string, export bool) (*apimodel.DomainBlock, gtserror.WithCode) { + domainBlock := &gtsmodel.DomainBlock{} + + if err := p.db.GetByID(id, domainBlock); err != nil { + if _, ok := err.(db.ErrNoEntries); !ok { + // something has gone really wrong + return nil, gtserror.NewErrorInternalError(err) + } + // there are no entries for this ID + return nil, gtserror.NewErrorNotFound(fmt.Errorf("no entry for ID %s", id)) + } + + mastoDomainBlock, err := p.tc.DomainBlockToMasto(domainBlock, export) + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + + return mastoDomainBlock, nil +} diff --git a/internal/processing/admin/getdomainblocks.go b/internal/processing/admin/getdomainblocks.go @@ -0,0 +1,30 @@ +package admin + +import ( + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +func (p *processor) DomainBlocksGet(account *gtsmodel.Account, export bool) ([]*apimodel.DomainBlock, gtserror.WithCode) { + domainBlocks := []*gtsmodel.DomainBlock{} + + if err := p.db.GetAll(&domainBlocks); err != nil { + if _, ok := err.(db.ErrNoEntries); !ok { + // something has gone really wrong + return nil, gtserror.NewErrorInternalError(err) + } + } + + mastoDomainBlocks := []*apimodel.DomainBlock{} + for _, b := range domainBlocks { + mastoDomainBlock, err := p.tc.DomainBlockToMasto(b, export) + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + mastoDomainBlocks = append(mastoDomainBlocks, mastoDomainBlock) + } + + return mastoDomainBlocks, nil +} diff --git a/internal/processing/federation.go b/internal/processing/federation.go @@ -20,6 +20,7 @@ package processing import ( "context" + "errors" "fmt" "net/http" "net/url" @@ -89,7 +90,7 @@ func (p *processor) dereferenceFediRequest(username string, requestingAccountURI return requestingAccount, nil } -func (p *processor) GetFediUser(requestedUsername string, request *http.Request) (interface{}, gtserror.WithCode) { +func (p *processor) GetFediUser(ctx context.Context, requestedUsername string, requestURL *url.URL) (interface{}, gtserror.WithCode) { // get the account the request is referring to requestedAccount := &gtsmodel.Account{} if err := p.db.GetLocalAccountByUsername(requestedUsername, requestedAccount); err != nil { @@ -98,17 +99,17 @@ func (p *processor) GetFediUser(requestedUsername string, request *http.Request) var requestedPerson vocab.ActivityStreamsPerson var err error - if util.IsPublicKeyPath(request.URL) { + if util.IsPublicKeyPath(requestURL) { // if it's a public key path, we don't need to authenticate but we'll only serve the bare minimum user profile needed for the public key requestedPerson, err = p.tc.AccountToASMinimal(requestedAccount) if err != nil { return nil, gtserror.NewErrorInternalError(err) } - } else if util.IsUserPath(request.URL) { + } else if util.IsUserPath(requestURL) { // if it's a user path, we want to fully authenticate the request before we serve any data, and then we can serve a more complete profile - requestingAccountURI, err := p.federator.AuthenticateFederatedRequest(requestedUsername, request) - if err != nil { - return nil, gtserror.NewErrorNotAuthorized(err) + requestingAccountURI, authenticated, err := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername) + if err != nil || !authenticated { + return nil, gtserror.NewErrorNotAuthorized(errors.New("not authorized"), "not authorized") } // if we're already handshaking/dereferencing a remote account, we can skip the dereferencing part @@ -144,7 +145,7 @@ func (p *processor) GetFediUser(requestedUsername string, request *http.Request) return data, nil } -func (p *processor) GetFediFollowers(requestedUsername string, request *http.Request) (interface{}, gtserror.WithCode) { +func (p *processor) GetFediFollowers(ctx context.Context, requestedUsername string, requestURL *url.URL) (interface{}, gtserror.WithCode) { // get the account the request is referring to requestedAccount := &gtsmodel.Account{} if err := p.db.GetLocalAccountByUsername(requestedUsername, requestedAccount); err != nil { @@ -152,9 +153,9 @@ func (p *processor) GetFediFollowers(requestedUsername string, request *http.Req } // authenticate the request - requestingAccountURI, err := p.federator.AuthenticateFederatedRequest(requestedUsername, request) - if err != nil { - return nil, gtserror.NewErrorNotAuthorized(err) + requestingAccountURI, authenticated, err := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername) + if err != nil || !authenticated { + return nil, gtserror.NewErrorNotAuthorized(errors.New("not authorized"), "not authorized") } requestingAccount, err := p.dereferenceFediRequest(requestedUsername, requestingAccountURI) @@ -189,7 +190,7 @@ func (p *processor) GetFediFollowers(requestedUsername string, request *http.Req return data, nil } -func (p *processor) GetFediFollowing(requestedUsername string, request *http.Request) (interface{}, gtserror.WithCode) { +func (p *processor) GetFediFollowing(ctx context.Context, requestedUsername string, requestURL *url.URL) (interface{}, gtserror.WithCode) { // get the account the request is referring to requestedAccount := &gtsmodel.Account{} if err := p.db.GetLocalAccountByUsername(requestedUsername, requestedAccount); err != nil { @@ -197,9 +198,9 @@ func (p *processor) GetFediFollowing(requestedUsername string, request *http.Req } // authenticate the request - requestingAccountURI, err := p.federator.AuthenticateFederatedRequest(requestedUsername, request) - if err != nil { - return nil, gtserror.NewErrorNotAuthorized(err) + requestingAccountURI, authenticated, err := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername) + if err != nil || !authenticated { + return nil, gtserror.NewErrorNotAuthorized(errors.New("not authorized"), "not authorized") } requestingAccount, err := p.dereferenceFediRequest(requestedUsername, requestingAccountURI) @@ -234,7 +235,7 @@ func (p *processor) GetFediFollowing(requestedUsername string, request *http.Req return data, nil } -func (p *processor) GetFediStatus(requestedUsername string, requestedStatusID string, request *http.Request) (interface{}, gtserror.WithCode) { +func (p *processor) GetFediStatus(ctx context.Context, requestedUsername string, requestedStatusID string, requestURL *url.URL) (interface{}, gtserror.WithCode) { // get the account the request is referring to requestedAccount := &gtsmodel.Account{} if err := p.db.GetLocalAccountByUsername(requestedUsername, requestedAccount); err != nil { @@ -242,9 +243,9 @@ func (p *processor) GetFediStatus(requestedUsername string, requestedStatusID st } // authenticate the request - requestingAccountURI, err := p.federator.AuthenticateFederatedRequest(requestedUsername, request) - if err != nil { - return nil, gtserror.NewErrorNotAuthorized(err) + requestingAccountURI, authenticated, err := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername) + if err != nil || !authenticated { + return nil, gtserror.NewErrorNotAuthorized(errors.New("not authorized"), "not authorized") } requestingAccount, err := p.dereferenceFediRequest(requestedUsername, requestingAccountURI) @@ -294,7 +295,7 @@ func (p *processor) GetFediStatus(requestedUsername string, requestedStatusID st return data, nil } -func (p *processor) GetWebfingerAccount(requestedUsername string, request *http.Request) (*apimodel.WellKnownResponse, gtserror.WithCode) { +func (p *processor) GetWebfingerAccount(ctx context.Context, requestedUsername string, requestURL *url.URL) (*apimodel.WellKnownResponse, gtserror.WithCode) { // get the account the request is referring to requestedAccount := &gtsmodel.Account{} if err := p.db.GetLocalAccountByUsername(requestedUsername, requestedAccount); err != nil { @@ -356,6 +357,5 @@ func (p *processor) GetNodeInfo(request *http.Request) (*apimodel.Nodeinfo, gtse 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 + return p.federator.FederatingActor().PostInbox(contextWithChannel, w, r) } diff --git a/internal/processing/fromclientapi.go b/internal/processing/fromclientapi.go @@ -25,6 +25,7 @@ import ( "net/url" "github.com/go-fed/activity/streams" + "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" ) @@ -161,17 +162,69 @@ func (p *processor) processFromClientAPI(clientMsg gtsmodel.FromClientAPI) error return errors.New("note was not parseable as *gtsmodel.Status") } + if statusToDelete.GTSAuthorAccount == nil { + statusToDelete.GTSAuthorAccount = clientMsg.OriginAccount + } + + // delete all attachments for this status + for _, a := range statusToDelete.Attachments { + if err := p.mediaProcessor.Delete(a); err != nil { + return err + } + } + + // delete all mentions for this status + for _, m := range statusToDelete.Mentions { + if err := p.db.DeleteByID(m, &gtsmodel.Mention{}); err != nil { + return err + } + } + + // delete all notifications for this status + if err := p.db.DeleteWhere([]db.Where{{Key: "status_id", Value: statusToDelete.ID}}, &[]*gtsmodel.Notification{}); err != nil { + return err + } + + // delete this status from any and all timelines if err := p.deleteStatusFromTimelines(statusToDelete); err != nil { return err } - return p.federateStatusDelete(statusToDelete, clientMsg.OriginAccount) + return p.federateStatusDelete(statusToDelete) + case gtsmodel.ActivityStreamsProfile, gtsmodel.ActivityStreamsPerson: + // DELETE ACCOUNT/PROFILE + accountToDelete, ok := clientMsg.GTSModel.(*gtsmodel.Account) + if !ok { + return errors.New("account was not parseable as *gtsmodel.Account") + } + + var deletedBy string + if clientMsg.OriginAccount != nil { + deletedBy = clientMsg.OriginAccount.ID + } + + return p.accountProcessor.Delete(accountToDelete, deletedBy) } } return nil } +// TODO: move all the below functions into federation.Federator + func (p *processor) federateStatus(status *gtsmodel.Status) error { + if status.GTSAuthorAccount == nil { + a := &gtsmodel.Account{} + if err := p.db.GetByID(status.AccountID, a); err != nil { + return fmt.Errorf("federateStatus: error fetching status author account: %s", err) + } + status.GTSAuthorAccount = a + } + + // do nothing if this isn't our status + if status.GTSAuthorAccount.Domain != "" { + return nil + } + asStatus, err := p.tc.StatusToAS(status) if err != nil { return fmt.Errorf("federateStatus: error converting status to as format: %s", err) @@ -186,20 +239,33 @@ func (p *processor) federateStatus(status *gtsmodel.Status) error { return err } -func (p *processor) federateStatusDelete(status *gtsmodel.Status, originAccount *gtsmodel.Account) error { +func (p *processor) federateStatusDelete(status *gtsmodel.Status) error { + if status.GTSAuthorAccount == nil { + a := &gtsmodel.Account{} + if err := p.db.GetByID(status.AccountID, a); err != nil { + return fmt.Errorf("federateStatus: error fetching status author account: %s", err) + } + status.GTSAuthorAccount = a + } + + // do nothing if this isn't our status + if status.GTSAuthorAccount.Domain != "" { + return nil + } + asStatus, err := p.tc.StatusToAS(status) if err != nil { return fmt.Errorf("federateStatusDelete: error converting status to as format: %s", err) } - outboxIRI, err := url.Parse(originAccount.OutboxURI) + outboxIRI, err := url.Parse(status.GTSAuthorAccount.OutboxURI) if err != nil { - return fmt.Errorf("federateStatusDelete: error parsing outboxURI %s: %s", originAccount.OutboxURI, err) + return fmt.Errorf("federateStatusDelete: error parsing outboxURI %s: %s", status.GTSAuthorAccount.OutboxURI, err) } - actorIRI, err := url.Parse(originAccount.URI) + actorIRI, err := url.Parse(status.GTSAuthorAccount.URI) if err != nil { - return fmt.Errorf("federateStatusDelete: error parsing actorIRI %s: %s", originAccount.URI, err) + return fmt.Errorf("federateStatusDelete: error parsing actorIRI %s: %s", status.GTSAuthorAccount.URI, err) } // create a delete and set the appropriate actor on it @@ -326,6 +392,11 @@ func (p *processor) federateUnfave(fave *gtsmodel.StatusFave, originAccount *gts } func (p *processor) federateUnannounce(boost *gtsmodel.Status, originAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) error { + if originAccount.Domain != "" { + // nothing to do here + return nil + } + asAnnounce, err := p.tc.BoostToAS(boost, originAccount, targetAccount) if err != nil { return fmt.Errorf("federateUnannounce: error converting status to announce: %s", err) diff --git a/internal/processing/fromfederator.go b/internal/processing/fromfederator.go @@ -21,7 +21,6 @@ package processing import ( "errors" "fmt" - "net/url" "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/db" @@ -49,7 +48,7 @@ func (p *processor) processFromFederator(federatorMsg gtsmodel.FromFederator) er } l.Debug("will now derefence incoming status") - if err := p.dereferenceStatusFields(incomingStatus, federatorMsg.ReceivingAccount.Username); err != nil { + if err := p.federator.DereferenceStatusFields(incomingStatus, federatorMsg.ReceivingAccount.Username); err != nil { return fmt.Errorf("error dereferencing status from federator: %s", err) } if err := p.db.UpdateByID(incomingStatus.ID, incomingStatus); err != nil { @@ -72,7 +71,7 @@ func (p *processor) processFromFederator(federatorMsg gtsmodel.FromFederator) er } l.Debug("will now derefence incoming account") - if err := p.dereferenceAccountFields(incomingAccount, "", false); err != nil { + if err := p.federator.DereferenceAccountFields(incomingAccount, "", false); err != nil { return fmt.Errorf("error dereferencing account from federator: %s", err) } if err := p.db.UpdateByID(incomingAccount.ID, incomingAccount); err != nil { @@ -105,7 +104,7 @@ func (p *processor) processFromFederator(federatorMsg gtsmodel.FromFederator) er return errors.New("announce was not parseable as *gtsmodel.Status") } - if err := p.dereferenceAnnounce(incomingAnnounce, federatorMsg.ReceivingAccount.Username); err != nil { + if err := p.federator.DereferenceAnnounce(incomingAnnounce, federatorMsg.ReceivingAccount.Username); err != nil { return fmt.Errorf("error dereferencing announce from federator: %s", err) } @@ -140,7 +139,7 @@ func (p *processor) processFromFederator(federatorMsg gtsmodel.FromFederator) er } l.Debug("will now derefence incoming account") - if err := p.dereferenceAccountFields(incomingAccount, federatorMsg.ReceivingAccount.Username, true); err != nil { + if err := p.federator.DereferenceAccountFields(incomingAccount, federatorMsg.ReceivingAccount.Username, true); err != nil { return fmt.Errorf("error dereferencing account from federator: %s", err) } if err := p.db.UpdateByID(incomingAccount.ID, incomingAccount); err != nil { @@ -160,6 +159,27 @@ func (p *processor) processFromFederator(federatorMsg gtsmodel.FromFederator) er if !ok { return errors.New("note was not parseable as *gtsmodel.Status") } + + // delete all attachments for this status + for _, a := range statusToDelete.Attachments { + if err := p.mediaProcessor.Delete(a); err != nil { + return err + } + } + + // delete all mentions for this status + for _, m := range statusToDelete.Mentions { + if err := p.db.DeleteByID(m, &gtsmodel.Mention{}); err != nil { + return err + } + } + + // delete all notifications for this status + if err := p.db.DeleteWhere([]db.Where{{Key: "status_id", Value: statusToDelete.ID}}, &[]*gtsmodel.Notification{}); err != nil { + return err + } + + // remove this status from any and all timelines return p.deleteStatusFromTimelines(statusToDelete) case gtsmodel.ActivityStreamsProfile: // DELETE A PROFILE/ACCOUNT @@ -183,299 +203,3 @@ func (p *processor) processFromFederator(federatorMsg gtsmodel.FromFederator) er return nil } - -// dereferenceStatusFields fetches all the information we temporarily pinned to an incoming -// federated status, back in the federating db's Create function. -// -// When a status comes in from the federation API, there are certain fields that -// haven't been dereferenced yet, because we needed to provide a snappy synchronous -// response to the caller. By the time it reaches this function though, it's being -// processed asynchronously, so we have all the time in the world to fetch the various -// bits and bobs that are attached to the status, and properly flesh it out, before we -// send the status to any timelines and notify people. -// -// Things to dereference and fetch here: -// -// 1. Media attachments. -// 2. Hashtags. -// 3. Emojis. -// 4. Mentions. -// 5. Posting account. -// 6. Replied-to-status. -// -// SIDE EFFECTS: -// This function will deference all of the above, insert them in the database as necessary, -// and attach them to the status. The status itself will not be added to the database yet, -// that's up the caller to do. -func (p *processor) dereferenceStatusFields(status *gtsmodel.Status, requestingUsername string) error { - l := p.log.WithFields(logrus.Fields{ - "func": "dereferenceStatusFields", - "status": fmt.Sprintf("%+v", status), - }) - l.Debug("entering function") - - t, err := p.federator.GetTransportForUser(requestingUsername) - if err != nil { - return fmt.Errorf("error creating transport: %s", err) - } - - // the status should have an ID by now, but just in case it doesn't let's generate one here - // because we'll need it further down - if status.ID == "" { - newID, err := id.NewULIDFromTime(status.CreatedAt) - if err != nil { - return err - } - status.ID = newID - } - - // 1. Media attachments. - // - // At this point we should know: - // * the media type of the file we're looking for (a.File.ContentType) - // * the blurhash (a.Blurhash) - // * the file type (a.Type) - // * the remote URL (a.RemoteURL) - // This should be enough to pass along to the media processor. - attachmentIDs := []string{} - for _, a := range status.GTSMediaAttachments { - l.Debugf("dereferencing attachment: %+v", a) - - // it might have been processed elsewhere so check first if it's already in the database or not - maybeAttachment := &gtsmodel.MediaAttachment{} - err := p.db.GetWhere([]db.Where{{Key: "remote_url", Value: a.RemoteURL}}, maybeAttachment) - if err == nil { - // we already have it in the db, dereferenced, no need to do it again - l.Debugf("attachment already exists with id %s", maybeAttachment.ID) - attachmentIDs = append(attachmentIDs, maybeAttachment.ID) - continue - } - if _, ok := err.(db.ErrNoEntries); !ok { - // we have a real error - return fmt.Errorf("error checking db for existence of attachment with remote url %s: %s", a.RemoteURL, err) - } - // it just doesn't exist yet so carry on - l.Debug("attachment doesn't exist yet, calling ProcessRemoteAttachment", a) - deferencedAttachment, err := p.mediaHandler.ProcessRemoteAttachment(t, a, status.AccountID) - if err != nil { - p.log.Errorf("error dereferencing status attachment: %s", err) - continue - } - l.Debugf("dereferenced attachment: %+v", deferencedAttachment) - deferencedAttachment.StatusID = status.ID - deferencedAttachment.Description = a.Description - if err := p.db.Put(deferencedAttachment); err != nil { - return fmt.Errorf("error inserting dereferenced attachment with remote url %s: %s", a.RemoteURL, err) - } - attachmentIDs = append(attachmentIDs, deferencedAttachment.ID) - } - status.Attachments = attachmentIDs - - // 2. Hashtags - - // 3. Emojis - - // 4. Mentions - // At this point, mentions should have the namestring and mentionedAccountURI set on them. - // - // We should dereference any accounts mentioned here which we don't have in our db yet, by their URI. - mentions := []string{} - for _, m := range status.GTSMentions { - if m.ID == "" { - mID, err := id.NewRandomULID() - if err != nil { - return err - } - m.ID = mID - } - - uri, err := url.Parse(m.MentionedAccountURI) - if err != nil { - l.Debugf("error parsing mentioned account uri %s: %s", m.MentionedAccountURI, err) - continue - } - - m.StatusID = status.ID - m.OriginAccountID = status.GTSAuthorAccount.ID - m.OriginAccountURI = status.GTSAuthorAccount.URI - - targetAccount := &gtsmodel.Account{} - if err := p.db.GetWhere([]db.Where{{Key: "uri", Value: uri.String()}}, targetAccount); err != nil { - // proper error - if _, ok := err.(db.ErrNoEntries); !ok { - return fmt.Errorf("db error checking for account with uri %s", uri.String()) - } - - // we just don't have it yet, so we should go get it.... - accountable, err := p.federator.DereferenceRemoteAccount(requestingUsername, uri) - if err != nil { - // we can't dereference it so just skip it - l.Debugf("error dereferencing remote account with uri %s: %s", uri.String(), err) - continue - } - - targetAccount, err = p.tc.ASRepresentationToAccount(accountable, false) - if err != nil { - l.Debugf("error converting remote account with uri %s into gts model: %s", uri.String(), err) - continue - } - - targetAccountID, err := id.NewRandomULID() - if err != nil { - return err - } - targetAccount.ID = targetAccountID - - if err := p.db.Put(targetAccount); err != nil { - return fmt.Errorf("db error inserting account with uri %s", uri.String()) - } - } - - // by this point, we know the targetAccount exists in our database with an ID :) - m.TargetAccountID = targetAccount.ID - if err := p.db.Put(m); err != nil { - return fmt.Errorf("error creating mention: %s", err) - } - mentions = append(mentions, m.ID) - } - status.Mentions = mentions - - return nil -} - -func (p *processor) dereferenceAccountFields(account *gtsmodel.Account, requestingUsername string, refresh bool) error { - l := p.log.WithFields(logrus.Fields{ - "func": "dereferenceAccountFields", - "requestingUsername": requestingUsername, - }) - - t, err := p.federator.GetTransportForUser(requestingUsername) - if err != nil { - return fmt.Errorf("error getting transport for user: %s", err) - } - - // fetch the header and avatar - if err := p.fetchHeaderAndAviForAccount(account, t, refresh); err != nil { - // if this doesn't work, just skip it -- we can do it later - l.Debugf("error fetching header/avi for account: %s", err) - } - - if err := p.db.UpdateByID(account.ID, account); err != nil { - return fmt.Errorf("error updating account in database: %s", err) - } - - return nil -} - -func (p *processor) dereferenceAnnounce(announce *gtsmodel.Status, requestingUsername string) error { - if announce.GTSBoostedStatus == nil || announce.GTSBoostedStatus.URI == "" { - // we can't do anything unfortunately - return errors.New("dereferenceAnnounce: no URI to dereference") - } - - // check if we already have the boosted status in the database - boostedStatus := &gtsmodel.Status{} - err := p.db.GetWhere([]db.Where{{Key: "uri", Value: announce.GTSBoostedStatus.URI}}, boostedStatus) - if err == nil { - // nice, we already have it so we don't actually need to dereference it from remote - announce.Content = boostedStatus.Content - announce.ContentWarning = boostedStatus.ContentWarning - announce.ActivityStreamsType = boostedStatus.ActivityStreamsType - announce.Sensitive = boostedStatus.Sensitive - announce.Language = boostedStatus.Language - announce.Text = boostedStatus.Text - announce.BoostOfID = boostedStatus.ID - announce.Visibility = boostedStatus.Visibility - announce.VisibilityAdvanced = boostedStatus.VisibilityAdvanced - announce.GTSBoostedStatus = boostedStatus - return nil - } - - // we don't have it so we need to dereference it - remoteStatusURI, err := url.Parse(announce.GTSBoostedStatus.URI) - if err != nil { - return fmt.Errorf("dereferenceAnnounce: error parsing url %s: %s", announce.GTSBoostedStatus.URI, err) - } - - statusable, err := p.federator.DereferenceRemoteStatus(requestingUsername, remoteStatusURI) - if err != nil { - return fmt.Errorf("dereferenceAnnounce: error dereferencing remote status with id %s: %s", announce.GTSBoostedStatus.URI, err) - } - - // make sure we have the author account in the db - attributedToProp := statusable.GetActivityStreamsAttributedTo() - for iter := attributedToProp.Begin(); iter != attributedToProp.End(); iter = iter.Next() { - accountURI := iter.GetIRI() - if accountURI == nil { - continue - } - - if err := p.db.GetWhere([]db.Where{{Key: "uri", Value: accountURI.String()}}, &gtsmodel.Account{}); err == nil { - // we already have it, fine - continue - } - - // we don't have the boosted status author account yet so dereference it - accountable, err := p.federator.DereferenceRemoteAccount(requestingUsername, accountURI) - if err != nil { - return fmt.Errorf("dereferenceAnnounce: error dereferencing remote account with id %s: %s", accountURI.String(), err) - } - account, err := p.tc.ASRepresentationToAccount(accountable, false) - if err != nil { - return fmt.Errorf("dereferenceAnnounce: error converting dereferenced account with id %s into account : %s", accountURI.String(), err) - } - - accountID, err := id.NewRandomULID() - if err != nil { - return err - } - account.ID = accountID - - if err := p.db.Put(account); err != nil { - return fmt.Errorf("dereferenceAnnounce: error putting dereferenced account with id %s into database : %s", accountURI.String(), err) - } - - if err := p.dereferenceAccountFields(account, requestingUsername, false); err != nil { - return fmt.Errorf("dereferenceAnnounce: error dereferencing fields on account with id %s : %s", accountURI.String(), err) - } - } - - // now convert the statusable into something we can understand - boostedStatus, err = p.tc.ASStatusToStatus(statusable) - if err != nil { - return fmt.Errorf("dereferenceAnnounce: error converting dereferenced statusable with id %s into status : %s", announce.GTSBoostedStatus.URI, err) - } - - boostedStatusID, err := id.NewULIDFromTime(boostedStatus.CreatedAt) - if err != nil { - return nil - } - boostedStatus.ID = boostedStatusID - - if err := p.db.Put(boostedStatus); err != nil { - return fmt.Errorf("dereferenceAnnounce: error putting dereferenced status with id %s into the db: %s", announce.GTSBoostedStatus.URI, err) - } - - // now dereference additional fields straight away (we're already async here so we have time) - if err := p.dereferenceStatusFields(boostedStatus, requestingUsername); err != nil { - return fmt.Errorf("dereferenceAnnounce: error dereferencing status fields for status with id %s: %s", announce.GTSBoostedStatus.URI, err) - } - - // update with the newly dereferenced fields - if err := p.db.UpdateByID(boostedStatus.ID, boostedStatus); err != nil { - return fmt.Errorf("dereferenceAnnounce: error updating dereferenced status in the db: %s", err) - } - - // we have everything we need! - announce.Content = boostedStatus.Content - announce.ContentWarning = boostedStatus.ContentWarning - announce.ActivityStreamsType = boostedStatus.ActivityStreamsType - announce.Sensitive = boostedStatus.Sensitive - announce.Language = boostedStatus.Language - announce.Text = boostedStatus.Text - announce.BoostOfID = boostedStatus.ID - announce.Visibility = boostedStatus.Visibility - announce.VisibilityAdvanced = boostedStatus.VisibilityAdvanced - announce.GTSBoostedStatus = boostedStatus - return nil -} diff --git a/internal/processing/instance.go b/internal/processing/instance.go @@ -131,7 +131,7 @@ func (p *processor) InstancePatch(form *apimodel.InstanceSettingsUpdateRequest) // process avatar if provided if form.Avatar != nil && form.Avatar.Size != 0 { - _, err := p.updateAccountAvatar(form.Avatar, ia.ID) + _, err := p.accountProcessor.UpdateAvatar(form.Avatar, ia.ID) if err != nil { return nil, gtserror.NewErrorBadRequest(err, "error processing avatar") } @@ -139,7 +139,7 @@ func (p *processor) InstancePatch(form *apimodel.InstanceSettingsUpdateRequest) // process header if provided if form.Header != nil && form.Header.Size != 0 { - _, err := p.updateAccountHeader(form.Header, ia.ID) + _, err := p.accountProcessor.UpdateHeader(form.Header, ia.ID) if err != nil { return nil, gtserror.NewErrorBadRequest(err, "error processing header") } diff --git a/internal/processing/media.go b/internal/processing/media.go @@ -19,268 +19,23 @@ package processing import ( - "bytes" - "errors" - "fmt" - "io" - "strconv" - "strings" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) func (p *processor) MediaCreate(authed *oauth.Auth, form *apimodel.AttachmentRequest) (*apimodel.Attachment, error) { - // First check this user/account is permitted to create media - // There's no point continuing otherwise. - // - // TODO: move this check to the oauth.Authed function and do it for all accounts - if authed.User.Disabled || !authed.User.Approved || !authed.Account.SuspendedAt.IsZero() { - return nil, errors.New("not authorized to post new media") - } - - // open the attachment and extract the bytes from it - f, err := form.File.Open() - if err != nil { - return nil, fmt.Errorf("error opening attachment: %s", err) - } - buf := new(bytes.Buffer) - size, err := io.Copy(buf, f) - if err != nil { - return nil, fmt.Errorf("error reading attachment: %s", err) - - } - if size == 0 { - return nil, errors.New("could not read provided attachment: size 0 bytes") - } - - // allow the mediaHandler to work its magic of processing the attachment bytes, and putting them in whatever storage backend we're using - attachment, err := p.mediaHandler.ProcessAttachment(buf.Bytes(), authed.Account.ID, "") - if err != nil { - return nil, fmt.Errorf("error reading attachment: %s", err) - } - - // now we need to add extra fields that the attachment processor doesn't know (from the form) - // TODO: handle this inside mediaHandler.ProcessAttachment (just pass more params to it) - - // first description - attachment.Description = form.Description - - // now parse the focus parameter - focusx, focusy, err := parseFocus(form.Focus) - if err != nil { - return nil, err - } - attachment.FileMeta.Focus.X = focusx - attachment.FileMeta.Focus.Y = focusy - - // prepare the frontend representation now -- if there are any errors here at least we can bail without - // having already put something in the database and then having to clean it up again (eugh) - mastoAttachment, err := p.tc.AttachmentToMasto(attachment) - if err != nil { - return nil, fmt.Errorf("error parsing media attachment to frontend type: %s", err) - } - - // now we can confidently put the attachment in the database - if err := p.db.Put(attachment); err != nil { - return nil, fmt.Errorf("error storing media attachment in db: %s", err) - } - - return &mastoAttachment, nil + return p.mediaProcessor.Create(authed.Account, form) } func (p *processor) MediaGet(authed *oauth.Auth, mediaAttachmentID string) (*apimodel.Attachment, gtserror.WithCode) { - attachment := &gtsmodel.MediaAttachment{} - if err := p.db.GetByID(mediaAttachmentID, attachment); err != nil { - if _, ok := err.(db.ErrNoEntries); ok { - // attachment doesn't exist - return nil, gtserror.NewErrorNotFound(errors.New("attachment doesn't exist in the db")) - } - return nil, gtserror.NewErrorNotFound(fmt.Errorf("db error getting attachment: %s", err)) - } - - if attachment.AccountID != authed.Account.ID { - return nil, gtserror.NewErrorNotFound(errors.New("attachment not owned by requesting account")) - } - - a, err := p.tc.AttachmentToMasto(attachment) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("error converting attachment: %s", err)) - } - - return &a, nil + return p.mediaProcessor.GetMedia(authed.Account, mediaAttachmentID) } func (p *processor) MediaUpdate(authed *oauth.Auth, mediaAttachmentID string, form *apimodel.AttachmentUpdateRequest) (*apimodel.Attachment, gtserror.WithCode) { - attachment := &gtsmodel.MediaAttachment{} - if err := p.db.GetByID(mediaAttachmentID, attachment); err != nil { - if _, ok := err.(db.ErrNoEntries); ok { - // attachment doesn't exist - return nil, gtserror.NewErrorNotFound(errors.New("attachment doesn't exist in the db")) - } - return nil, gtserror.NewErrorNotFound(fmt.Errorf("db error getting attachment: %s", err)) - } - - if attachment.AccountID != authed.Account.ID { - return nil, gtserror.NewErrorNotFound(errors.New("attachment not owned by requesting account")) - } - - if form.Description != nil { - attachment.Description = *form.Description - if err := p.db.UpdateByID(mediaAttachmentID, attachment); err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("database error updating description: %s", err)) - } - } - - if form.Focus != nil { - focusx, focusy, err := parseFocus(*form.Focus) - if err != nil { - return nil, gtserror.NewErrorBadRequest(err) - } - attachment.FileMeta.Focus.X = focusx - attachment.FileMeta.Focus.Y = focusy - if err := p.db.UpdateByID(mediaAttachmentID, attachment); err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("database error updating focus: %s", err)) - } - } - - a, err := p.tc.AttachmentToMasto(attachment) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("error converting attachment: %s", err)) - } - - return &a, nil + return p.mediaProcessor.Update(authed.Account, mediaAttachmentID, form) } func (p *processor) FileGet(authed *oauth.Auth, form *apimodel.GetContentRequestForm) (*apimodel.Content, error) { - // parse the form fields - mediaSize, err := media.ParseMediaSize(form.MediaSize) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("media size %s not valid", form.MediaSize)) - } - - mediaType, err := media.ParseMediaType(form.MediaType) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("media type %s not valid", form.MediaType)) - } - - spl := strings.Split(form.FileName, ".") - if len(spl) != 2 || spl[0] == "" || spl[1] == "" { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("file name %s not parseable", form.FileName)) - } - wantedMediaID := spl[0] - - // get the account that owns the media and make sure it's not suspended - acct := &gtsmodel.Account{} - if err := p.db.GetByID(form.AccountID, acct); err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("account with id %s could not be selected from the db: %s", form.AccountID, err)) - } - if !acct.SuspendedAt.IsZero() { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("account with id %s is suspended", form.AccountID)) - } - - // make sure the requesting account and the media account don't block each other - if authed.Account != nil { - blocked, err := p.db.Blocked(authed.Account.ID, form.AccountID) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("block status could not be established between accounts %s and %s: %s", form.AccountID, authed.Account.ID, err)) - } - if blocked { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("block exists between accounts %s and %s", form.AccountID, authed.Account.ID)) - } - } - - // the way we store emojis is a little different from the way we store other attachments, - // so we need to take different steps depending on the media type being requested - content := &apimodel.Content{} - var storagePath string - switch mediaType { - case media.Emoji: - e := &gtsmodel.Emoji{} - if err := p.db.GetByID(wantedMediaID, e); err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("emoji %s could not be taken from the db: %s", wantedMediaID, err)) - } - if e.Disabled { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("emoji %s has been disabled", wantedMediaID)) - } - switch mediaSize { - case media.Original: - content.ContentType = e.ImageContentType - storagePath = e.ImagePath - case media.Static: - content.ContentType = e.ImageStaticContentType - storagePath = e.ImageStaticPath - default: - return nil, gtserror.NewErrorNotFound(fmt.Errorf("media size %s not recognized for emoji", mediaSize)) - } - case media.Attachment, media.Header, media.Avatar: - a := &gtsmodel.MediaAttachment{} - if err := p.db.GetByID(wantedMediaID, a); err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("attachment %s could not be taken from the db: %s", wantedMediaID, err)) - } - if a.AccountID != form.AccountID { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("attachment %s is not owned by %s", wantedMediaID, form.AccountID)) - } - switch mediaSize { - case media.Original: - content.ContentType = a.File.ContentType - storagePath = a.File.Path - case media.Small: - content.ContentType = a.Thumbnail.ContentType - storagePath = a.Thumbnail.Path - default: - return nil, gtserror.NewErrorNotFound(fmt.Errorf("media size %s not recognized for attachment", mediaSize)) - } - } - - bytes, err := p.storage.RetrieveFileFrom(storagePath) - if err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("error retrieving from storage: %s", err)) - } - - content.ContentLength = int64(len(bytes)) - content.Content = bytes - return content, nil -} - -func parseFocus(focus string) (focusx, focusy float32, err error) { - if focus == "" { - return - } - spl := strings.Split(focus, ",") - if len(spl) != 2 { - err = fmt.Errorf("improperly formatted focus %s", focus) - return - } - xStr := spl[0] - yStr := spl[1] - if xStr == "" || yStr == "" { - err = fmt.Errorf("improperly formatted focus %s", focus) - return - } - fx, err := strconv.ParseFloat(xStr, 32) - if err != nil { - err = fmt.Errorf("improperly formatted focus %s: %s", focus, err) - return - } - if fx > 1 || fx < -1 { - err = fmt.Errorf("improperly formatted focus %s", focus) - return - } - focusx = float32(fx) - fy, err := strconv.ParseFloat(yStr, 32) - if err != nil { - err = fmt.Errorf("improperly formatted focus %s: %s", focus, err) - return - } - if fy > 1 || fy < -1 { - err = fmt.Errorf("improperly formatted focus %s", focus) - return - } - focusy = float32(fy) - return + return p.mediaProcessor.GetFile(authed.Account, form) } diff --git a/internal/processing/media/create.go b/internal/processing/media/create.go @@ -0,0 +1,79 @@ +/* + 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 <http://www.gnu.org/licenses/>. +*/ + +package media + +import ( + "bytes" + "errors" + "fmt" + "io" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +func (p *processor) Create(account *gtsmodel.Account, form *apimodel.AttachmentRequest) (*apimodel.Attachment, error) { + // open the attachment and extract the bytes from it + f, err := form.File.Open() + if err != nil { + return nil, fmt.Errorf("error opening attachment: %s", err) + } + buf := new(bytes.Buffer) + size, err := io.Copy(buf, f) + if err != nil { + return nil, fmt.Errorf("error reading attachment: %s", err) + } + if size == 0 { + return nil, errors.New("could not read provided attachment: size 0 bytes") + } + + // allow the mediaHandler to work its magic of processing the attachment bytes, and putting them in whatever storage backend we're using + attachment, err := p.mediaHandler.ProcessAttachment(buf.Bytes(), account.ID, "") + if err != nil { + return nil, fmt.Errorf("error reading attachment: %s", err) + } + + // now we need to add extra fields that the attachment processor doesn't know (from the form) + // TODO: handle this inside mediaHandler.ProcessAttachment (just pass more params to it) + + // first description + attachment.Description = form.Description + + // now parse the focus parameter + focusx, focusy, err := parseFocus(form.Focus) + if err != nil { + return nil, err + } + attachment.FileMeta.Focus.X = focusx + attachment.FileMeta.Focus.Y = focusy + + // prepare the frontend representation now -- if there are any errors here at least we can bail without + // having already put something in the database and then having to clean it up again (eugh) + mastoAttachment, err := p.tc.AttachmentToMasto(attachment) + if err != nil { + return nil, fmt.Errorf("error parsing media attachment to frontend type: %s", err) + } + + // now we can confidently put the attachment in the database + if err := p.db.Put(attachment); err != nil { + return nil, fmt.Errorf("error storing media attachment in db: %s", err) + } + + return &mastoAttachment, nil +} diff --git a/internal/processing/media/delete.go b/internal/processing/media/delete.go @@ -0,0 +1,51 @@ +package media + +import ( + "fmt" + "strings" + + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +func (p *processor) Delete(mediaAttachmentID string) gtserror.WithCode { + a := &gtsmodel.MediaAttachment{} + if err := p.db.GetByID(mediaAttachmentID, a); err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + // attachment already gone + return nil + } + // actual error + return gtserror.NewErrorInternalError(err) + } + + errs := []string{} + + // delete the thumbnail from storage + if a.Thumbnail.Path != "" { + if err := p.storage.RemoveFileAt(a.Thumbnail.Path); err != nil { + errs = append(errs, fmt.Sprintf("remove thumbnail at path %s: %s", a.Thumbnail.Path, err)) + } + } + + // delete the file from storage + if a.File.Path != "" { + if err := p.storage.RemoveFileAt(a.File.Path); err != nil { + errs = append(errs, fmt.Sprintf("remove file at path %s: %s", a.File.Path, err)) + } + } + + // delete the attachment + if err := p.db.DeleteByID(mediaAttachmentID, a); err != nil { + if _, ok := err.(db.ErrNoEntries); !ok { + errs = append(errs, fmt.Sprintf("remove attachment: %s", err)) + } + } + + if len(errs) != 0 { + return gtserror.NewErrorInternalError(fmt.Errorf("Delete: one or more errors removing attachment with id %s: %s", mediaAttachmentID, strings.Join(errs, "; "))) + } + + return nil +} diff --git a/internal/processing/media/getfile.go b/internal/processing/media/getfile.go @@ -0,0 +1,120 @@ +/* + 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 <http://www.gnu.org/licenses/>. +*/ + +package media + +import ( + "fmt" + "strings" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/media" +) + +func (p *processor) GetFile(account *gtsmodel.Account, form *apimodel.GetContentRequestForm) (*apimodel.Content, error) { + // parse the form fields + mediaSize, err := media.ParseMediaSize(form.MediaSize) + if err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("media size %s not valid", form.MediaSize)) + } + + mediaType, err := media.ParseMediaType(form.MediaType) + if err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("media type %s not valid", form.MediaType)) + } + + spl := strings.Split(form.FileName, ".") + if len(spl) != 2 || spl[0] == "" || spl[1] == "" { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("file name %s not parseable", form.FileName)) + } + wantedMediaID := spl[0] + + // get the account that owns the media and make sure it's not suspended + acct := &gtsmodel.Account{} + if err := p.db.GetByID(form.AccountID, acct); err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("account with id %s could not be selected from the db: %s", form.AccountID, err)) + } + if !acct.SuspendedAt.IsZero() { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("account with id %s is suspended", form.AccountID)) + } + + // make sure the requesting account and the media account don't block each other + if account != nil { + blocked, err := p.db.Blocked(account.ID, form.AccountID) + if err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("block status could not be established between accounts %s and %s: %s", form.AccountID, account.ID, err)) + } + if blocked { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("block exists between accounts %s and %s", form.AccountID, account.ID)) + } + } + + // the way we store emojis is a little different from the way we store other attachments, + // so we need to take different steps depending on the media type being requested + content := &apimodel.Content{} + var storagePath string + switch mediaType { + case media.Emoji: + e := &gtsmodel.Emoji{} + if err := p.db.GetByID(wantedMediaID, e); err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("emoji %s could not be taken from the db: %s", wantedMediaID, err)) + } + if e.Disabled { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("emoji %s has been disabled", wantedMediaID)) + } + switch mediaSize { + case media.Original: + content.ContentType = e.ImageContentType + storagePath = e.ImagePath + case media.Static: + content.ContentType = e.ImageStaticContentType + storagePath = e.ImageStaticPath + default: + return nil, gtserror.NewErrorNotFound(fmt.Errorf("media size %s not recognized for emoji", mediaSize)) + } + case media.Attachment, media.Header, media.Avatar: + a := &gtsmodel.MediaAttachment{} + if err := p.db.GetByID(wantedMediaID, a); err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("attachment %s could not be taken from the db: %s", wantedMediaID, err)) + } + if a.AccountID != form.AccountID { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("attachment %s is not owned by %s", wantedMediaID, form.AccountID)) + } + switch mediaSize { + case media.Original: + content.ContentType = a.File.ContentType + storagePath = a.File.Path + case media.Small: + content.ContentType = a.Thumbnail.ContentType + storagePath = a.Thumbnail.Path + default: + return nil, gtserror.NewErrorNotFound(fmt.Errorf("media size %s not recognized for attachment", mediaSize)) + } + } + + bytes, err := p.storage.RetrieveFileFrom(storagePath) + if err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error retrieving from storage: %s", err)) + } + + content.ContentLength = int64(len(bytes)) + content.Content = bytes + return content, nil +} diff --git a/internal/processing/media/getmedia.go b/internal/processing/media/getmedia.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 <http://www.gnu.org/licenses/>. +*/ + +package media + +import ( + "errors" + "fmt" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +func (p *processor) GetMedia(account *gtsmodel.Account, mediaAttachmentID string) (*apimodel.Attachment, gtserror.WithCode) { + attachment := &gtsmodel.MediaAttachment{} + if err := p.db.GetByID(mediaAttachmentID, attachment); err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + // attachment doesn't exist + return nil, gtserror.NewErrorNotFound(errors.New("attachment doesn't exist in the db")) + } + return nil, gtserror.NewErrorNotFound(fmt.Errorf("db error getting attachment: %s", err)) + } + + if attachment.AccountID != account.ID { + return nil, gtserror.NewErrorNotFound(errors.New("attachment not owned by requesting account")) + } + + a, err := p.tc.AttachmentToMasto(attachment) + if err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error converting attachment: %s", err)) + } + + return &a, nil +} diff --git a/internal/processing/media/media.go b/internal/processing/media/media.go @@ -0,0 +1,63 @@ +/* + 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 <http://www.gnu.org/licenses/>. +*/ + +package media + +import ( + "github.com/sirupsen/logrus" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/blob" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" +) + +// Processor wraps a bunch of functions for processing media actions. +type Processor interface { + // Create creates a new media attachment belonging to the given account, using the request form. + Create(account *gtsmodel.Account, form *apimodel.AttachmentRequest) (*apimodel.Attachment, error) + // Delete deletes the media attachment with the given ID, including all files pertaining to that attachment. + Delete(mediaAttachmentID string) gtserror.WithCode + GetFile(account *gtsmodel.Account, form *apimodel.GetContentRequestForm) (*apimodel.Content, error) + GetMedia(account *gtsmodel.Account, mediaAttachmentID string) (*apimodel.Attachment, gtserror.WithCode) + Update(account *gtsmodel.Account, mediaAttachmentID string, form *apimodel.AttachmentUpdateRequest) (*apimodel.Attachment, gtserror.WithCode) +} + +type processor struct { + tc typeutils.TypeConverter + config *config.Config + mediaHandler media.Handler + storage blob.Storage + db db.DB + log *logrus.Logger +} + +// New returns a new media processor. +func New(db db.DB, tc typeutils.TypeConverter, mediaHandler media.Handler, storage blob.Storage, config *config.Config, log *logrus.Logger) Processor { + return &processor{ + tc: tc, + config: config, + mediaHandler: mediaHandler, + storage: storage, + db: db, + log: log, + } +} diff --git a/internal/processing/media/update.go b/internal/processing/media/update.go @@ -0,0 +1,70 @@ +/* + 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 <http://www.gnu.org/licenses/>. +*/ + +package media + +import ( + "errors" + "fmt" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +func (p *processor) Update(account *gtsmodel.Account, mediaAttachmentID string, form *apimodel.AttachmentUpdateRequest) (*apimodel.Attachment, gtserror.WithCode) { + attachment := &gtsmodel.MediaAttachment{} + if err := p.db.GetByID(mediaAttachmentID, attachment); err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + // attachment doesn't exist + return nil, gtserror.NewErrorNotFound(errors.New("attachment doesn't exist in the db")) + } + return nil, gtserror.NewErrorNotFound(fmt.Errorf("db error getting attachment: %s", err)) + } + + if attachment.AccountID != account.ID { + return nil, gtserror.NewErrorNotFound(errors.New("attachment not owned by requesting account")) + } + + if form.Description != nil { + attachment.Description = *form.Description + if err := p.db.UpdateByID(mediaAttachmentID, attachment); err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("database error updating description: %s", err)) + } + } + + if form.Focus != nil { + focusx, focusy, err := parseFocus(*form.Focus) + if err != nil { + return nil, gtserror.NewErrorBadRequest(err) + } + attachment.FileMeta.Focus.X = focusx + attachment.FileMeta.Focus.Y = focusy + if err := p.db.UpdateByID(mediaAttachmentID, attachment); err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("database error updating focus: %s", err)) + } + } + + a, err := p.tc.AttachmentToMasto(attachment) + if err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error converting attachment: %s", err)) + } + + return &a, nil +} diff --git a/internal/processing/media/util.go b/internal/processing/media/util.go @@ -0,0 +1,63 @@ +/* + 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 <http://www.gnu.org/licenses/>. +*/ + +package media + +import ( + "fmt" + "strconv" + "strings" +) + +func parseFocus(focus string) (focusx, focusy float32, err error) { + if focus == "" { + return + } + spl := strings.Split(focus, ",") + if len(spl) != 2 { + err = fmt.Errorf("improperly formatted focus %s", focus) + return + } + xStr := spl[0] + yStr := spl[1] + if xStr == "" || yStr == "" { + err = fmt.Errorf("improperly formatted focus %s", focus) + return + } + fx, err := strconv.ParseFloat(xStr, 32) + if err != nil { + err = fmt.Errorf("improperly formatted focus %s: %s", focus, err) + return + } + if fx > 1 || fx < -1 { + err = fmt.Errorf("improperly formatted focus %s", focus) + return + } + focusx = float32(fx) + fy, err := strconv.ParseFloat(yStr, 32) + if err != nil { + err = fmt.Errorf("improperly formatted focus %s: %s", focus, err) + return + } + if fy > 1 || fy < -1 { + err = fmt.Errorf("improperly formatted focus %s", focus) + return + } + focusy = float32(fy) + return +} diff --git a/internal/processing/processor.go b/internal/processing/processor.go @@ -21,6 +21,7 @@ package processing import ( "context" "net/http" + "net/url" "github.com/sirupsen/logrus" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" @@ -32,8 +33,11 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/processing/synchronous/status" - "github.com/superseriousbusiness/gotosocial/internal/processing/synchronous/streaming" + "github.com/superseriousbusiness/gotosocial/internal/processing/account" + "github.com/superseriousbusiness/gotosocial/internal/processing/admin" + mediaProcessor "github.com/superseriousbusiness/gotosocial/internal/processing/media" + "github.com/superseriousbusiness/gotosocial/internal/processing/status" + "github.com/superseriousbusiness/gotosocial/internal/processing/streaming" "github.com/superseriousbusiness/gotosocial/internal/timeline" "github.com/superseriousbusiness/gotosocial/internal/typeutils" "github.com/superseriousbusiness/gotosocial/internal/visibility" @@ -81,6 +85,14 @@ type Processor interface { // AdminEmojiCreate handles the creation of a new instance emoji by an admin, using the given form. AdminEmojiCreate(authed *oauth.Auth, form *apimodel.EmojiCreateRequest) (*apimodel.Emoji, error) + // AdminDomainBlockCreate handles the creation of a new domain block by an admin, using the given form. + AdminDomainBlockCreate(authed *oauth.Auth, form *apimodel.DomainBlockCreateRequest) (*apimodel.DomainBlock, gtserror.WithCode) + // AdminDomainBlocksGet returns a list of currently blocked domains. + AdminDomainBlocksGet(authed *oauth.Auth, export bool) ([]*apimodel.DomainBlock, gtserror.WithCode) + // AdminDomainBlockGet returns one domain block, specified by ID. + AdminDomainBlockGet(authed *oauth.Auth, id string, export bool) (*apimodel.DomainBlock, gtserror.WithCode) + // AdminDomainBlockDelete deletes one domain block, specified by ID, returning the deleted domain block. + AdminDomainBlockDelete(authed *oauth.Auth, id string) (*apimodel.DomainBlock, gtserror.WithCode) // AppCreate processes the creation of a new API application AppCreate(authed *oauth.Auth, form *apimodel.ApplicationCreateRequest) (*apimodel.Application, error) @@ -154,22 +166,22 @@ type Processor interface { // GetFediUser handles the getting of a fedi/activitypub representation of a user/account, performing appropriate authentication // before returning a JSON serializable interface to the caller. - GetFediUser(requestedUsername string, request *http.Request) (interface{}, gtserror.WithCode) + GetFediUser(ctx context.Context, requestedUsername string, requestURL *url.URL) (interface{}, gtserror.WithCode) // GetFediFollowers handles the getting of a fedi/activitypub representation of a user/account's followers, performing appropriate // authentication before returning a JSON serializable interface to the caller. - GetFediFollowers(requestedUsername string, request *http.Request) (interface{}, gtserror.WithCode) + GetFediFollowers(ctx context.Context, requestedUsername string, requestURL *url.URL) (interface{}, gtserror.WithCode) // GetFediFollowing handles the getting of a fedi/activitypub representation of a user/account's following, performing appropriate // authentication before returning a JSON serializable interface to the caller. - GetFediFollowing(requestedUsername string, request *http.Request) (interface{}, gtserror.WithCode) + GetFediFollowing(ctx context.Context, requestedUsername string, requestURL *url.URL) (interface{}, gtserror.WithCode) // GetFediStatus handles the getting of a fedi/activitypub representation of a particular status, performing appropriate // authentication before returning a JSON serializable interface to the caller. - GetFediStatus(requestedUsername string, requestedStatusID string, request *http.Request) (interface{}, gtserror.WithCode) + GetFediStatus(ctx context.Context, requestedUsername string, requestedStatusID string, requestURL *url.URL) (interface{}, gtserror.WithCode) // 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.WellKnownResponse, gtserror.WithCode) + GetWebfingerAccount(ctx context.Context, requestedUsername string, requestURL *url.URL) (*apimodel.WellKnownResponse, gtserror.WithCode) // GetNodeInfoRel returns a well known response giving the path to node info. GetNodeInfoRel(request *http.Request) (*apimodel.WellKnownResponse, gtserror.WithCode) @@ -210,8 +222,11 @@ type processor struct { SUB-PROCESSORS */ + accountProcessor account.Processor + adminProcessor admin.Processor statusProcessor status.Processor streamingProcessor streaming.Processor + mediaProcessor mediaProcessor.Processor } // NewProcessor returns a new Processor that uses the given federator and logger @@ -222,6 +237,9 @@ func NewProcessor(config *config.Config, tc typeutils.TypeConverter, federator f statusProcessor := status.New(db, tc, config, fromClientAPI, log) streamingProcessor := streaming.New(db, tc, oauthServer, config, log) + accountProcessor := account.New(db, tc, mediaHandler, oauthServer, fromClientAPI, federator, config, log) + adminProcessor := admin.New(db, tc, mediaHandler, fromClientAPI, config, log) + mediaProcessor := mediaProcessor.New(db, tc, mediaHandler, storage, config, log) return &processor{ fromClientAPI: fromClientAPI, @@ -238,8 +256,11 @@ func NewProcessor(config *config.Config, tc typeutils.TypeConverter, federator f db: db, filter: visibility.NewFilter(db, log), + accountProcessor: accountProcessor, + adminProcessor: adminProcessor, statusProcessor: statusProcessor, streamingProcessor: streamingProcessor, + mediaProcessor: mediaProcessor, } } @@ -251,14 +272,18 @@ func (p *processor) Start() error { select { 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) - } + go func() { + if err := p.processFromClientAPI(clientMsg); err != nil { + p.log.Error(err) + } + }() case federatorMsg := <-p.fromFederator: p.log.Infof("received message FROM federator: %+v", federatorMsg) - if err := p.processFromFederator(federatorMsg); err != nil { - p.log.Error(err) - } + go func() { + if err := p.processFromFederator(federatorMsg); err != nil { + p.log.Error(err) + } + }() case <-p.stop: break DistLoop } diff --git a/internal/processing/search.go b/internal/processing/search.go @@ -176,7 +176,7 @@ func (p *processor) searchStatusByURI(authed *oauth.Auth, uri *url.URL, resolve } // properly dereference everything in the status (media attachments etc) - if err := p.dereferenceStatusFields(status, authed.Account.Username); err != nil { + if err := p.federator.DereferenceStatusFields(status, authed.Account.Username); err != nil { return nil, fmt.Errorf("error dereferencing status fields: %s", err) } @@ -223,7 +223,7 @@ func (p *processor) searchAccountByURI(authed *oauth.Auth, uri *url.URL, resolve return nil, fmt.Errorf("searchAccountByURI: error inserting account with uri %s: %s", uri.String(), err) } - if err := p.dereferenceAccountFields(account, authed.Account.Username, false); err != nil { + if err := p.federator.DereferenceAccountFields(account, authed.Account.Username, false); err != nil { return nil, fmt.Errorf("searchAccountByURI: error further dereferencing account with uri %s: %s", uri.String(), err) } @@ -301,7 +301,7 @@ func (p *processor) searchAccountByMention(authed *oauth.Auth, mention string, r } // properly dereference all the fields on the account immediately - if err := p.dereferenceAccountFields(foundAccount, authed.Account.Username, true); err != nil { + if err := p.federator.DereferenceAccountFields(foundAccount, authed.Account.Username, true); err != nil { return nil, fmt.Errorf("searchAccountByMention: error dereferencing fields on account with uri %s: %s", acctURI.String(), err) } } diff --git a/internal/processing/synchronous/status/boost.go b/internal/processing/status/boost.go diff --git a/internal/processing/synchronous/status/boostedby.go b/internal/processing/status/boostedby.go diff --git a/internal/processing/synchronous/status/context.go b/internal/processing/status/context.go diff --git a/internal/processing/synchronous/status/create.go b/internal/processing/status/create.go diff --git a/internal/processing/status/delete.go b/internal/processing/status/delete.go @@ -0,0 +1,56 @@ +package status + +import ( + "errors" + "fmt" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +func (p *processor) Delete(account *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) { + l := p.log.WithField("func", "StatusDelete") + l.Tracef("going to search for target status %s", targetStatusID) + targetStatus := &gtsmodel.Status{} + if err := p.db.GetByID(targetStatusID, targetStatus); err != nil { + if _, ok := err.(db.ErrNoEntries); !ok { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching status %s: %s", targetStatusID, err)) + } + // status is already gone + return nil, nil + } + + if targetStatus.AccountID != account.ID { + return nil, gtserror.NewErrorForbidden(errors.New("status doesn't belong to requesting account")) + } + + var boostOfStatus *gtsmodel.Status + if targetStatus.BoostOfID != "" { + boostOfStatus = &gtsmodel.Status{} + if err := p.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err)) + } + } + + mastoStatus, err := p.tc.StatusToMasto(targetStatus, account) + if err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)) + } + + if err := p.db.DeleteByID(targetStatus.ID, &gtsmodel.Status{}); err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error deleting status from the database: %s", err)) + } + + // send it back to the processor for async processing + p.fromClientAPI <- gtsmodel.FromClientAPI{ + APObjectType: gtsmodel.ActivityStreamsNote, + APActivityType: gtsmodel.ActivityStreamsDelete, + GTSModel: targetStatus, + OriginAccount: account, + TargetAccount: account, + } + + return mastoStatus, nil +} diff --git a/internal/processing/synchronous/status/fave.go b/internal/processing/status/fave.go diff --git a/internal/processing/synchronous/status/favedby.go b/internal/processing/status/favedby.go diff --git a/internal/processing/synchronous/status/get.go b/internal/processing/status/get.go diff --git a/internal/processing/synchronous/status/status.go b/internal/processing/status/status.go diff --git a/internal/processing/synchronous/status/unboost.go b/internal/processing/status/unboost.go diff --git a/internal/processing/synchronous/status/unfave.go b/internal/processing/status/unfave.go diff --git a/internal/processing/synchronous/status/util.go b/internal/processing/status/util.go diff --git a/internal/processing/synchronous/streaming/authorize.go b/internal/processing/streaming/authorize.go diff --git a/internal/processing/synchronous/streaming/openstream.go b/internal/processing/streaming/openstream.go diff --git a/internal/processing/synchronous/streaming/streamdelete.go b/internal/processing/streaming/streamdelete.go diff --git a/internal/processing/synchronous/streaming/streaming.go b/internal/processing/streaming/streaming.go diff --git a/internal/processing/synchronous/streaming/streamnotification.go b/internal/processing/streaming/streamnotification.go diff --git a/internal/processing/synchronous/streaming/streamstatus.go b/internal/processing/streaming/streamstatus.go diff --git a/internal/processing/synchronous/status/delete.go b/internal/processing/synchronous/status/delete.go @@ -1,55 +0,0 @@ -package status - -import ( - "errors" - "fmt" - - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" -) - -func (p *processor) Delete(account *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) { - l := p.log.WithField("func", "StatusDelete") - l.Tracef("going to search for target status %s", targetStatusID) - targetStatus := &gtsmodel.Status{} - if err := p.db.GetByID(targetStatusID, targetStatus); err != nil { - if _, ok := err.(db.ErrNoEntries); !ok { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching status %s: %s", targetStatusID, err)) - } - // status is already gone - return nil, nil - } - - if targetStatus.AccountID != account.ID { - return nil, gtserror.NewErrorForbidden(errors.New("status doesn't belong to requesting account")) - } - - var boostOfStatus *gtsmodel.Status - if targetStatus.BoostOfID != "" { - boostOfStatus = &gtsmodel.Status{} - if err := p.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { - return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err)) - } - } - - mastoStatus, err := p.tc.StatusToMasto(targetStatus, account) - if err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)) - } - - if err := p.db.DeleteByID(targetStatus.ID, &gtsmodel.Status{}); err != nil { - return nil, gtserror.NewErrorInternalError(fmt.Errorf("error deleting status from the database: %s", err)) - } - - // send it back to the processor for async processing - p.fromClientAPI <- gtsmodel.FromClientAPI{ - APObjectType: gtsmodel.ActivityStreamsNote, - APActivityType: gtsmodel.ActivityStreamsDelete, - GTSModel: targetStatus, - OriginAccount: account, - } - - return mastoStatus, nil -} diff --git a/internal/processing/util.go b/internal/processing/util.go @@ -1,135 +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 <http://www.gnu.org/licenses/>. -*/ - -package processing - -import ( - "bytes" - "errors" - "fmt" - "io" - "mime/multipart" - - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/transport" -) - -/* - HELPER FUNCTIONS -*/ - -// TODO: try to combine the below two functions because this is a lot of code repetition. - -// updateAccountAvatar does the dirty work of checking the avatar part of an account update form, -// parsing and checking the image, and doing the necessary updates in the database for this to become -// the account's new avatar image. -func (p *processor) updateAccountAvatar(avatar *multipart.FileHeader, accountID string) (*gtsmodel.MediaAttachment, error) { - var err error - if int(avatar.Size) > p.config.MediaConfig.MaxImageSize { - err = fmt.Errorf("avatar with size %d exceeded max image size of %d bytes", avatar.Size, p.config.MediaConfig.MaxImageSize) - return nil, err - } - f, err := avatar.Open() - if err != nil { - return nil, fmt.Errorf("could not read provided avatar: %s", err) - } - - // extract the bytes - buf := new(bytes.Buffer) - size, err := io.Copy(buf, f) - if err != nil { - return nil, fmt.Errorf("could not read provided avatar: %s", err) - } - if size == 0 { - return nil, errors.New("could not read provided avatar: size 0 bytes") - } - - // do the setting - avatarInfo, err := p.mediaHandler.ProcessHeaderOrAvatar(buf.Bytes(), accountID, media.Avatar, "") - if err != nil { - return nil, fmt.Errorf("error processing avatar: %s", err) - } - - return avatarInfo, f.Close() -} - -// updateAccountHeader does the dirty work of checking the header part of an account update form, -// parsing and checking the image, and doing the necessary updates in the database for this to become -// the account's new header image. -func (p *processor) updateAccountHeader(header *multipart.FileHeader, accountID string) (*gtsmodel.MediaAttachment, error) { - var err error - if int(header.Size) > p.config.MediaConfig.MaxImageSize { - err = fmt.Errorf("header with size %d exceeded max image size of %d bytes", header.Size, p.config.MediaConfig.MaxImageSize) - return nil, err - } - f, err := header.Open() - if err != nil { - return nil, fmt.Errorf("could not read provided header: %s", err) - } - - // extract the bytes - buf := new(bytes.Buffer) - size, err := io.Copy(buf, f) - if err != nil { - return nil, fmt.Errorf("could not read provided header: %s", err) - } - if size == 0 { - return nil, errors.New("could not read provided header: size 0 bytes") - } - - // do the setting - headerInfo, err := p.mediaHandler.ProcessHeaderOrAvatar(buf.Bytes(), accountID, media.Header, "") - if err != nil { - return nil, fmt.Errorf("error processing header: %s", err) - } - - return headerInfo, f.Close() -} - -// fetchHeaderAndAviForAccount fetches the header and avatar for a remote account, using a transport -// on behalf of requestingUsername. -// -// targetAccount's AvatarMediaAttachmentID and HeaderMediaAttachmentID will be updated as necessary. -// -// SIDE EFFECTS: remote header and avatar will be stored in local storage, and the database will be updated -// to reflect the creation of these new attachments. -func (p *processor) fetchHeaderAndAviForAccount(targetAccount *gtsmodel.Account, t transport.Transport, refresh bool) error { - if targetAccount.AvatarRemoteURL != "" && (targetAccount.AvatarMediaAttachmentID == "" || refresh) { - a, err := p.mediaHandler.ProcessRemoteHeaderOrAvatar(t, &gtsmodel.MediaAttachment{ - RemoteURL: targetAccount.AvatarRemoteURL, - Avatar: true, - }, targetAccount.ID) - if err != nil { - return fmt.Errorf("error processing avatar for user: %s", err) - } - targetAccount.AvatarMediaAttachmentID = a.ID - } - - if targetAccount.HeaderRemoteURL != "" && (targetAccount.HeaderMediaAttachmentID == "" || refresh) { - a, err := p.mediaHandler.ProcessRemoteHeaderOrAvatar(t, &gtsmodel.MediaAttachment{ - RemoteURL: targetAccount.HeaderRemoteURL, - Header: true, - }, targetAccount.ID) - if err != nil { - return fmt.Errorf("error processing header for user: %s", err) - } - targetAccount.HeaderMediaAttachmentID = a.ID - } - return nil -} diff --git a/internal/typeutils/converter.go b/internal/typeutils/converter.go @@ -76,6 +76,8 @@ type TypeConverter interface { RelationshipToMasto(r *gtsmodel.Relationship) (*model.Relationship, error) // NotificationToMasto converts a gts notification into a mastodon notification NotificationToMasto(n *gtsmodel.Notification) (*model.Notification, error) + // DomainBlockTomasto converts a gts model domin block into a mastodon domain block, for serving at /api/v1/admin/domain_blocks + DomainBlockToMasto(b *gtsmodel.DomainBlock, export bool) (*model.DomainBlock, error) /* FRONTEND (mastodon) MODEL TO INTERNAL (gts) MODEL diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go @@ -644,3 +644,23 @@ func (c *converter) NotificationToMasto(n *gtsmodel.Notification) (*model.Notifi Status: mastoStatus, }, nil } + +func (c *converter) DomainBlockToMasto(b *gtsmodel.DomainBlock, export bool) (*model.DomainBlock, error) { + + domainBlock := &model.DomainBlock{ + Domain: b.Domain, + PublicComment: b.PublicComment, + } + + // if we're exporting a domain block, return it with minimal information attached + if !export { + domainBlock.ID = b.ID + domainBlock.Obfuscate = b.Obfuscate + domainBlock.PrivateComment = b.PrivateComment + domainBlock.SubscriptionID = b.SubscriptionID + domainBlock.CreatedBy = b.CreatedByAccountID + domainBlock.CreatedAt = b.CreatedAt.Format(time.RFC3339) + } + + return domainBlock, nil +} diff --git a/internal/util/uri.go b/internal/util/uri.go @@ -66,8 +66,8 @@ const ( // 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" + // APRequestingPublicKeyVerifier can be used to set and retrieve the public key verifier of an incoming federation request. + APRequestingPublicKeyVerifier APContextKey = "requestingPublicKeyVerifier" // APFromFederatorChanKey can be used to pass a pointer to the fromFederator channel into the federator for use in callbacks. APFromFederatorChanKey APContextKey = "fromFederatorChan" ) diff --git a/internal/visibility/statusvisible.go b/internal/visibility/statusvisible.go @@ -19,9 +19,20 @@ func (f *filter) StatusVisible(targetStatus *gtsmodel.Status, requestingAccount relevantAccounts, err := f.pullRelevantAccountsFromStatus(targetStatus) if err != nil { l.Debugf("error pulling relevant accounts for status %s: %s", targetStatus.ID, err) + return false, fmt.Errorf("error pulling relevant accounts for status %s: %s", targetStatus.ID, err) + } + + domainBlocked, err := f.domainBlockedRelevant(relevantAccounts) + if err != nil { + l.Debugf("error checking domain block: %s", err) + return false, fmt.Errorf("error checking domain block: %s", err) } - targetAccount := relevantAccounts.StatusAuthor + if domainBlocked { + return false, nil + } + + targetAccount := relevantAccounts.StatusAuthor // if target account is suspended then don't show the status if !targetAccount.SuspendedAt.IsZero() { l.Trace("target account suspended at is not zero") @@ -123,8 +134,8 @@ func (f *filter) StatusVisible(targetStatus *gtsmodel.Status, requestingAccount } // status boosts accounts id - if relevantAccounts.BoostedAccount != nil { - if blocked, err := f.db.Blocked(relevantAccounts.BoostedAccount.ID, requestingAccount.ID); err != nil { + if relevantAccounts.BoostedStatusAuthor != nil { + if blocked, err := f.db.Blocked(relevantAccounts.BoostedStatusAuthor.ID, requestingAccount.ID); err != nil { return false, err } else if blocked { l.Trace("a block exists between requesting account and boosted account") @@ -152,6 +163,16 @@ func (f *filter) StatusVisible(targetStatus *gtsmodel.Status, requestingAccount } } + // boost mentions accounts + for _, a := range relevantAccounts.BoostedMentionedAccounts { + if blocked, err := f.db.Blocked(a.ID, requestingAccount.ID); err != nil { + return false, err + } else if blocked { + l.Trace("a block exists between requesting account and a boosted mentioned account") + return false, nil + } + } + // if the requesting account is mentioned in the status it should always be visible for _, acct := range relevantAccounts.MentionedAccounts { if acct.ID == requestingAccount.ID { diff --git a/internal/visibility/util.go b/internal/visibility/util.go @@ -3,12 +3,14 @@ package visibility import ( "fmt" + "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" ) func (f *filter) pullRelevantAccountsFromStatus(targetStatus *gtsmodel.Status) (*relevantAccounts, error) { accounts := &relevantAccounts{ - MentionedAccounts: []*gtsmodel.Account{}, + MentionedAccounts: []*gtsmodel.Account{}, + BoostedMentionedAccounts: []*gtsmodel.Account{}, } // get the author account @@ -30,6 +32,21 @@ func (f *filter) pullRelevantAccountsFromStatus(targetStatus *gtsmodel.Status) ( accounts.ReplyToAccount = repliedToAccount } + // now get all accounts with IDs that are mentioned in the status + for _, mentionID := range targetStatus.Mentions { + + mention := &gtsmodel.Mention{} + if err := f.db.GetByID(mentionID, mention); err != nil { + return accounts, fmt.Errorf("PullRelevantAccountsFromStatus: error getting mention with id %s: %s", mentionID, err) + } + + mentionedAccount := &gtsmodel.Account{} + if err := f.db.GetByID(mention.TargetAccountID, mentionedAccount); err != nil { + return accounts, fmt.Errorf("PullRelevantAccountsFromStatus: error getting mentioned account: %s", err) + } + accounts.MentionedAccounts = append(accounts.MentionedAccounts, mentionedAccount) + } + // get the boosted account from the status and add it to the pile if targetStatus.BoostOfID != "" { // retrieve the boosted status first @@ -41,7 +58,7 @@ func (f *filter) pullRelevantAccountsFromStatus(targetStatus *gtsmodel.Status) ( if err := f.db.GetByID(boostedStatus.AccountID, boostedAccount); err != nil { return accounts, fmt.Errorf("PullRelevantAccountsFromStatus: error getting boostedAccount with id %s: %s", boostedStatus.AccountID, err) } - accounts.BoostedAccount = boostedAccount + accounts.BoostedStatusAuthor = boostedAccount // the boosted status might be a reply to another account so we should get that too if boostedStatus.InReplyToAccountID != "" { @@ -51,21 +68,20 @@ func (f *filter) pullRelevantAccountsFromStatus(targetStatus *gtsmodel.Status) ( } accounts.BoostedReplyToAccount = boostedStatusRepliedToAccount } - } - // now get all accounts with IDs that are mentioned in the status - for _, mentionID := range targetStatus.Mentions { - - mention := &gtsmodel.Mention{} - if err := f.db.GetByID(mentionID, mention); err != nil { - return accounts, fmt.Errorf("PullRelevantAccountsFromStatus: error getting mention with id %s: %s", mentionID, err) - } + // now get all accounts with IDs that are mentioned in the status + for _, mentionID := range boostedStatus.Mentions { + mention := &gtsmodel.Mention{} + if err := f.db.GetByID(mentionID, mention); err != nil { + return accounts, fmt.Errorf("PullRelevantAccountsFromStatus: error getting boosted mention with id %s: %s", mentionID, err) + } - mentionedAccount := &gtsmodel.Account{} - if err := f.db.GetByID(mention.TargetAccountID, mentionedAccount); err != nil { - return accounts, fmt.Errorf("PullRelevantAccountsFromStatus: error getting mentioned account: %s", err) + mentionedAccount := &gtsmodel.Account{} + if err := f.db.GetByID(mention.TargetAccountID, mentionedAccount); err != nil { + return accounts, fmt.Errorf("PullRelevantAccountsFromStatus: error getting boosted mentioned account: %s", err) + } + accounts.BoostedMentionedAccounts = append(accounts.BoostedMentionedAccounts, mentionedAccount) } - accounts.MentionedAccounts = append(accounts.MentionedAccounts, mentionedAccount) } return accounts, nil @@ -73,9 +89,103 @@ func (f *filter) pullRelevantAccountsFromStatus(targetStatus *gtsmodel.Status) ( // relevantAccounts denotes accounts that are replied to, boosted by, or mentioned in a status. type relevantAccounts struct { - StatusAuthor *gtsmodel.Account - ReplyToAccount *gtsmodel.Account - BoostedAccount *gtsmodel.Account + // Who wrote the status + StatusAuthor *gtsmodel.Account + // Who is the status replying to + ReplyToAccount *gtsmodel.Account + // Which accounts are mentioned (tagged) in the status + MentionedAccounts []*gtsmodel.Account + // Who authed the boosted status + BoostedStatusAuthor *gtsmodel.Account + // If the boosted status replies to another account, who does it reply to? BoostedReplyToAccount *gtsmodel.Account - MentionedAccounts []*gtsmodel.Account + // Who is mentioned (tagged) in the boosted status + BoostedMentionedAccounts []*gtsmodel.Account +} + +// blockedDomain checks whether the given domain is blocked by us or not +func (f *filter) blockedDomain(host string) (bool, error) { + b := &gtsmodel.DomainBlock{} + err := f.db.GetWhere([]db.Where{{Key: "domain", Value: host, CaseInsensitive: true}}, b) + if err == nil { + // block exists + return true, nil + } + + if _, ok := err.(db.ErrNoEntries); ok { + // there are no entries so there's no block + return false, nil + } + + // there's an actual error + return false, err +} + +// domainBlockedRelevant checks through all relevant accounts attached to a status +// to make sure none of them are domain blocked by this instance. +// +// Will return true+nil if there's a block, false+nil if there's no block, or +// an error if something goes wrong. +func (f *filter) domainBlockedRelevant(r *relevantAccounts) (bool, error) { + if r.StatusAuthor != nil { + b, err := f.blockedDomain(r.StatusAuthor.Domain) + if err != nil { + return false, err + } + if b { + return true, nil + } + } + + if r.ReplyToAccount != nil { + b, err := f.blockedDomain(r.ReplyToAccount.Domain) + if err != nil { + return false, err + } + if b { + return true, nil + } + } + + for _, a := range r.MentionedAccounts { + b, err := f.blockedDomain(a.Domain) + if err != nil { + return false, err + } + if b { + return true, nil + } + } + + if r.BoostedStatusAuthor != nil { + b, err := f.blockedDomain(r.BoostedStatusAuthor.Domain) + if err != nil { + return false, err + } + if b { + return true, nil + } + } + + if r.BoostedReplyToAccount != nil { + b, err := f.blockedDomain(r.BoostedReplyToAccount.Domain) + if err != nil { + return false, err + } + if b { + return true, nil + } + } + + for _, a := range r.BoostedMentionedAccounts { + b, err := f.blockedDomain(a.Domain) + if err != nil { + return false, err + } + if b { + return true, nil + } + } + + return false, nil } diff --git a/testrig/federator.go b/testrig/federator.go @@ -19,12 +19,13 @@ package testrig import ( + "github.com/superseriousbusiness/gotosocial/internal/blob" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/federation" "github.com/superseriousbusiness/gotosocial/internal/transport" ) // NewTestFederator returns a federator with the given database and (mock!!) transport controller. -func NewTestFederator(db db.DB, tc transport.Controller) federation.Federator { - return federation.NewFederator(db, NewTestFederatingDB(db), tc, NewTestConfig(), NewTestLog(), NewTestTypeConverter(db)) +func NewTestFederator(db db.DB, tc transport.Controller, storage blob.Storage) federation.Federator { + return federation.NewFederator(db, NewTestFederatingDB(db), tc, NewTestConfig(), NewTestLog(), NewTestTypeConverter(db), NewTestMediaHandler(db, storage)) }