From 6f5c045284d34ba580d3007f70b97e05d6760527 Mon Sep 17 00:00:00 2001 From: Tobi Smethurst <31960611+tsmethurst@users.noreply.github.com> Date: Sat, 8 May 2021 14:25:55 +0200 Subject: [PATCH] Ap (#14) Big restructuring and initial work on activitypub --- go.mod | 1 + internal/{apimodule => api}/apimodule.go | 16 +- .../client}/account/account.go | 50 +- internal/api/client/account/account_test.go | 40 ++ .../client}/account/accountcreate.go | 58 +- .../api/client/account/accountcreate_test.go | 388 ++++++++++++ .../client}/account/accountget.go | 23 +- internal/api/client/account/accountupdate.go | 71 +++ .../api/client/account/accountupdate_test.go | 106 ++++ .../client}/account/accountverify.go | 10 +- .../client/account}/accountverify_test.go | 2 +- .../{apimodule => api/client}/admin/admin.go | 50 +- .../client}/admin/emojicreate.go | 50 +- internal/{apimodule => api/client}/app/app.go | 43 +- .../app/test => api/client/app}/app_test.go | 2 +- internal/api/client/app/appcreate.go | 79 +++ .../{apimodule => api/client}/auth/auth.go | 35 +- .../test => api/client/auth}/auth_test.go | 6 +- .../client}/auth/authorize.go | 6 +- .../client}/auth/middleware.go | 4 +- .../{apimodule => api/client}/auth/signin.go | 2 +- .../{apimodule => api/client}/auth/token.go | 0 .../client}/fileserver/fileserver.go | 16 +- internal/api/client/fileserver/servefile.go | 94 +++ .../client/fileserver}/servefile_test.go | 36 +- .../{apimodule => api/client}/media/media.go | 25 +- internal/api/client/media/mediacreate.go | 91 +++ .../client/media}/mediacreate_test.go | 44 +- .../client}/status/status.go | 78 +-- internal/api/client/status/status_test.go | 58 ++ internal/api/client/status/statuscreate.go | 130 ++++ .../client/status}/statuscreate_test.go | 107 +--- internal/api/client/status/statusdelete.go | 60 ++ internal/api/client/status/statusfave.go | 60 ++ .../client/status}/statusfave_test.go | 89 +-- internal/api/client/status/statusfavedby.go | 60 ++ .../client/status}/statusfavedby_test.go | 79 +-- internal/api/client/status/statusget.go | 60 ++ .../client/status}/statusget_test.go | 91 +-- internal/api/client/status/statusunfave.go | 60 ++ .../client/status}/statusunfave_test.go | 89 +-- .../mastomodel => api/model}/account.go | 9 +- .../mastomodel => api/model}/activity.go | 2 +- .../mastomodel => api/model}/admin.go | 2 +- .../mastomodel => api/model}/announcement.go | 2 +- .../model}/announcementreaction.go | 2 +- .../mastomodel => api/model}/application.go | 6 +- .../mastomodel => api/model}/attachment.go | 2 +- .../mastomodel => api/model}/card.go | 2 +- internal/api/model/content.go | 41 ++ .../mastomodel => api/model}/context.go | 2 +- .../mastomodel => api/model}/conversation.go | 2 +- .../mastomodel => api/model}/emoji.go | 2 +- .../mastomodel => api/model}/error.go | 2 +- .../mastomodel => api/model}/featuredtag.go | 2 +- .../mastomodel => api/model}/field.go | 2 +- .../mastomodel => api/model}/filter.go | 2 +- .../mastomodel => api/model}/history.go | 2 +- .../mastomodel => api/model}/identityproof.go | 2 +- .../mastomodel => api/model}/instance.go | 2 +- .../mastomodel => api/model}/list.go | 2 +- .../mastomodel => api/model}/marker.go | 2 +- .../mastomodel => api/model}/mention.go | 2 +- .../mastomodel => api/model}/notification.go | 2 +- .../mastomodel => api/model}/oauth.go | 2 +- .../mastomodel => api/model}/poll.go | 2 +- .../mastomodel => api/model}/preferences.go | 2 +- .../model}/pushsubscription.go | 2 +- .../mastomodel => api/model}/relationship.go | 2 +- .../mastomodel => api/model}/results.go | 2 +- .../model}/scheduledstatus.go | 2 +- .../mastomodel => api/model}/source.go | 2 +- .../mastomodel => api/model}/status.go | 20 +- .../mastomodel => api/model}/tag.go | 2 +- .../mastomodel => api/model}/token.go | 2 +- internal/api/s2s/user/user.go | 70 +++ internal/api/s2s/user/user_test.go | 40 ++ internal/api/s2s/user/userget.go | 67 +++ internal/api/s2s/user/userget_test.go | 155 +++++ .../{apimodule => api}/security/flocblock.go | 0 .../{apimodule => api}/security/security.go | 10 +- internal/apimodule/account/accountupdate.go | 260 -------- .../account/test/accountcreate_test.go | 551 ----------------- .../account/test/accountupdate_test.go | 303 ---------- internal/apimodule/app/appcreate.go | 119 ---- internal/apimodule/fileserver/servefile.go | 243 -------- internal/apimodule/media/mediacreate.go | 193 ------ internal/apimodule/mock_ClientAPIModule.go | 43 -- internal/apimodule/status/statuscreate.go | 462 --------------- internal/apimodule/status/statusdelete.go | 107 ---- internal/apimodule/status/statusfave.go | 137 ----- internal/apimodule/status/statusfavedby.go | 129 ---- internal/apimodule/status/statusget.go | 112 ---- internal/apimodule/status/statusunfave.go | 137 ----- internal/cache/mock_Cache.go | 47 -- internal/config/mock_KeyedFlags.go | 66 --- internal/db/db.go | 25 +- internal/db/federating_db.go | 119 +++- internal/db/mock_DB.go | 484 --------------- internal/db/pg.go | 43 +- internal/db/pg_test.go | 2 +- internal/distributor/distributor.go | 110 ---- internal/distributor/mock_Distributor.go | 70 --- .../federation/clock.go | 26 +- internal/federation/commonbehavior.go | 152 +++++ internal/federation/federatingactor.go | 136 +++++ .../{federation.go => federatingprotocol.go} | 218 +++---- internal/federation/federator.go | 79 +++ internal/federation/federator_test.go | 190 ++++++ internal/federation/util.go | 237 ++++++++ internal/gotosocial/actions.go | 64 +- internal/gotosocial/gotosocial.go | 23 +- internal/gotosocial/mock_Gotosocial.go | 42 -- internal/{db => }/gtsmodel/README.md | 0 internal/{db => }/gtsmodel/account.go | 20 +- internal/{db => }/gtsmodel/activitystreams.go | 0 internal/{db => }/gtsmodel/application.go | 0 internal/{db => }/gtsmodel/block.go | 0 internal/{db => }/gtsmodel/domainblock.go | 0 .../{db => }/gtsmodel/emaildomainblock.go | 0 internal/{db => }/gtsmodel/emoji.go | 2 + internal/{db => }/gtsmodel/follow.go | 0 internal/{db => }/gtsmodel/followrequest.go | 0 internal/{db => }/gtsmodel/mediaattachment.go | 10 +- internal/{db => }/gtsmodel/mention.go | 0 internal/{db => }/gtsmodel/poll.go | 0 internal/{db => }/gtsmodel/status.go | 0 internal/{db => }/gtsmodel/statusbookmark.go | 0 internal/{db => }/gtsmodel/statusfave.go | 0 internal/{db => }/gtsmodel/statusmute.go | 0 internal/{db => }/gtsmodel/statuspin.go | 0 internal/{db => }/gtsmodel/tag.go | 0 internal/{db => }/gtsmodel/user.go | 0 internal/mastotypes/mastomodel/README.md | 5 - internal/mastotypes/mock_Converter.go | 148 ----- internal/media/media.go | 148 ++--- internal/media/media_test.go | 4 +- internal/media/mock_MediaHandler.go | 2 +- internal/media/util.go | 88 ++- internal/media/util_test.go | 4 +- internal/message/accountprocess.go | 168 ++++++ internal/message/adminprocess.go | 48 ++ internal/message/appprocess.go | 59 ++ internal/message/error.go | 106 ++++ internal/message/fediprocess.go | 102 ++++ internal/message/mediaprocess.go | 188 ++++++ internal/message/processor.go | 215 +++++++ internal/message/processorutil.go | 304 ++++++++++ internal/message/statusprocess.go | 350 +++++++++++ internal/oauth/clientstore.go | 3 +- internal/oauth/clientstore_test.go | 13 +- internal/oauth/oauth_test.go | 2 +- internal/oauth/server.go | 178 ++---- internal/oauth/tokenstore_test.go | 2 +- internal/oauth/util.go | 86 +++ internal/storage/inmem.go | 2 +- internal/transport/controller.go | 71 +++ internal/typeutils/accountable.go | 101 ++++ internal/typeutils/asextractionutil.go | 216 +++++++ internal/typeutils/astointernal.go | 164 +++++ internal/typeutils/astointernal_test.go | 206 +++++++ internal/typeutils/converter.go | 113 ++++ internal/typeutils/converter_test.go | 40 ++ internal/typeutils/frontendtointernal.go | 39 ++ internal/typeutils/internaltoas.go | 260 ++++++++ internal/typeutils/internaltoas_test.go | 76 +++ .../internaltofrontend.go} | 147 ++--- internal/util/parse.go | 96 --- internal/util/regexes.go | 79 ++- internal/util/{status.go => statustools.go} | 20 +- .../{status_test.go => statustools_test.go} | 11 +- internal/util/uri.go | 218 +++++++ internal/util/validation.go | 49 +- internal/util/validation_test.go | 81 +-- testrig/actions.go | 71 +-- testrig/db.go | 4 +- testrig/federator.go | 29 + testrig/media/test-jpeg.jpg | Bin 0 -> 269739 bytes testrig/processor.go | 31 + testrig/testmodels.go | 558 ++++++++++++++++-- testrig/transportcontroller.go | 73 +++ .../{mastoconverter.go => typeconverter.go} | 8 +- testrig/util.go | 11 + 183 files changed, 7391 insertions(+), 5414 deletions(-) rename internal/{apimodule => api}/apimodule.go (65%) rename internal/{apimodule => api/client}/account/account.go (62%) create mode 100644 internal/api/client/account/account_test.go rename internal/{apimodule => api/client}/account/accountcreate.go (59%) create mode 100644 internal/api/client/account/accountcreate_test.go rename internal/{apimodule => api/client}/account/accountget.go (69%) create mode 100644 internal/api/client/account/accountupdate.go create mode 100644 internal/api/client/account/accountupdate_test.go rename internal/{apimodule => api/client}/account/accountverify.go (75%) rename internal/{apimodule/account/test => api/client/account}/accountverify_test.go (97%) rename internal/{apimodule => api/client}/admin/admin.go (52%) rename internal/{apimodule => api/client}/admin/emojicreate.go (60%) rename internal/{apimodule => api/client}/app/app.go (54%) rename internal/{apimodule/app/test => api/client/app}/app_test.go (97%) create mode 100644 internal/api/client/app/appcreate.go rename internal/{apimodule => api/client}/auth/auth.go (74%) rename internal/{apimodule/auth/test => api/client/auth}/auth_test.go (96%) rename internal/{apimodule => api/client}/auth/authorize.go (97%) rename internal/{apimodule => api/client}/auth/middleware.go (96%) rename internal/{apimodule => api/client}/auth/signin.go (98%) rename internal/{apimodule => api/client}/auth/token.go (100%) rename internal/{apimodule => api/client}/fileserver/fileserver.go (85%) create mode 100644 internal/api/client/fileserver/servefile.go rename internal/{apimodule/fileserver/test => api/client/fileserver}/servefile_test.go (80%) rename internal/{apimodule => api/client}/media/media.go (71%) create mode 100644 internal/api/client/media/mediacreate.go rename internal/{apimodule/media/test => api/client/media}/mediacreate_test.go (82%) rename internal/{apimodule => api/client}/status/status.go (62%) create mode 100644 internal/api/client/status/status_test.go create mode 100644 internal/api/client/status/statuscreate.go rename internal/{apimodule/status/test => api/client/status}/statuscreate_test.go (79%) create mode 100644 internal/api/client/status/statusdelete.go create mode 100644 internal/api/client/status/statusfave.go rename internal/{apimodule/status/test => api/client/status}/statusfave_test.go (67%) create mode 100644 internal/api/client/status/statusfavedby.go rename internal/{apimodule/status/test => api/client/status}/statusfavedby_test.go (62%) create mode 100644 internal/api/client/status/statusget.go rename internal/{apimodule/status/test => api/client/status}/statusget_test.go (62%) create mode 100644 internal/api/client/status/statusunfave.go rename internal/{apimodule/status/test => api/client/status}/statusunfave_test.go (70%) rename internal/{mastotypes/mastomodel => api/model}/account.go (97%) rename internal/{mastotypes/mastomodel => api/model}/activity.go (98%) rename internal/{mastotypes/mastomodel => api/model}/admin.go (99%) rename internal/{mastotypes/mastomodel => api/model}/announcement.go (98%) rename internal/{mastotypes/mastomodel => api/model}/announcementreaction.go (98%) rename internal/{mastotypes/mastomodel => api/model}/application.go (94%) rename internal/{mastotypes/mastomodel => api/model}/attachment.go (99%) rename internal/{mastotypes/mastomodel => api/model}/card.go (99%) create mode 100644 internal/api/model/content.go rename internal/{mastotypes/mastomodel => api/model}/context.go (98%) rename internal/{mastotypes/mastomodel => api/model}/conversation.go (98%) rename internal/{mastotypes/mastomodel => api/model}/emoji.go (98%) rename internal/{mastotypes/mastomodel => api/model}/error.go (98%) rename internal/{mastotypes/mastomodel => api/model}/featuredtag.go (98%) rename internal/{mastotypes/mastomodel => api/model}/field.go (98%) rename internal/{mastotypes/mastomodel => api/model}/filter.go (99%) rename internal/{mastotypes/mastomodel => api/model}/history.go (98%) rename internal/{mastotypes/mastomodel => api/model}/identityproof.go (98%) rename internal/{mastotypes/mastomodel => api/model}/instance.go (99%) rename internal/{mastotypes/mastomodel => api/model}/list.go (98%) rename internal/{mastotypes/mastomodel => api/model}/marker.go (98%) rename internal/{mastotypes/mastomodel => api/model}/mention.go (98%) rename internal/{mastotypes/mastomodel => api/model}/notification.go (98%) rename internal/{mastotypes/mastomodel => api/model}/oauth.go (98%) rename internal/{mastotypes/mastomodel => api/model}/poll.go (99%) rename internal/{mastotypes/mastomodel => api/model}/preferences.go (98%) rename internal/{mastotypes/mastomodel => api/model}/pushsubscription.go (99%) rename internal/{mastotypes/mastomodel => api/model}/relationship.go (99%) rename internal/{mastotypes/mastomodel => api/model}/results.go (98%) rename internal/{mastotypes/mastomodel => api/model}/scheduledstatus.go (98%) rename internal/{mastotypes/mastomodel => api/model}/source.go (98%) rename internal/{mastotypes/mastomodel => api/model}/status.go (90%) rename internal/{mastotypes/mastomodel => api/model}/tag.go (98%) rename internal/{mastotypes/mastomodel => api/model}/token.go (98%) create mode 100644 internal/api/s2s/user/user.go create mode 100644 internal/api/s2s/user/user_test.go create mode 100644 internal/api/s2s/user/userget.go create mode 100644 internal/api/s2s/user/userget_test.go rename internal/{apimodule => api}/security/flocblock.go (100%) rename internal/{apimodule => api}/security/security.go (78%) delete mode 100644 internal/apimodule/account/accountupdate.go delete mode 100644 internal/apimodule/account/test/accountcreate_test.go delete mode 100644 internal/apimodule/account/test/accountupdate_test.go delete mode 100644 internal/apimodule/app/appcreate.go delete mode 100644 internal/apimodule/fileserver/servefile.go delete mode 100644 internal/apimodule/media/mediacreate.go delete mode 100644 internal/apimodule/mock_ClientAPIModule.go delete mode 100644 internal/apimodule/status/statuscreate.go delete mode 100644 internal/apimodule/status/statusdelete.go delete mode 100644 internal/apimodule/status/statusfave.go delete mode 100644 internal/apimodule/status/statusfavedby.go delete mode 100644 internal/apimodule/status/statusget.go delete mode 100644 internal/apimodule/status/statusunfave.go delete mode 100644 internal/cache/mock_Cache.go delete mode 100644 internal/config/mock_KeyedFlags.go delete mode 100644 internal/db/mock_DB.go delete mode 100644 internal/distributor/distributor.go delete mode 100644 internal/distributor/mock_Distributor.go rename testrig/distributor.go => internal/federation/clock.go (69%) create mode 100644 internal/federation/commonbehavior.go create mode 100644 internal/federation/federatingactor.go rename internal/federation/{federation.go => federatingprotocol.go} (55%) create mode 100644 internal/federation/federator.go create mode 100644 internal/federation/federator_test.go create mode 100644 internal/federation/util.go delete mode 100644 internal/gotosocial/mock_Gotosocial.go rename internal/{db => }/gtsmodel/README.md (100%) rename internal/{db => }/gtsmodel/account.go (90%) rename internal/{db => }/gtsmodel/activitystreams.go (100%) rename internal/{db => }/gtsmodel/application.go (100%) rename internal/{db => }/gtsmodel/block.go (100%) rename internal/{db => }/gtsmodel/domainblock.go (100%) rename internal/{db => }/gtsmodel/emaildomainblock.go (100%) rename internal/{db => }/gtsmodel/emoji.go (97%) rename internal/{db => }/gtsmodel/follow.go (100%) rename internal/{db => }/gtsmodel/followrequest.go (100%) rename internal/{db => }/gtsmodel/mediaattachment.go (96%) rename internal/{db => }/gtsmodel/mention.go (100%) rename internal/{db => }/gtsmodel/poll.go (100%) rename internal/{db => }/gtsmodel/status.go (100%) rename internal/{db => }/gtsmodel/statusbookmark.go (100%) rename internal/{db => }/gtsmodel/statusfave.go (100%) rename internal/{db => }/gtsmodel/statusmute.go (100%) rename internal/{db => }/gtsmodel/statuspin.go (100%) rename internal/{db => }/gtsmodel/tag.go (100%) rename internal/{db => }/gtsmodel/user.go (100%) delete mode 100644 internal/mastotypes/mastomodel/README.md delete mode 100644 internal/mastotypes/mock_Converter.go create mode 100644 internal/message/accountprocess.go create mode 100644 internal/message/adminprocess.go create mode 100644 internal/message/appprocess.go create mode 100644 internal/message/error.go create mode 100644 internal/message/fediprocess.go create mode 100644 internal/message/mediaprocess.go create mode 100644 internal/message/processor.go create mode 100644 internal/message/processorutil.go create mode 100644 internal/message/statusprocess.go create mode 100644 internal/oauth/util.go create mode 100644 internal/transport/controller.go create mode 100644 internal/typeutils/accountable.go create mode 100644 internal/typeutils/asextractionutil.go create mode 100644 internal/typeutils/astointernal.go create mode 100644 internal/typeutils/astointernal_test.go create mode 100644 internal/typeutils/converter.go create mode 100644 internal/typeutils/converter_test.go create mode 100644 internal/typeutils/frontendtointernal.go create mode 100644 internal/typeutils/internaltoas.go create mode 100644 internal/typeutils/internaltoas_test.go rename internal/{mastotypes/converter.go => typeutils/internaltofrontend.go} (73%) delete mode 100644 internal/util/parse.go rename internal/util/{status.go => statustools.go} (84%) rename internal/util/{status_test.go => statustools_test.go} (91%) create mode 100644 internal/util/uri.go create mode 100644 testrig/federator.go create mode 100644 testrig/media/test-jpeg.jpg create mode 100644 testrig/processor.go create mode 100644 testrig/transportcontroller.go rename testrig/{mastoconverter.go => typeconverter.go} (75%) diff --git a/go.mod b/go.mod index 07edd0a..d1cefcf 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/gin-contrib/sessions v0.0.3 github.com/gin-gonic/gin v1.6.3 github.com/go-fed/activity v1.0.0 + github.com/go-fed/httpsig v0.1.1-0.20190914113940-c2de3672e5b5 github.com/go-pg/pg/extra/pgdebug v0.2.0 github.com/go-pg/pg/v10 v10.8.0 github.com/golang/mock v1.4.4 // indirect diff --git a/internal/apimodule/apimodule.go b/internal/api/apimodule.go similarity index 65% rename from internal/apimodule/apimodule.go rename to internal/api/apimodule.go index 6d7dbdb..d0bcc61 100644 --- a/internal/apimodule/apimodule.go +++ b/internal/api/apimodule.go @@ -16,18 +16,22 @@ along with this program. If not, see . */ -// Package apimodule is basically a wrapper for a lot of modules (in subdirectories) that satisfy the ClientAPIModule interface. -package apimodule +package api import ( - "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/router" ) -// ClientAPIModule represents a chunk of code (usually contained in a single package) that adds a set +// ClientModule represents a chunk of code (usually contained in a single package) that adds a set // of functionalities and/or side effects to a router, by mapping routes and/or middlewares onto it--in other words, a REST API ;) // A ClientAPIMpdule with routes corresponds roughly to one main path of the gotosocial REST api, for example /api/v1/accounts/ or /oauth/ -type ClientAPIModule interface { +type ClientModule interface { + Route(s router.Router) error +} + +// FederationModule represents a chunk of code (usually contained in a single package) that adds a set +// of functionalities and/or side effects to a router, by mapping routes and/or middlewares onto it--in other words, a REST API ;) +// Unlike ClientAPIModule, federation API module is not intended to be interacted with by clients directly -- it is primarily a server-to-server interface. +type FederationModule interface { Route(s router.Router) error - CreateTables(db db.DB) error } diff --git a/internal/apimodule/account/account.go b/internal/api/client/account/account.go similarity index 62% rename from internal/apimodule/account/account.go rename to internal/api/client/account/account.go index a836afc..dce8102 100644 --- a/internal/apimodule/account/account.go +++ b/internal/api/client/account/account.go @@ -19,20 +19,15 @@ package account import ( - "fmt" "net/http" "strings" "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" - "github.com/superseriousbusiness/gotosocial/internal/apimodule" + "github.com/superseriousbusiness/gotosocial/internal/api" "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/mastotypes" + "github.com/superseriousbusiness/gotosocial/internal/message" - "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/router" ) @@ -51,23 +46,17 @@ const ( // Module implements the ClientAPIModule interface for account-related actions type Module struct { - config *config.Config - db db.DB - oauthServer oauth.Server - mediaHandler media.Handler - mastoConverter mastotypes.Converter - log *logrus.Logger + config *config.Config + processor message.Processor + log *logrus.Logger } // New returns a new account module -func New(config *config.Config, db db.DB, oauthServer oauth.Server, mediaHandler media.Handler, mastoConverter mastotypes.Converter, log *logrus.Logger) apimodule.ClientAPIModule { +func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule { return &Module{ - config: config, - db: db, - oauthServer: oauthServer, - mediaHandler: mediaHandler, - mastoConverter: mastoConverter, - log: log, + config: config, + processor: processor, + log: log, } } @@ -79,27 +68,6 @@ func (m *Module) Route(r router.Router) error { return nil } -// CreateTables creates the required tables for this module in the given database -func (m *Module) CreateTables(db db.DB) error { - models := []interface{}{ - >smodel.User{}, - >smodel.Account{}, - >smodel.Follow{}, - >smodel.FollowRequest{}, - >smodel.Status{}, - >smodel.Application{}, - >smodel.EmailDomainBlock{}, - >smodel.MediaAttachment{}, - } - - for _, m := range models { - if err := db.CreateTable(m); err != nil { - return fmt.Errorf("error creating table: %s", err) - } - } - return nil -} - func (m *Module) muxHandler(c *gin.Context) { ru := c.Request.RequestURI switch c.Request.Method { diff --git a/internal/api/client/account/account_test.go b/internal/api/client/account/account_test.go new file mode 100644 index 0000000..d0560bc --- /dev/null +++ b/internal/api/client/account/account_test.go @@ -0,0 +1,40 @@ +package account_test + +import ( + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/account" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/message" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/storage" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" +) + +// nolint +type AccountStandardTestSuite struct { + // standard suite interfaces + suite.Suite + config *config.Config + db db.DB + log *logrus.Logger + tc typeutils.TypeConverter + storage storage.Storage + federator federation.Federator + processor message.Processor + + // standard suite models + testTokens map[string]*oauth.Token + testClients map[string]*oauth.Client + testApplications map[string]*gtsmodel.Application + testUsers map[string]*gtsmodel.User + testAccounts map[string]*gtsmodel.Account + testAttachments map[string]*gtsmodel.MediaAttachment + testStatuses map[string]*gtsmodel.Status + + // module being tested + accountModule *account.Module +} diff --git a/internal/apimodule/account/accountcreate.go b/internal/api/client/account/accountcreate.go similarity index 59% rename from internal/apimodule/account/accountcreate.go rename to internal/api/client/account/accountcreate.go index fb21925..b53d8c4 100644 --- a/internal/apimodule/account/accountcreate.go +++ b/internal/api/client/account/accountcreate.go @@ -20,18 +20,14 @@ package account import ( "errors" - "fmt" "net" "net/http" "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" "github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/util" - "github.com/superseriousbusiness/oauth2/v4" ) // AccountCreatePOSTHandler handles create account requests, validates them, @@ -39,7 +35,7 @@ import ( // It should be served as a POST at /api/v1/accounts func (m *Module) AccountCreatePOSTHandler(c *gin.Context) { l := m.log.WithField("func", "accountCreatePOSTHandler") - authed, err := oauth.MustAuth(c, true, true, false, false) + authed, err := oauth.Authed(c, true, true, false, false) if err != nil { l.Debugf("couldn't auth: %s", err) c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) @@ -47,7 +43,7 @@ func (m *Module) AccountCreatePOSTHandler(c *gin.Context) { } l.Trace("parsing request form") - form := &mastotypes.AccountCreateRequest{} + form := &model.AccountCreateRequest{} if err := c.ShouldBind(form); err != nil || form == nil { l.Debugf("could not parse form from request: %s", err) c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"}) @@ -55,7 +51,7 @@ func (m *Module) AccountCreatePOSTHandler(c *gin.Context) { } l.Tracef("validating form %+v", form) - if err := validateCreateAccount(form, m.config.AccountsConfig, m.db); err != nil { + if err := validateCreateAccount(form, m.config.AccountsConfig); err != nil { l.Debugf("error validating form: %s", err) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -70,7 +66,9 @@ func (m *Module) AccountCreatePOSTHandler(c *gin.Context) { return } - ti, err := m.accountCreate(form, signUpIP, authed.Token, authed.Application) + form.IP = signUpIP + + ti, err := m.processor.AccountCreate(authed, form) if err != nil { l.Errorf("internal server error while creating new account: %s", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) @@ -80,41 +78,9 @@ func (m *Module) AccountCreatePOSTHandler(c *gin.Context) { c.JSON(http.StatusOK, ti) } -// 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 (m *Module) accountCreate(form *mastotypes.AccountCreateRequest, signUpIP net.IP, token oauth2.TokenInfo, app *gtsmodel.Application) (*mastotypes.Token, error) { - l := m.log.WithField("func", "accountCreate") - - // don't store a reason if we don't require one - reason := form.Reason - if !m.config.AccountsConfig.ReasonRequired { - reason = "" - } - - l.Trace("creating new username and account") - user, err := m.db.NewSignup(form.Username, reason, m.config.AccountsConfig.RequireApproval, form.Email, form.Password, signUpIP, form.Locale, app.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, app.ID) - accessToken, err := m.oauthServer.GenerateUserAccessToken(token, app.ClientSecret, user.ID) - if err != nil { - return nil, fmt.Errorf("error creating new access token for user %s: %s", user.ID, err) - } - - return &mastotypes.Token{ - AccessToken: accessToken.GetAccess(), - TokenType: "Bearer", - Scope: accessToken.GetScope(), - CreatedAt: accessToken.GetAccessCreateAt().Unix(), - }, nil -} - // validateCreateAccount checks through all the necessary prerequisites for creating a new account, // according to the provided account create request. If the account isn't eligible, an error will be returned. -func validateCreateAccount(form *mastotypes.AccountCreateRequest, c *config.AccountsConfig, database db.DB) error { +func validateCreateAccount(form *model.AccountCreateRequest, c *config.AccountsConfig) error { if !c.OpenRegistration { return errors.New("registration is not open for this server") } @@ -143,13 +109,5 @@ func validateCreateAccount(form *mastotypes.AccountCreateRequest, c *config.Acco return err } - if err := database.IsEmailAvailable(form.Email); err != nil { - return err - } - - if err := database.IsUsernameAvailable(form.Username); err != nil { - return err - } - return nil } diff --git a/internal/api/client/account/accountcreate_test.go b/internal/api/client/account/accountcreate_test.go new file mode 100644 index 0000000..da86ee9 --- /dev/null +++ b/internal/api/client/account/accountcreate_test.go @@ -0,0 +1,388 @@ +// /* +// GoToSocial +// Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. + +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// */ + +package account_test + +// import ( +// "bytes" +// "encoding/json" +// "fmt" +// "io" +// "io/ioutil" +// "mime/multipart" +// "net/http" +// "net/http/httptest" +// "os" +// "testing" + +// "github.com/gin-gonic/gin" +// "github.com/google/uuid" +// "github.com/stretchr/testify/assert" +// "github.com/stretchr/testify/suite" +// "github.com/superseriousbusiness/gotosocial/internal/api/client/account" +// "github.com/superseriousbusiness/gotosocial/internal/api/model" +// "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +// "github.com/superseriousbusiness/gotosocial/testrig" + +// "github.com/superseriousbusiness/gotosocial/internal/oauth" +// "golang.org/x/crypto/bcrypt" +// ) + +// type AccountCreateTestSuite struct { +// AccountStandardTestSuite +// } + +// func (suite *AccountCreateTestSuite) SetupSuite() { +// suite.testTokens = testrig.NewTestTokens() +// suite.testClients = testrig.NewTestClients() +// suite.testApplications = testrig.NewTestApplications() +// suite.testUsers = testrig.NewTestUsers() +// suite.testAccounts = testrig.NewTestAccounts() +// suite.testAttachments = testrig.NewTestAttachments() +// suite.testStatuses = testrig.NewTestStatuses() +// } + +// func (suite *AccountCreateTestSuite) SetupTest() { +// suite.config = testrig.NewTestConfig() +// suite.db = testrig.NewTestDB() +// suite.storage = testrig.NewTestStorage() +// suite.log = testrig.NewTestLog() +// suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil))) +// 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) +// testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") +// } + +// func (suite *AccountCreateTestSuite) TearDownTest() { +// testrig.StandardDBTeardown(suite.db) +// testrig.StandardStorageTeardown(suite.storage) +// } + +// // TestAccountCreatePOSTHandlerSuccessful checks the happy path for an account creation request: all the fields provided are valid, +// // and at the end of it a new user and account should be added into the database. +// // +// // This is the handler served at /api/v1/accounts as POST +// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerSuccessful() { + +// t := suite.testTokens["local_account_1"] +// oauthToken := oauth.TokenToOauthToken(t) + +// // setup +// recorder := httptest.NewRecorder() +// ctx, _ := gin.CreateTestContext(recorder) +// ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) +// ctx.Set(oauth.SessionAuthorizedToken, oauthToken) +// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting +// ctx.Request.Form = suite.newUserFormHappyPath +// suite.accountModule.AccountCreatePOSTHandler(ctx) + +// // check response + +// // 1. we should have OK from our call to the function +// suite.EqualValues(http.StatusOK, recorder.Code) + +// // 2. we should have a token in the result body +// result := recorder.Result() +// defer result.Body.Close() +// b, err := ioutil.ReadAll(result.Body) +// assert.NoError(suite.T(), err) +// t := &model.Token{} +// err = json.Unmarshal(b, t) +// assert.NoError(suite.T(), err) +// assert.Equal(suite.T(), "we're authorized now!", t.AccessToken) + +// // check new account + +// // 1. we should be able to get the new account from the db +// acct := >smodel.Account{} +// err = suite.db.GetLocalAccountByUsername("test_user", acct) +// assert.NoError(suite.T(), err) +// assert.NotNil(suite.T(), acct) +// // 2. reason should be set +// assert.Equal(suite.T(), suite.newUserFormHappyPath.Get("reason"), acct.Reason) +// // 3. display name should be equal to username by default +// assert.Equal(suite.T(), suite.newUserFormHappyPath.Get("username"), acct.DisplayName) +// // 4. domain should be nil because this is a local account +// assert.Nil(suite.T(), nil, acct.Domain) +// // 5. id should be set and parseable as a uuid +// assert.NotNil(suite.T(), acct.ID) +// _, err = uuid.Parse(acct.ID) +// assert.Nil(suite.T(), err) +// // 6. private and public key should be set +// assert.NotNil(suite.T(), acct.PrivateKey) +// assert.NotNil(suite.T(), acct.PublicKey) + +// // check new user + +// // 1. we should be able to get the new user from the db +// usr := >smodel.User{} +// err = suite.db.GetWhere("unconfirmed_email", suite.newUserFormHappyPath.Get("email"), usr) +// assert.Nil(suite.T(), err) +// assert.NotNil(suite.T(), usr) + +// // 2. user should have account id set to account we got above +// assert.Equal(suite.T(), acct.ID, usr.AccountID) + +// // 3. id should be set and parseable as a uuid +// assert.NotNil(suite.T(), usr.ID) +// _, err = uuid.Parse(usr.ID) +// assert.Nil(suite.T(), err) + +// // 4. locale should be equal to what we requested +// assert.Equal(suite.T(), suite.newUserFormHappyPath.Get("locale"), usr.Locale) + +// // 5. created by application id should be equal to the app id +// assert.Equal(suite.T(), suite.testApplication.ID, usr.CreatedByApplicationID) + +// // 6. password should be matcheable to what we set above +// err = bcrypt.CompareHashAndPassword([]byte(usr.EncryptedPassword), []byte(suite.newUserFormHappyPath.Get("password"))) +// assert.Nil(suite.T(), err) +// } + +// // TestAccountCreatePOSTHandlerNoAuth makes sure that the handler fails when no authorization is provided: +// // only registered applications can create accounts, and we don't provide one here. +// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerNoAuth() { + +// // setup +// recorder := httptest.NewRecorder() +// ctx, _ := gin.CreateTestContext(recorder) +// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting +// ctx.Request.Form = suite.newUserFormHappyPath +// suite.accountModule.AccountCreatePOSTHandler(ctx) + +// // check response + +// // 1. we should have forbidden from our call to the function because we didn't auth +// suite.EqualValues(http.StatusForbidden, recorder.Code) + +// // 2. we should have an error message in the result body +// result := recorder.Result() +// defer result.Body.Close() +// b, err := ioutil.ReadAll(result.Body) +// assert.NoError(suite.T(), err) +// assert.Equal(suite.T(), `{"error":"not authorized"}`, string(b)) +// } + +// // TestAccountCreatePOSTHandlerNoAuth makes sure that the handler fails when no form is provided at all. +// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerNoForm() { + +// // setup +// recorder := httptest.NewRecorder() +// ctx, _ := gin.CreateTestContext(recorder) +// ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication) +// ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) +// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting +// suite.accountModule.AccountCreatePOSTHandler(ctx) + +// // check response +// suite.EqualValues(http.StatusBadRequest, recorder.Code) + +// // 2. we should have an error message in the result body +// result := recorder.Result() +// defer result.Body.Close() +// b, err := ioutil.ReadAll(result.Body) +// assert.NoError(suite.T(), err) +// assert.Equal(suite.T(), `{"error":"missing one or more required form values"}`, string(b)) +// } + +// // TestAccountCreatePOSTHandlerWeakPassword makes sure that the handler fails when a weak password is provided +// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerWeakPassword() { + +// // setup +// recorder := httptest.NewRecorder() +// ctx, _ := gin.CreateTestContext(recorder) +// ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication) +// ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) +// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting +// ctx.Request.Form = suite.newUserFormHappyPath +// // set a weak password +// ctx.Request.Form.Set("password", "weak") +// suite.accountModule.AccountCreatePOSTHandler(ctx) + +// // check response +// suite.EqualValues(http.StatusBadRequest, recorder.Code) + +// // 2. we should have an error message in the result body +// result := recorder.Result() +// defer result.Body.Close() +// b, err := ioutil.ReadAll(result.Body) +// assert.NoError(suite.T(), err) +// assert.Equal(suite.T(), `{"error":"insecure password, try including more special characters, using uppercase letters, using numbers or using a longer password"}`, string(b)) +// } + +// // TestAccountCreatePOSTHandlerWeirdLocale makes sure that the handler fails when a weird locale is provided +// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerWeirdLocale() { + +// // setup +// recorder := httptest.NewRecorder() +// ctx, _ := gin.CreateTestContext(recorder) +// ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication) +// ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) +// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting +// ctx.Request.Form = suite.newUserFormHappyPath +// // set an invalid locale +// ctx.Request.Form.Set("locale", "neverneverland") +// suite.accountModule.AccountCreatePOSTHandler(ctx) + +// // check response +// suite.EqualValues(http.StatusBadRequest, recorder.Code) + +// // 2. we should have an error message in the result body +// result := recorder.Result() +// defer result.Body.Close() +// b, err := ioutil.ReadAll(result.Body) +// assert.NoError(suite.T(), err) +// assert.Equal(suite.T(), `{"error":"language: tag is not well-formed"}`, string(b)) +// } + +// // TestAccountCreatePOSTHandlerRegistrationsClosed makes sure that the handler fails when registrations are closed +// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerRegistrationsClosed() { + +// // setup +// recorder := httptest.NewRecorder() +// ctx, _ := gin.CreateTestContext(recorder) +// ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication) +// ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) +// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting +// ctx.Request.Form = suite.newUserFormHappyPath + +// // close registrations +// suite.config.AccountsConfig.OpenRegistration = false +// suite.accountModule.AccountCreatePOSTHandler(ctx) + +// // check response +// suite.EqualValues(http.StatusBadRequest, recorder.Code) + +// // 2. we should have an error message in the result body +// result := recorder.Result() +// defer result.Body.Close() +// b, err := ioutil.ReadAll(result.Body) +// assert.NoError(suite.T(), err) +// assert.Equal(suite.T(), `{"error":"registration is not open for this server"}`, string(b)) +// } + +// // TestAccountCreatePOSTHandlerReasonNotProvided makes sure that the handler fails when no reason is provided but one is required +// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerReasonNotProvided() { + +// // setup +// recorder := httptest.NewRecorder() +// ctx, _ := gin.CreateTestContext(recorder) +// ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication) +// ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) +// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting +// ctx.Request.Form = suite.newUserFormHappyPath + +// // remove reason +// ctx.Request.Form.Set("reason", "") + +// suite.accountModule.AccountCreatePOSTHandler(ctx) + +// // check response +// suite.EqualValues(http.StatusBadRequest, recorder.Code) + +// // 2. we should have an error message in the result body +// result := recorder.Result() +// defer result.Body.Close() +// b, err := ioutil.ReadAll(result.Body) +// assert.NoError(suite.T(), err) +// assert.Equal(suite.T(), `{"error":"no reason provided"}`, string(b)) +// } + +// // TestAccountCreatePOSTHandlerReasonNotProvided makes sure that the handler fails when a crappy reason is presented but a good one is required +// func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerInsufficientReason() { + +// // setup +// recorder := httptest.NewRecorder() +// ctx, _ := gin.CreateTestContext(recorder) +// ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication) +// ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) +// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting +// ctx.Request.Form = suite.newUserFormHappyPath + +// // remove reason +// ctx.Request.Form.Set("reason", "just cuz") + +// suite.accountModule.AccountCreatePOSTHandler(ctx) + +// // check response +// suite.EqualValues(http.StatusBadRequest, recorder.Code) + +// // 2. we should have an error message in the result body +// result := recorder.Result() +// defer result.Body.Close() +// b, err := ioutil.ReadAll(result.Body) +// assert.NoError(suite.T(), err) +// assert.Equal(suite.T(), `{"error":"reason should be at least 40 chars but 'just cuz' was 8"}`, string(b)) +// } + +// /* +// TESTING: AccountUpdateCredentialsPATCHHandler +// */ + +// func (suite *AccountCreateTestSuite) TestAccountUpdateCredentialsPATCHHandler() { + +// // put test local account in db +// err := suite.db.Put(suite.testAccountLocal) +// assert.NoError(suite.T(), err) + +// // attach avatar to request +// aviFile, err := os.Open("../../media/test/test-jpeg.jpg") +// assert.NoError(suite.T(), err) +// body := &bytes.Buffer{} +// writer := multipart.NewWriter(body) + +// part, err := writer.CreateFormFile("avatar", "test-jpeg.jpg") +// assert.NoError(suite.T(), err) + +// _, err = io.Copy(part, aviFile) +// assert.NoError(suite.T(), err) + +// err = aviFile.Close() +// assert.NoError(suite.T(), err) + +// err = writer.Close() +// assert.NoError(suite.T(), err) + +// // setup +// recorder := httptest.NewRecorder() +// ctx, _ := gin.CreateTestContext(recorder) +// ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccountLocal) +// ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) +// ctx.Request = httptest.NewRequest(http.MethodPatch, fmt.Sprintf("http://localhost:8080/%s", account.UpdateCredentialsPath), body) // the endpoint we're hitting +// ctx.Request.Header.Set("Content-Type", writer.FormDataContentType()) +// suite.accountModule.AccountUpdateCredentialsPATCHHandler(ctx) + +// // check response + +// // 1. we should have OK because our request was valid +// suite.EqualValues(http.StatusOK, recorder.Code) + +// // 2. we should have an error message in the result body +// result := recorder.Result() +// defer result.Body.Close() +// // TODO: implement proper checks here +// // +// // b, err := ioutil.ReadAll(result.Body) +// // assert.NoError(suite.T(), err) +// // assert.Equal(suite.T(), `{"error":"not authorized"}`, string(b)) +// } + +// func TestAccountCreateTestSuite(t *testing.T) { +// suite.Run(t, new(AccountCreateTestSuite)) +// } diff --git a/internal/apimodule/account/accountget.go b/internal/api/client/account/accountget.go similarity index 69% rename from internal/apimodule/account/accountget.go rename to internal/api/client/account/accountget.go index 5003be1..5ca17a1 100644 --- a/internal/apimodule/account/accountget.go +++ b/internal/api/client/account/accountget.go @@ -22,8 +22,7 @@ import ( "net/http" "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/oauth" ) // AccountGETHandler serves the account information held by the server in response to a GET @@ -31,25 +30,21 @@ import ( // // See: https://docs.joinmastodon.org/methods/accounts/ func (m *Module) AccountGETHandler(c *gin.Context) { + authed, err := oauth.Authed(c, false, false, false, false) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + targetAcctID := c.Param(IDKey) if targetAcctID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "no account id specified"}) return } - targetAccount := >smodel.Account{} - if err := m.db.GetByID(targetAcctID, targetAccount); err != nil { - if _, ok := err.(db.ErrNoEntries); ok { - c.JSON(http.StatusNotFound, gin.H{"error": "Record not found"}) - return - } - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - acctInfo, err := m.mastoConverter.AccountToMastoPublic(targetAccount) + acctInfo, err := m.processor.AccountGet(authed, targetAcctID) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } diff --git a/internal/api/client/account/accountupdate.go b/internal/api/client/account/accountupdate.go new file mode 100644 index 0000000..406769f --- /dev/null +++ b/internal/api/client/account/accountupdate.go @@ -0,0 +1,71 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package account + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// AccountUpdateCredentialsPATCHHandler allows a user to modify their account/profile settings. +// It should be served as a PATCH at /api/v1/accounts/update_credentials +// +// TODO: this can be optimized massively by building up a picture of what we want the new account +// details to be, and then inserting it all in the database at once. As it is, we do queries one-by-one +// which is not gonna make the database very happy when lots of requests are going through. +// This way it would also be safer because the update won't happen until *all* the fields are validated. +// Otherwise we risk doing a partial update and that's gonna cause probllleeemmmsss. +func (m *Module) AccountUpdateCredentialsPATCHHandler(c *gin.Context) { + l := m.log.WithField("func", "accountUpdateCredentialsPATCHHandler") + authed, err := oauth.Authed(c, true, false, false, true) + if err != nil { + l.Debugf("couldn't auth: %s", err) + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + l.Tracef("retrieved account %+v", authed.Account.ID) + + l.Trace("parsing request form") + form := &model.UpdateCredentialsRequest{} + if err := c.ShouldBind(form); err != nil || form == nil { + l.Debugf("could not parse form from request: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // if everything on the form is nil, then nothing has been set and we shouldn't continue + if form.Discoverable == nil && form.Bot == nil && form.DisplayName == nil && form.Note == nil && form.Avatar == nil && form.Header == nil && form.Locked == nil && form.Source == nil && form.FieldsAttributes == nil { + l.Debugf("could not parse form from request") + c.JSON(http.StatusBadRequest, gin.H{"error": "empty form submitted"}) + return + } + + acctSensitive, err := m.processor.AccountUpdate(authed, form) + if err != nil { + l.Debugf("could not update account: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + l.Tracef("conversion successful, returning OK and mastosensitive account %+v", acctSensitive) + c.JSON(http.StatusOK, acctSensitive) +} diff --git a/internal/api/client/account/accountupdate_test.go b/internal/api/client/account/accountupdate_test.go new file mode 100644 index 0000000..ba7faa7 --- /dev/null +++ b/internal/api/client/account/accountupdate_test.go @@ -0,0 +1,106 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package account_test + +import ( + "bytes" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/account" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type AccountUpdateTestSuite struct { + AccountStandardTestSuite +} + +func (suite *AccountUpdateTestSuite) SetupSuite() { + suite.testTokens = testrig.NewTestTokens() + suite.testClients = testrig.NewTestClients() + suite.testApplications = testrig.NewTestApplications() + suite.testUsers = testrig.NewTestUsers() + suite.testAccounts = testrig.NewTestAccounts() + suite.testAttachments = testrig.NewTestAttachments() + suite.testStatuses = testrig.NewTestStatuses() +} + +func (suite *AccountUpdateTestSuite) SetupTest() { + suite.config = testrig.NewTestConfig() + suite.db = testrig.NewTestDB() + suite.storage = testrig.NewTestStorage() + suite.log = testrig.NewTestLog() + suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil))) + 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) + testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") +} + +func (suite *AccountUpdateTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) +} + +func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandler() { + + requestBody, w, err := testrig.CreateMultipartFormData("header", "../../../../testrig/media/test-jpeg.jpg", map[string]string{ + "display_name": "updated zork display name!!!", + "locked": "true", + }) + if err != nil { + panic(err) + } + + // setup + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedToken, oauth.TokenToOauthToken(suite.testTokens["local_account_1"])) + ctx.Request = httptest.NewRequest(http.MethodPatch, fmt.Sprintf("http://localhost:8080/%s", account.UpdateCredentialsPath), bytes.NewReader(requestBody.Bytes())) // the endpoint we're hitting + ctx.Request.Header.Set("Content-Type", w.FormDataContentType()) + suite.accountModule.AccountUpdateCredentialsPATCHHandler(ctx) + + // check response + + // 1. we should have OK because our request was valid + suite.EqualValues(http.StatusOK, recorder.Code) + + // 2. we should have no error message in the result body + result := recorder.Result() + defer result.Body.Close() + + b, err := ioutil.ReadAll(result.Body) + assert.NoError(suite.T(), err) + + fmt.Println(string(b)) + + // TODO write more assertions allee +} + +func TestAccountUpdateTestSuite(t *testing.T) { + suite.Run(t, new(AccountUpdateTestSuite)) +} diff --git a/internal/apimodule/account/accountverify.go b/internal/api/client/account/accountverify.go similarity index 75% rename from internal/apimodule/account/accountverify.go rename to internal/api/client/account/accountverify.go index 9edf1e7..4c62ff7 100644 --- a/internal/apimodule/account/accountverify.go +++ b/internal/api/client/account/accountverify.go @@ -30,21 +30,19 @@ import ( // It should be served as a GET at /api/v1/accounts/verify_credentials func (m *Module) AccountVerifyGETHandler(c *gin.Context) { l := m.log.WithField("func", "accountVerifyGETHandler") - authed, err := oauth.MustAuth(c, true, false, false, true) + authed, err := oauth.Authed(c, true, false, false, true) if err != nil { l.Debugf("couldn't auth: %s", err) c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) return } - l.Tracef("retrieved account %+v, converting to mastosensitive...", authed.Account.ID) - acctSensitive, err := m.mastoConverter.AccountToMastoSensitive(authed.Account) + acctSensitive, err := m.processor.AccountGet(authed, authed.Account.ID) if err != nil { - l.Tracef("could not convert account into mastosensitive account: %s", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + l.Debugf("error getting account from processor: %s", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) return } - l.Tracef("conversion successful, returning OK and mastosensitive account %+v", acctSensitive) c.JSON(http.StatusOK, acctSensitive) } diff --git a/internal/apimodule/account/test/accountverify_test.go b/internal/api/client/account/accountverify_test.go similarity index 97% rename from internal/apimodule/account/test/accountverify_test.go rename to internal/api/client/account/accountverify_test.go index 223a0c1..85b0dce 100644 --- a/internal/apimodule/account/test/accountverify_test.go +++ b/internal/api/client/account/accountverify_test.go @@ -16,4 +16,4 @@ along with this program. If not, see . */ -package account +package account_test diff --git a/internal/apimodule/admin/admin.go b/internal/api/client/admin/admin.go similarity index 52% rename from internal/apimodule/admin/admin.go rename to internal/api/client/admin/admin.go index 2ebe9c7..7ce5311 100644 --- a/internal/apimodule/admin/admin.go +++ b/internal/api/client/admin/admin.go @@ -19,43 +19,35 @@ package admin import ( - "fmt" "net/http" "github.com/sirupsen/logrus" - "github.com/superseriousbusiness/gotosocial/internal/apimodule" + "github.com/superseriousbusiness/gotosocial/internal/api" "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/mastotypes" - "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/message" "github.com/superseriousbusiness/gotosocial/internal/router" ) const ( // BasePath is the base API path for this module - BasePath = "/api/v1/admin" + BasePath = "/api/v1/admin" // EmojiPath is used for posting/deleting custom emojis EmojiPath = BasePath + "/custom_emojis" ) // Module implements the ClientAPIModule interface for admin-related actions (reports, emojis, etc) type Module struct { - config *config.Config - db db.DB - mediaHandler media.Handler - mastoConverter mastotypes.Converter - log *logrus.Logger + config *config.Config + processor message.Processor + log *logrus.Logger } // New returns a new admin module -func New(config *config.Config, db db.DB, mediaHandler media.Handler, mastoConverter mastotypes.Converter, log *logrus.Logger) apimodule.ClientAPIModule { +func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule { return &Module{ - config: config, - db: db, - mediaHandler: mediaHandler, - mastoConverter: mastoConverter, - log: log, + config: config, + processor: processor, + log: log, } } @@ -64,25 +56,3 @@ func (m *Module) Route(r router.Router) error { r.AttachHandler(http.MethodPost, EmojiPath, m.emojiCreatePOSTHandler) return nil } - -// CreateTables creates the necessary tables for this module in the given database -func (m *Module) CreateTables(db db.DB) error { - models := []interface{}{ - >smodel.User{}, - >smodel.Account{}, - >smodel.Follow{}, - >smodel.FollowRequest{}, - >smodel.Status{}, - >smodel.Application{}, - >smodel.EmailDomainBlock{}, - >smodel.MediaAttachment{}, - >smodel.Emoji{}, - } - - for _, m := range models { - if err := db.CreateTable(m); err != nil { - return fmt.Errorf("error creating table: %s", err) - } - } - return nil -} diff --git a/internal/apimodule/admin/emojicreate.go b/internal/api/client/admin/emojicreate.go similarity index 60% rename from internal/apimodule/admin/emojicreate.go rename to internal/api/client/admin/emojicreate.go index 49e5492..0e60db6 100644 --- a/internal/apimodule/admin/emojicreate.go +++ b/internal/api/client/admin/emojicreate.go @@ -19,15 +19,13 @@ package admin import ( - "bytes" "errors" "fmt" - "io" "net/http" "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" - mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" + "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/util" @@ -42,7 +40,7 @@ func (m *Module) emojiCreatePOSTHandler(c *gin.Context) { }) // make sure we're authed with an admin account - authed, err := oauth.MustAuth(c, true, true, true, true) // posting a status is serious business so we want *everything* + authed, err := oauth.Authed(c, true, true, true, true) // posting a status is serious business so we want *everything* if err != nil { l.Debugf("couldn't auth: %s", err) c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) @@ -56,7 +54,7 @@ func (m *Module) emojiCreatePOSTHandler(c *gin.Context) { // extract the media create form from the request context l.Tracef("parsing request form: %+v", c.Request.Form) - form := &mastotypes.EmojiCreateRequest{} + form := &model.EmojiCreateRequest{} 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)}) @@ -71,51 +69,17 @@ func (m *Module) emojiCreatePOSTHandler(c *gin.Context) { return } - // open the emoji and extract the bytes from it - f, err := form.Image.Open() + mastoEmoji, err := m.processor.AdminEmojiCreate(authed, form) if err != nil { - l.Debugf("error opening emoji: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not open provided emoji: %s", err)}) - return - } - buf := new(bytes.Buffer) - size, err := io.Copy(buf, f) - if err != nil { - l.Debugf("error reading emoji: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not read provided emoji: %s", err)}) - return - } - if size == 0 { - l.Debug("could not read provided emoji: size 0 bytes") - c.JSON(http.StatusBadRequest, gin.H{"error": "could not read provided emoji: size 0 bytes"}) - return - } - - // allow the mediaHandler to work its magic of processing the emoji bytes, and putting them in whatever storage backend we're using - emoji, err := m.mediaHandler.ProcessLocalEmoji(buf.Bytes(), form.Shortcode) - if err != nil { - l.Debugf("error reading emoji: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not process emoji: %s", err)}) - return - } - - mastoEmoji, err := m.mastoConverter.EmojiToMasto(emoji) - if err != nil { - l.Debugf("error converting emoji to mastotype: %s", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("could not convert emoji: %s", err)}) - return - } - - if err := m.db.Put(emoji); err != nil { - l.Debugf("database error while processing emoji: %s", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("database error while processing emoji: %s", err)}) + l.Debugf("error creating emoji: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, mastoEmoji) } -func validateCreateEmoji(form *mastotypes.EmojiCreateRequest) error { +func validateCreateEmoji(form *model.EmojiCreateRequest) error { // check there actually is an image attached and it's not size 0 if form.Image == nil || form.Image.Size == 0 { return errors.New("no emoji given") diff --git a/internal/apimodule/app/app.go b/internal/api/client/app/app.go similarity index 54% rename from internal/apimodule/app/app.go rename to internal/api/client/app/app.go index 5181927..d1e732a 100644 --- a/internal/apimodule/app/app.go +++ b/internal/api/client/app/app.go @@ -19,15 +19,12 @@ package app import ( - "fmt" "net/http" "github.com/sirupsen/logrus" - "github.com/superseriousbusiness/gotosocial/internal/apimodule" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/mastotypes" - "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/message" "github.com/superseriousbusiness/gotosocial/internal/router" ) @@ -36,19 +33,17 @@ const BasePath = "/api/v1/apps" // Module implements the ClientAPIModule interface for requests relating to registering/removing applications type Module struct { - server oauth.Server - db db.DB - mastoConverter mastotypes.Converter - log *logrus.Logger + config *config.Config + processor message.Processor + log *logrus.Logger } // New returns a new auth module -func New(srv oauth.Server, db db.DB, mastoConverter mastotypes.Converter, log *logrus.Logger) apimodule.ClientAPIModule { +func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule { return &Module{ - server: srv, - db: db, - mastoConverter: mastoConverter, - log: log, + config: config, + processor: processor, + log: log, } } @@ -57,21 +52,3 @@ func (m *Module) Route(s router.Router) error { s.AttachHandler(http.MethodPost, BasePath, m.AppsPOSTHandler) return nil } - -// CreateTables creates the necessary tables for this module in the given database -func (m *Module) CreateTables(db db.DB) error { - models := []interface{}{ - &oauth.Client{}, - &oauth.Token{}, - >smodel.User{}, - >smodel.Account{}, - >smodel.Application{}, - } - - for _, m := range models { - if err := db.CreateTable(m); err != nil { - return fmt.Errorf("error creating table: %s", err) - } - } - return nil -} diff --git a/internal/apimodule/app/test/app_test.go b/internal/api/client/app/app_test.go similarity index 97% rename from internal/apimodule/app/test/app_test.go rename to internal/api/client/app/app_test.go index d45b04e..42760a2 100644 --- a/internal/apimodule/app/test/app_test.go +++ b/internal/api/client/app/app_test.go @@ -16,6 +16,6 @@ along with this program. If not, see . */ -package app +package app_test // TODO: write tests diff --git a/internal/api/client/app/appcreate.go b/internal/api/client/app/appcreate.go new file mode 100644 index 0000000..fd42482 --- /dev/null +++ b/internal/api/client/app/appcreate.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 . +*/ + +package app + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// AppsPOSTHandler should be served at https://example.org/api/v1/apps +// It is equivalent to: https://docs.joinmastodon.org/methods/apps/ +func (m *Module) AppsPOSTHandler(c *gin.Context) { + l := m.log.WithField("func", "AppsPOSTHandler") + l.Trace("entering AppsPOSTHandler") + + authed, err := oauth.Authed(c, false, false, false, false) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + + form := &model.ApplicationCreateRequest{} + if err := c.ShouldBind(form); err != nil { + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) + return + } + + // permitted length for most fields + formFieldLen := 64 + // redirect can be a bit bigger because we probably need to encode data in the redirect uri + formRedirectLen := 512 + + // check lengths of fields before proceeding so the user can't spam huge entries into the database + if len(form.ClientName) > formFieldLen { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("client_name must be less than %d bytes", formFieldLen)}) + return + } + if len(form.Website) > formFieldLen { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("website must be less than %d bytes", formFieldLen)}) + return + } + if len(form.RedirectURIs) > formRedirectLen { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("redirect_uris must be less than %d bytes", formRedirectLen)}) + return + } + if len(form.Scopes) > formFieldLen { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("scopes must be less than %d bytes", formFieldLen)}) + return + } + + mastoApp, err := m.processor.AppCreate(authed, form) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // done, return the new app information per the spec here: https://docs.joinmastodon.org/methods/apps/ + c.JSON(http.StatusOK, mastoApp) +} diff --git a/internal/apimodule/auth/auth.go b/internal/api/client/auth/auth.go similarity index 74% rename from internal/apimodule/auth/auth.go rename to internal/api/client/auth/auth.go index 341805b..793c19f 100644 --- a/internal/apimodule/auth/auth.go +++ b/internal/api/client/auth/auth.go @@ -19,38 +19,39 @@ package auth import ( - "fmt" "net/http" "github.com/sirupsen/logrus" - "github.com/superseriousbusiness/gotosocial/internal/apimodule" + "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/router" ) const ( // AuthSignInPath is the API path for users to sign in through - AuthSignInPath = "/auth/sign_in" + AuthSignInPath = "/auth/sign_in" // OauthTokenPath is the API path to use for granting token requests to users with valid credentials - OauthTokenPath = "/oauth/token" + OauthTokenPath = "/oauth/token" // OauthAuthorizePath is the API path for authorization requests (eg., authorize this app to act on my behalf as a user) OauthAuthorizePath = "/oauth/authorize" ) // Module implements the ClientAPIModule interface for type Module struct { - server oauth.Server + config *config.Config db db.DB + server oauth.Server log *logrus.Logger } // New returns a new auth module -func New(srv oauth.Server, db db.DB, log *logrus.Logger) apimodule.ClientAPIModule { +func New(config *config.Config, db db.DB, server oauth.Server, log *logrus.Logger) api.ClientModule { return &Module{ - server: srv, + config: config, db: db, + server: server, log: log, } } @@ -68,21 +69,3 @@ func (m *Module) Route(s router.Router) error { s.AttachMiddleware(m.OauthTokenMiddleware) return nil } - -// CreateTables creates the necessary tables for this module in the given database -func (m *Module) CreateTables(db db.DB) error { - models := []interface{}{ - &oauth.Client{}, - &oauth.Token{}, - >smodel.User{}, - >smodel.Account{}, - >smodel.Application{}, - } - - for _, m := range models { - if err := db.CreateTable(m); err != nil { - return fmt.Errorf("error creating table: %s", err) - } - } - return nil -} diff --git a/internal/apimodule/auth/test/auth_test.go b/internal/api/client/auth/auth_test.go similarity index 96% rename from internal/apimodule/auth/test/auth_test.go rename to internal/api/client/auth/auth_test.go index 2c272e9..7ec788a 100644 --- a/internal/apimodule/auth/test/auth_test.go +++ b/internal/api/client/auth/auth_test.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package auth +package auth_test import ( "context" @@ -28,7 +28,7 @@ import ( "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/oauth" "golang.org/x/crypto/bcrypt" ) @@ -103,7 +103,7 @@ func (suite *AuthTestSuite) SetupTest() { log := logrus.New() log.SetLevel(logrus.TraceLevel) - db, err := db.New(context.Background(), suite.config, log) + db, err := db.NewPostgresService(context.Background(), suite.config, log) if err != nil { logrus.Panicf("error creating database connection: %s", err) } diff --git a/internal/apimodule/auth/authorize.go b/internal/api/client/auth/authorize.go similarity index 97% rename from internal/apimodule/auth/authorize.go rename to internal/api/client/auth/authorize.go index 4bc1991..d5f8ee2 100644 --- a/internal/apimodule/auth/authorize.go +++ b/internal/api/client/auth/authorize.go @@ -27,8 +27,8 @@ import ( "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" + "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" ) // AuthorizeGETHandler should be served as GET at https://example.org/oauth/authorize @@ -178,7 +178,7 @@ func parseAuthForm(c *gin.Context, l *logrus.Entry) error { s := sessions.Default(c) // first make sure they've filled out the authorize form with the required values - form := &mastotypes.OAuthAuthorize{} + form := &model.OAuthAuthorize{} if err := c.ShouldBind(form); err != nil { return err } diff --git a/internal/apimodule/auth/middleware.go b/internal/api/client/auth/middleware.go similarity index 96% rename from internal/apimodule/auth/middleware.go rename to internal/api/client/auth/middleware.go index 1d9a859..c42ba77 100644 --- a/internal/apimodule/auth/middleware.go +++ b/internal/api/client/auth/middleware.go @@ -20,7 +20,7 @@ package auth import ( "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -30,7 +30,7 @@ import ( // If user or account can't be found, then the handler won't *fail*, in case the server wants to allow // public requests that don't have a Bearer token set (eg., for public instance information and so on). func (m *Module) OauthTokenMiddleware(c *gin.Context) { - l := m.log.WithField("func", "ValidatePassword") + l := m.log.WithField("func", "OauthTokenMiddleware") l.Trace("entering OauthTokenMiddleware") ti, err := m.server.ValidationBearerToken(c.Request) diff --git a/internal/apimodule/auth/signin.go b/internal/api/client/auth/signin.go similarity index 98% rename from internal/apimodule/auth/signin.go rename to internal/api/client/auth/signin.go index 44de089..79d9b30 100644 --- a/internal/apimodule/auth/signin.go +++ b/internal/api/client/auth/signin.go @@ -24,7 +24,7 @@ import ( "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "golang.org/x/crypto/bcrypt" ) diff --git a/internal/apimodule/auth/token.go b/internal/api/client/auth/token.go similarity index 100% rename from internal/apimodule/auth/token.go rename to internal/api/client/auth/token.go diff --git a/internal/apimodule/fileserver/fileserver.go b/internal/api/client/fileserver/fileserver.go similarity index 85% rename from internal/apimodule/fileserver/fileserver.go rename to internal/api/client/fileserver/fileserver.go index 7651c8c..63d323a 100644 --- a/internal/apimodule/fileserver/fileserver.go +++ b/internal/api/client/fileserver/fileserver.go @@ -23,12 +23,12 @@ import ( "net/http" "github.com/sirupsen/logrus" - "github.com/superseriousbusiness/gotosocial/internal/apimodule" + "github.com/superseriousbusiness/gotosocial/internal/api" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/message" "github.com/superseriousbusiness/gotosocial/internal/router" - "github.com/superseriousbusiness/gotosocial/internal/storage" ) const ( @@ -39,25 +39,23 @@ const ( // MediaSizeKey is the url key for the desired media size--original/small/static MediaSizeKey = "media_size" // FileNameKey is the actual filename being sought. Will usually be a UUID then something like .jpeg - FileNameKey = "file_name" + FileNameKey = "file_name" ) // FileServer implements the RESTAPIModule interface. // The goal here is to serve requested media files if the gotosocial server is configured to use local storage. type FileServer struct { config *config.Config - db db.DB - storage storage.Storage + processor message.Processor log *logrus.Logger storageBase string } // New returns a new fileServer module -func New(config *config.Config, db db.DB, storage storage.Storage, log *logrus.Logger) apimodule.ClientAPIModule { +func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule { return &FileServer{ config: config, - db: db, - storage: storage, + processor: processor, log: log, storageBase: config.StorageConfig.ServeBasePath, } diff --git a/internal/api/client/fileserver/servefile.go b/internal/api/client/fileserver/servefile.go new file mode 100644 index 0000000..9823eb3 --- /dev/null +++ b/internal/api/client/fileserver/servefile.go @@ -0,0 +1,94 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package fileserver + +import ( + "bytes" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// ServeFile is for serving attachments, headers, and avatars to the requester from instance storage. +// +// Note: to mitigate scraping attempts, no information should be given out on a bad request except "404 page not found". +// Don't give away account ids or media ids or anything like that; callers shouldn't be able to infer anything. +func (m *FileServer) ServeFile(c *gin.Context) { + l := m.log.WithFields(logrus.Fields{ + "func": "ServeFile", + "request_uri": c.Request.RequestURI, + "user_agent": c.Request.UserAgent(), + "origin_ip": c.ClientIP(), + }) + l.Trace("received request") + + authed, err := oauth.Authed(c, false, false, false, false) + if err != nil { + c.String(http.StatusNotFound, "404 page not found") + return + } + + // We use request params to check what to pull out of the database/storage so check everything. A request URL should be formatted as follows: + // "https://example.org/fileserver/[ACCOUNT_ID]/[MEDIA_TYPE]/[MEDIA_SIZE]/[FILE_NAME]" + // "FILE_NAME" consists of two parts, the attachment's database id, a period, and the file extension. + accountID := c.Param(AccountIDKey) + if accountID == "" { + l.Debug("missing accountID from request") + c.String(http.StatusNotFound, "404 page not found") + return + } + + mediaType := c.Param(MediaTypeKey) + if mediaType == "" { + l.Debug("missing mediaType from request") + c.String(http.StatusNotFound, "404 page not found") + return + } + + mediaSize := c.Param(MediaSizeKey) + if mediaSize == "" { + l.Debug("missing mediaSize from request") + c.String(http.StatusNotFound, "404 page not found") + return + } + + fileName := c.Param(FileNameKey) + if fileName == "" { + l.Debug("missing fileName from request") + c.String(http.StatusNotFound, "404 page not found") + return + } + + content, err := m.processor.MediaGet(authed, &model.GetContentRequestForm{ + AccountID: accountID, + MediaType: mediaType, + MediaSize: mediaSize, + FileName: fileName, + }) + if err != nil { + l.Debug(err) + c.String(http.StatusNotFound, "404 page not found") + return + } + + c.DataFromReader(http.StatusOK, content.ContentLength, content.ContentType, bytes.NewReader(content.Content), nil) +} diff --git a/internal/apimodule/fileserver/test/servefile_test.go b/internal/api/client/fileserver/servefile_test.go similarity index 80% rename from internal/apimodule/fileserver/test/servefile_test.go rename to internal/api/client/fileserver/servefile_test.go index 516e352..09fd8ea 100644 --- a/internal/apimodule/fileserver/test/servefile_test.go +++ b/internal/api/client/fileserver/servefile_test.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package test +package fileserver_test import ( "context" @@ -30,27 +30,31 @@ import ( "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/apimodule/fileserver" + "github.com/superseriousbusiness/gotosocial/internal/api/client/fileserver" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/mastotypes" + "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/message" "github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/storage" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" "github.com/superseriousbusiness/gotosocial/testrig" ) type ServeFileTestSuite struct { // standard suite interfaces suite.Suite - config *config.Config - db db.DB - log *logrus.Logger - storage storage.Storage - mastoConverter mastotypes.Converter - mediaHandler media.Handler - oauthServer oauth.Server + config *config.Config + db db.DB + log *logrus.Logger + storage storage.Storage + federator federation.Federator + tc typeutils.TypeConverter + processor message.Processor + mediaHandler media.Handler + oauthServer oauth.Server // standard suite models testTokens map[string]*oauth.Token @@ -74,12 +78,14 @@ func (suite *ServeFileTestSuite) SetupSuite() { suite.db = testrig.NewTestDB() suite.log = testrig.NewTestLog() suite.storage = testrig.NewTestStorage() - suite.mastoConverter = testrig.NewTestMastoConverter(suite.db) + suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil))) + suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator) + suite.tc = testrig.NewTestTypeConverter(suite.db) suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage) suite.oauthServer = testrig.NewTestOauthServer(suite.db) // setup module being tested - suite.fileServer = fileserver.New(suite.config, suite.db, suite.storage, suite.log).(*fileserver.FileServer) + suite.fileServer = fileserver.New(suite.config, suite.processor, suite.log).(*fileserver.FileServer) } func (suite *ServeFileTestSuite) TearDownSuite() { @@ -126,11 +132,11 @@ func (suite *ServeFileTestSuite) TestServeOriginalFileSuccessful() { }, gin.Param{ Key: fileserver.MediaTypeKey, - Value: media.MediaAttachment, + Value: string(media.Attachment), }, gin.Param{ Key: fileserver.MediaSizeKey, - Value: media.MediaOriginal, + Value: string(media.Original), }, gin.Param{ Key: fileserver.FileNameKey, diff --git a/internal/apimodule/media/media.go b/internal/api/client/media/media.go similarity index 71% rename from internal/apimodule/media/media.go rename to internal/api/client/media/media.go index 8fb9f16..2826783 100644 --- a/internal/apimodule/media/media.go +++ b/internal/api/client/media/media.go @@ -23,12 +23,11 @@ import ( "net/http" "github.com/sirupsen/logrus" - "github.com/superseriousbusiness/gotosocial/internal/apimodule" + "github.com/superseriousbusiness/gotosocial/internal/api" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/mastotypes" - "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/message" "github.com/superseriousbusiness/gotosocial/internal/router" ) @@ -37,21 +36,17 @@ const BasePath = "/api/v1/media" // Module implements the ClientAPIModule interface for media type Module struct { - mediaHandler media.Handler - config *config.Config - db db.DB - mastoConverter mastotypes.Converter - log *logrus.Logger + config *config.Config + processor message.Processor + log *logrus.Logger } // New returns a new auth module -func New(db db.DB, mediaHandler media.Handler, mastoConverter mastotypes.Converter, config *config.Config, log *logrus.Logger) apimodule.ClientAPIModule { +func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule { return &Module{ - mediaHandler: mediaHandler, - config: config, - db: db, - mastoConverter: mastoConverter, - log: log, + config: config, + processor: processor, + log: log, } } diff --git a/internal/api/client/media/mediacreate.go b/internal/api/client/media/mediacreate.go new file mode 100644 index 0000000..db57e20 --- /dev/null +++ b/internal/api/client/media/mediacreate.go @@ -0,0 +1,91 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package media + +import ( + "errors" + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// MediaCreatePOSTHandler handles requests to create/upload media attachments +func (m *Module) MediaCreatePOSTHandler(c *gin.Context) { + l := m.log.WithField("func", "statusCreatePOSTHandler") + authed, err := oauth.Authed(c, true, true, true, true) // posting new media is serious business so we want *everything* + if err != nil { + l.Debugf("couldn't auth: %s", err) + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + + // extract the media create form from the request context + l.Tracef("parsing request form: %s", c.Request.Form) + form := &model.AttachmentRequest{} + if err := c.ShouldBind(form); err != nil || form == nil { + l.Debugf("could not parse form from request: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"}) + return + } + + // Give the fields on the request form a first pass to make sure the request is superficially valid. + l.Tracef("validating form %+v", form) + if err := validateCreateMedia(form, m.config.MediaConfig); err != nil { + l.Debugf("error validating form: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + mastoAttachment, err := m.processor.MediaCreate(authed, form) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusAccepted, mastoAttachment) +} + +func validateCreateMedia(form *model.AttachmentRequest, config *config.MediaConfig) error { + // check there actually is a file attached and it's not size 0 + if form.File == nil || form.File.Size == 0 { + return errors.New("no attachment given") + } + + // a very superficial check to see if no size limits are exceeded + // we still don't actually know which media types we're dealing with but the other handlers will go into more detail there + maxSize := config.MaxVideoSize + if config.MaxImageSize > maxSize { + maxSize = config.MaxImageSize + } + if form.File.Size > int64(maxSize) { + return fmt.Errorf("file size limit exceeded: limit is %d bytes but attachment was %d bytes", maxSize, form.File.Size) + } + + if len(form.Description) < config.MinDescriptionChars || len(form.Description) > config.MaxDescriptionChars { + return fmt.Errorf("image description length must be between %d and %d characters (inclusive), but provided image description was %d chars", config.MinDescriptionChars, config.MaxDescriptionChars, len(form.Description)) + } + + // TODO: validate focus here + + return nil +} diff --git a/internal/apimodule/media/test/mediacreate_test.go b/internal/api/client/media/mediacreate_test.go similarity index 82% rename from internal/apimodule/media/test/mediacreate_test.go rename to internal/api/client/media/mediacreate_test.go index 30bbb11..e86c660 100644 --- a/internal/apimodule/media/test/mediacreate_test.go +++ b/internal/api/client/media/mediacreate_test.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package test +package media_test import ( "bytes" @@ -32,28 +32,32 @@ import ( "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" - mediamodule "github.com/superseriousbusiness/gotosocial/internal/apimodule/media" + mediamodule "github.com/superseriousbusiness/gotosocial/internal/api/client/media" + "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/mastotypes" - mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" + "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/message" "github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/storage" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" "github.com/superseriousbusiness/gotosocial/testrig" ) type MediaCreateTestSuite struct { // standard suite interfaces suite.Suite - config *config.Config - db db.DB - log *logrus.Logger - storage storage.Storage - mastoConverter mastotypes.Converter - mediaHandler media.Handler - oauthServer oauth.Server + config *config.Config + db db.DB + log *logrus.Logger + storage storage.Storage + federator federation.Federator + tc typeutils.TypeConverter + mediaHandler media.Handler + oauthServer oauth.Server + processor message.Processor // standard suite models testTokens map[string]*oauth.Token @@ -77,12 +81,14 @@ func (suite *MediaCreateTestSuite) SetupSuite() { suite.db = testrig.NewTestDB() suite.log = testrig.NewTestLog() suite.storage = testrig.NewTestStorage() - suite.mastoConverter = testrig.NewTestMastoConverter(suite.db) + 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.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator) // setup module being tested - suite.mediaModule = mediamodule.New(suite.db, suite.mediaHandler, suite.mastoConverter, suite.config, suite.log).(*mediamodule.Module) + suite.mediaModule = mediamodule.New(suite.config, suite.processor, suite.log).(*mediamodule.Module) } func (suite *MediaCreateTestSuite) TearDownSuite() { @@ -158,26 +164,26 @@ func (suite *MediaCreateTestSuite) TestStatusCreatePOSTImageHandlerSuccessful() assert.NoError(suite.T(), err) fmt.Println(string(b)) - attachmentReply := &mastomodel.Attachment{} + attachmentReply := &model.Attachment{} err = json.Unmarshal(b, attachmentReply) assert.NoError(suite.T(), err) assert.Equal(suite.T(), "this is a test image -- a cool background from somewhere", attachmentReply.Description) assert.Equal(suite.T(), "image", attachmentReply.Type) - assert.EqualValues(suite.T(), mastomodel.MediaMeta{ - Original: mastomodel.MediaDimensions{ + assert.EqualValues(suite.T(), model.MediaMeta{ + Original: model.MediaDimensions{ Width: 1920, Height: 1080, Size: "1920x1080", Aspect: 1.7777778, }, - Small: mastomodel.MediaDimensions{ + Small: model.MediaDimensions{ Width: 256, Height: 144, Size: "256x144", Aspect: 1.7777778, }, - Focus: mastomodel.MediaFocus{ + Focus: model.MediaFocus{ X: -0.5, Y: 0.5, }, diff --git a/internal/apimodule/status/status.go b/internal/api/client/status/status.go similarity index 62% rename from internal/apimodule/status/status.go rename to internal/api/client/status/status.go index 73a1b58..ba92956 100644 --- a/internal/apimodule/status/status.go +++ b/internal/api/client/status/status.go @@ -19,27 +19,22 @@ package status import ( - "fmt" "net/http" "strings" "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" - "github.com/superseriousbusiness/gotosocial/internal/apimodule" + "github.com/superseriousbusiness/gotosocial/internal/api" "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/distributor" - "github.com/superseriousbusiness/gotosocial/internal/mastotypes" - "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/message" "github.com/superseriousbusiness/gotosocial/internal/router" ) const ( // IDKey is for status UUIDs - IDKey = "id" + IDKey = "id" // BasePath is the base path for serving the status API - BasePath = "/api/v1/statuses" + BasePath = "/api/v1/statuses" // BasePathWithID is just the base path with the ID key in it. // Use this anywhere you need to know the ID of the status being queried. BasePathWithID = BasePath + "/:" + IDKey @@ -48,54 +43,48 @@ const ( ContextPath = BasePathWithID + "/context" // FavouritedPath is for seeing who's faved a given status - FavouritedPath = BasePathWithID + "/favourited_by" + FavouritedPath = BasePathWithID + "/favourited_by" // FavouritePath is for posting a fave on a status - FavouritePath = BasePathWithID + "/favourite" + FavouritePath = BasePathWithID + "/favourite" // UnfavouritePath is for removing a fave from a status UnfavouritePath = BasePathWithID + "/unfavourite" // RebloggedPath is for seeing who's boosted a given status RebloggedPath = BasePathWithID + "/reblogged_by" // ReblogPath is for boosting/reblogging a given status - ReblogPath = BasePathWithID + "/reblog" + ReblogPath = BasePathWithID + "/reblog" // UnreblogPath is for undoing a boost/reblog of a given status - UnreblogPath = BasePathWithID + "/unreblog" + UnreblogPath = BasePathWithID + "/unreblog" // BookmarkPath is for creating a bookmark on a given status - BookmarkPath = BasePathWithID + "/bookmark" + BookmarkPath = BasePathWithID + "/bookmark" // UnbookmarkPath is for removing a bookmark from a given status UnbookmarkPath = BasePathWithID + "/unbookmark" // MutePath is for muting a given status so that notifications will no longer be received about it. - MutePath = BasePathWithID + "/mute" + MutePath = BasePathWithID + "/mute" // UnmutePath is for undoing an existing mute UnmutePath = BasePathWithID + "/unmute" // PinPath is for pinning a status to an account profile so that it's the first thing people see - PinPath = BasePathWithID + "/pin" + PinPath = BasePathWithID + "/pin" // UnpinPath is for undoing a pin and returning a status to the ever-swirling drain of time and entropy UnpinPath = BasePathWithID + "/unpin" ) // Module implements the ClientAPIModule interface for every related to posting/deleting/interacting with statuses type Module struct { - config *config.Config - db db.DB - mediaHandler media.Handler - mastoConverter mastotypes.Converter - distributor distributor.Distributor - log *logrus.Logger + config *config.Config + processor message.Processor + log *logrus.Logger } // New returns a new account module -func New(config *config.Config, db db.DB, mediaHandler media.Handler, mastoConverter mastotypes.Converter, distributor distributor.Distributor, log *logrus.Logger) apimodule.ClientAPIModule { +func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule { return &Module{ - config: config, - db: db, - mediaHandler: mediaHandler, - mastoConverter: mastoConverter, - distributor: distributor, - log: log, + config: config, + processor: processor, + log: log, } } @@ -105,41 +94,12 @@ func (m *Module) Route(r router.Router) error { r.AttachHandler(http.MethodDelete, BasePathWithID, m.StatusDELETEHandler) r.AttachHandler(http.MethodPost, FavouritePath, m.StatusFavePOSTHandler) - r.AttachHandler(http.MethodPost, UnfavouritePath, m.StatusFavePOSTHandler) + r.AttachHandler(http.MethodPost, UnfavouritePath, m.StatusUnfavePOSTHandler) r.AttachHandler(http.MethodGet, BasePathWithID, m.muxHandler) return nil } -// CreateTables populates necessary tables in the given DB -func (m *Module) CreateTables(db db.DB) error { - models := []interface{}{ - >smodel.User{}, - >smodel.Account{}, - >smodel.Block{}, - >smodel.Follow{}, - >smodel.FollowRequest{}, - >smodel.Status{}, - >smodel.StatusFave{}, - >smodel.StatusBookmark{}, - >smodel.StatusMute{}, - >smodel.StatusPin{}, - >smodel.Application{}, - >smodel.EmailDomainBlock{}, - >smodel.MediaAttachment{}, - >smodel.Emoji{}, - >smodel.Tag{}, - >smodel.Mention{}, - } - - for _, m := range models { - if err := db.CreateTable(m); err != nil { - return fmt.Errorf("error creating table: %s", err) - } - } - return nil -} - // muxHandler is a little workaround to overcome the limitations of Gin func (m *Module) muxHandler(c *gin.Context) { m.log.Debug("entering mux handler") diff --git a/internal/api/client/status/status_test.go b/internal/api/client/status/status_test.go new file mode 100644 index 0000000..0f77820 --- /dev/null +++ b/internal/api/client/status/status_test.go @@ -0,0 +1,58 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package status_test + +import ( + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/status" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/message" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/storage" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" +) + +// nolint +type StatusStandardTestSuite struct { + // standard suite interfaces + suite.Suite + config *config.Config + db db.DB + log *logrus.Logger + tc typeutils.TypeConverter + federator federation.Federator + processor message.Processor + storage storage.Storage + + // standard suite models + testTokens map[string]*oauth.Token + testClients map[string]*oauth.Client + testApplications map[string]*gtsmodel.Application + testUsers map[string]*gtsmodel.User + testAccounts map[string]*gtsmodel.Account + testAttachments map[string]*gtsmodel.MediaAttachment + testStatuses map[string]*gtsmodel.Status + + // module being tested + statusModule *status.Module +} diff --git a/internal/api/client/status/statuscreate.go b/internal/api/client/status/statuscreate.go new file mode 100644 index 0000000..02080b0 --- /dev/null +++ b/internal/api/client/status/statuscreate.go @@ -0,0 +1,130 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package status + +import ( + "errors" + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +// StatusCreatePOSTHandler deals with the creation of new statuses +func (m *Module) StatusCreatePOSTHandler(c *gin.Context) { + l := m.log.WithField("func", "statusCreatePOSTHandler") + authed, err := oauth.Authed(c, true, true, true, true) // posting a status is serious business so we want *everything* + if err != nil { + l.Debugf("couldn't auth: %s", err) + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + + // First check this user/account is permitted to post new statuses. + // There's no point continuing otherwise. + if authed.User.Disabled || !authed.User.Approved || !authed.Account.SuspendedAt.IsZero() { + l.Debugf("couldn't auth: %s", err) + c.JSON(http.StatusForbidden, gin.H{"error": "account is disabled, not yet approved, or suspended"}) + return + } + + // extract the status create form from the request context + l.Tracef("parsing request form: %s", c.Request.Form) + form := &model.AdvancedStatusCreateForm{} + if err := c.ShouldBind(form); err != nil || form == nil { + l.Debugf("could not parse form from request: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"}) + 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 := validateCreateStatus(form, m.config.StatusesConfig); err != nil { + l.Debugf("error validating form: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + mastoStatus, err := m.processor.StatusCreate(authed, form) + if err != nil { + l.Debugf("error processing status create: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"}) + return + } + + c.JSON(http.StatusOK, mastoStatus) +} + +func validateCreateStatus(form *model.AdvancedStatusCreateForm, config *config.StatusesConfig) error { + // validate that, structurally, we have a valid status/post + if form.Status == "" && form.MediaIDs == nil && form.Poll == nil { + return errors.New("no status, media, or poll provided") + } + + if form.MediaIDs != nil && form.Poll != nil { + return errors.New("can't post media + poll in same status") + } + + // validate status + if form.Status != "" { + if len(form.Status) > config.MaxChars { + return fmt.Errorf("status too long, %d characters provided but limit is %d", len(form.Status), config.MaxChars) + } + } + + // validate media attachments + if len(form.MediaIDs) > config.MaxMediaFiles { + return fmt.Errorf("too many media files attached to status, %d attached but limit is %d", len(form.MediaIDs), config.MaxMediaFiles) + } + + // validate poll + if form.Poll != nil { + if form.Poll.Options == nil { + return errors.New("poll with no options") + } + if len(form.Poll.Options) > config.PollMaxOptions { + return fmt.Errorf("too many poll options provided, %d provided but limit is %d", len(form.Poll.Options), config.PollMaxOptions) + } + for _, p := range form.Poll.Options { + if len(p) > config.PollOptionMaxChars { + return fmt.Errorf("poll option too long, %d characters provided but limit is %d", len(p), config.PollOptionMaxChars) + } + } + } + + // validate spoiler text/cw + if form.SpoilerText != "" { + if len(form.SpoilerText) > config.CWMaxChars { + return fmt.Errorf("content-warning/spoilertext too long, %d characters provided but limit is %d", len(form.SpoilerText), config.CWMaxChars) + } + } + + // validate post language + if form.Language != "" { + if err := util.ValidateLanguage(form.Language); err != nil { + return err + } + } + + return nil +} diff --git a/internal/apimodule/status/test/statuscreate_test.go b/internal/api/client/status/statuscreate_test.go similarity index 79% rename from internal/apimodule/status/test/statuscreate_test.go rename to internal/api/client/status/statuscreate_test.go index d143ac9..fb9b48f 100644 --- a/internal/apimodule/status/test/statuscreate_test.go +++ b/internal/api/client/status/statuscreate_test.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package status +package status_test import ( "encoding/json" @@ -28,95 +28,46 @@ import ( "testing" "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/apimodule/status" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/distributor" - "github.com/superseriousbusiness/gotosocial/internal/mastotypes" - mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" - "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/api/client/status" + "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/storage" "github.com/superseriousbusiness/gotosocial/testrig" ) type StatusCreateTestSuite struct { - // standard suite interfaces - suite.Suite - config *config.Config - db db.DB - log *logrus.Logger - storage storage.Storage - mastoConverter mastotypes.Converter - mediaHandler media.Handler - oauthServer oauth.Server - distributor distributor.Distributor - - // standard suite models - testTokens map[string]*oauth.Token - testClients map[string]*oauth.Client - testApplications map[string]*gtsmodel.Application - testUsers map[string]*gtsmodel.User - testAccounts map[string]*gtsmodel.Account - testAttachments map[string]*gtsmodel.MediaAttachment - - // module being tested - statusModule *status.Module + StatusStandardTestSuite } -/* - TEST INFRASTRUCTURE -*/ - -// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout func (suite *StatusCreateTestSuite) SetupSuite() { - // setup standard items - suite.config = testrig.NewTestConfig() - suite.db = testrig.NewTestDB() - suite.log = testrig.NewTestLog() - suite.storage = testrig.NewTestStorage() - suite.mastoConverter = testrig.NewTestMastoConverter(suite.db) - suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage) - suite.oauthServer = testrig.NewTestOauthServer(suite.db) - suite.distributor = testrig.NewTestDistributor() - - // setup module being tested - suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.Module) -} - -func (suite *StatusCreateTestSuite) TearDownSuite() { - testrig.StandardDBTeardown(suite.db) - testrig.StandardStorageTeardown(suite.storage) -} - -func (suite *StatusCreateTestSuite) SetupTest() { - testrig.StandardDBSetup(suite.db) - testrig.StandardStorageSetup(suite.storage, "../../../testrig/media") suite.testTokens = testrig.NewTestTokens() suite.testClients = testrig.NewTestClients() suite.testApplications = testrig.NewTestApplications() suite.testUsers = testrig.NewTestUsers() suite.testAccounts = testrig.NewTestAccounts() suite.testAttachments = testrig.NewTestAttachments() + suite.testStatuses = testrig.NewTestStatuses() +} + +func (suite *StatusCreateTestSuite) SetupTest() { + suite.config = testrig.NewTestConfig() + suite.db = testrig.NewTestDB() + suite.storage = testrig.NewTestStorage() + suite.log = testrig.NewTestLog() + suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil))) + 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) + testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") } -// TearDownTest drops tables to make sure there's no data in the db func (suite *StatusCreateTestSuite) TearDownTest() { testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) } -/* - ACTUAL TESTS -*/ - -/* - TESTING: StatusCreatePOSTHandler -*/ - // Post a new status with some custom visibility settings func (suite *StatusCreateTestSuite) TestPostNewStatus() { @@ -152,16 +103,16 @@ func (suite *StatusCreateTestSuite) TestPostNewStatus() { b, err := ioutil.ReadAll(result.Body) assert.NoError(suite.T(), err) - statusReply := &mastomodel.Status{} + statusReply := &model.Status{} err = json.Unmarshal(b, statusReply) assert.NoError(suite.T(), err) assert.Equal(suite.T(), "hello hello", statusReply.SpoilerText) assert.Equal(suite.T(), "this is a brand new status! #helloworld", statusReply.Content) assert.True(suite.T(), statusReply.Sensitive) - assert.Equal(suite.T(), mastomodel.VisibilityPrivate, statusReply.Visibility) + assert.Equal(suite.T(), model.VisibilityPrivate, statusReply.Visibility) assert.Len(suite.T(), statusReply.Tags, 1) - assert.Equal(suite.T(), mastomodel.Tag{ + assert.Equal(suite.T(), model.Tag{ Name: "helloworld", URL: "http://localhost:8080/tags/helloworld", }, statusReply.Tags[0]) @@ -197,7 +148,7 @@ func (suite *StatusCreateTestSuite) TestPostNewStatusWithEmoji() { b, err := ioutil.ReadAll(result.Body) assert.NoError(suite.T(), err) - statusReply := &mastomodel.Status{} + statusReply := &model.Status{} err = json.Unmarshal(b, statusReply) assert.NoError(suite.T(), err) @@ -241,7 +192,7 @@ func (suite *StatusCreateTestSuite) TestReplyToNonexistentStatus() { defer result.Body.Close() b, err := ioutil.ReadAll(result.Body) assert.NoError(suite.T(), err) - assert.Equal(suite.T(), `{"error":"status with id 3759e7ef-8ee1-4c0c-86f6-8b70b9ad3d50 not replyable because it doesn't exist"}`, string(b)) + assert.Equal(suite.T(), `{"error":"bad request"}`, string(b)) } // Post a reply to the status of a local user that allows replies. @@ -271,14 +222,14 @@ func (suite *StatusCreateTestSuite) TestReplyToLocalStatus() { b, err := ioutil.ReadAll(result.Body) assert.NoError(suite.T(), err) - statusReply := &mastomodel.Status{} + statusReply := &model.Status{} err = json.Unmarshal(b, statusReply) assert.NoError(suite.T(), err) assert.Equal(suite.T(), "", statusReply.SpoilerText) assert.Equal(suite.T(), fmt.Sprintf("hello @%s this reply should work!", testrig.NewTestAccounts()["local_account_2"].Username), statusReply.Content) assert.False(suite.T(), statusReply.Sensitive) - assert.Equal(suite.T(), mastomodel.VisibilityPublic, statusReply.Visibility) + assert.Equal(suite.T(), model.VisibilityPublic, statusReply.Visibility) assert.Equal(suite.T(), testrig.NewTestStatuses()["local_account_2_status_1"].ID, statusReply.InReplyToID) assert.Equal(suite.T(), testrig.NewTestAccounts()["local_account_2"].ID, statusReply.InReplyToAccountID) assert.Len(suite.T(), statusReply.Mentions, 1) @@ -313,14 +264,14 @@ func (suite *StatusCreateTestSuite) TestAttachNewMediaSuccess() { fmt.Println(string(b)) - statusReply := &mastomodel.Status{} + statusReply := &model.Status{} err = json.Unmarshal(b, statusReply) assert.NoError(suite.T(), err) assert.Equal(suite.T(), "", statusReply.SpoilerText) assert.Equal(suite.T(), "here's an image attachment", statusReply.Content) assert.False(suite.T(), statusReply.Sensitive) - assert.Equal(suite.T(), mastomodel.VisibilityPublic, statusReply.Visibility) + assert.Equal(suite.T(), model.VisibilityPublic, statusReply.Visibility) // there should be one media attachment assert.Len(suite.T(), statusReply.MediaAttachments, 1) @@ -331,7 +282,7 @@ func (suite *StatusCreateTestSuite) TestAttachNewMediaSuccess() { assert.NoError(suite.T(), err) // convert it to a masto attachment - gtsAttachmentAsMasto, err := suite.mastoConverter.AttachmentToMasto(gtsAttachment) + gtsAttachmentAsMasto, err := suite.tc.AttachmentToMasto(gtsAttachment) assert.NoError(suite.T(), err) // compare it with what we have now diff --git a/internal/api/client/status/statusdelete.go b/internal/api/client/status/statusdelete.go new file mode 100644 index 0000000..e554165 --- /dev/null +++ b/internal/api/client/status/statusdelete.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 . +*/ + +package status + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// StatusDELETEHandler verifies and handles deletion of a status +func (m *Module) StatusDELETEHandler(c *gin.Context) { + l := m.log.WithFields(logrus.Fields{ + "func": "StatusDELETEHandler", + "request_uri": c.Request.RequestURI, + "user_agent": c.Request.UserAgent(), + "origin_ip": c.ClientIP(), + }) + l.Debugf("entering function") + + authed, err := oauth.Authed(c, true, false, true, true) // we don't really need an app here but we want everything else + if err != nil { + l.Debug("not authed so can't delete status") + c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"}) + return + } + + targetStatusID := c.Param(IDKey) + if targetStatusID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"}) + return + } + + mastoStatus, err := m.processor.StatusDelete(authed, targetStatusID) + if err != nil { + l.Debugf("error processing status delete: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"}) + return + } + + c.JSON(http.StatusOK, mastoStatus) +} diff --git a/internal/api/client/status/statusfave.go b/internal/api/client/status/statusfave.go new file mode 100644 index 0000000..888589a --- /dev/null +++ b/internal/api/client/status/statusfave.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 . +*/ + +package status + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// StatusFavePOSTHandler handles fave requests against a given status ID +func (m *Module) StatusFavePOSTHandler(c *gin.Context) { + l := m.log.WithFields(logrus.Fields{ + "func": "StatusFavePOSTHandler", + "request_uri": c.Request.RequestURI, + "user_agent": c.Request.UserAgent(), + "origin_ip": c.ClientIP(), + }) + l.Debugf("entering function") + + authed, err := oauth.Authed(c, true, false, true, true) // we don't really need an app here but we want everything else + if err != nil { + l.Debug("not authed so can't fave status") + c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"}) + return + } + + targetStatusID := c.Param(IDKey) + if targetStatusID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"}) + return + } + + mastoStatus, err := m.processor.StatusFave(authed, targetStatusID) + if err != nil { + l.Debugf("error processing status fave: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"}) + return + } + + c.JSON(http.StatusOK, mastoStatus) +} diff --git a/internal/apimodule/status/test/statusfave_test.go b/internal/api/client/status/statusfave_test.go similarity index 67% rename from internal/apimodule/status/test/statusfave_test.go rename to internal/api/client/status/statusfave_test.go index 9ccf589..2f779ba 100644 --- a/internal/apimodule/status/test/statusfave_test.go +++ b/internal/api/client/status/statusfave_test.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package status +package status_test import ( "encoding/json" @@ -28,75 +28,19 @@ import ( "testing" "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/apimodule/status" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/distributor" - "github.com/superseriousbusiness/gotosocial/internal/mastotypes" - mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" - "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/api/client/status" + "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/storage" "github.com/superseriousbusiness/gotosocial/testrig" ) type StatusFaveTestSuite struct { - // standard suite interfaces - suite.Suite - config *config.Config - db db.DB - log *logrus.Logger - storage storage.Storage - mastoConverter mastotypes.Converter - mediaHandler media.Handler - oauthServer oauth.Server - distributor distributor.Distributor - - // standard suite models - testTokens map[string]*oauth.Token - testClients map[string]*oauth.Client - testApplications map[string]*gtsmodel.Application - testUsers map[string]*gtsmodel.User - testAccounts map[string]*gtsmodel.Account - testAttachments map[string]*gtsmodel.MediaAttachment - testStatuses map[string]*gtsmodel.Status - - // module being tested - statusModule *status.Module + StatusStandardTestSuite } -/* - TEST INFRASTRUCTURE -*/ - -// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout func (suite *StatusFaveTestSuite) SetupSuite() { - // setup standard items - suite.config = testrig.NewTestConfig() - suite.db = testrig.NewTestDB() - suite.log = testrig.NewTestLog() - suite.storage = testrig.NewTestStorage() - suite.mastoConverter = testrig.NewTestMastoConverter(suite.db) - suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage) - suite.oauthServer = testrig.NewTestOauthServer(suite.db) - suite.distributor = testrig.NewTestDistributor() - - // setup module being tested - suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.Module) -} - -func (suite *StatusFaveTestSuite) TearDownSuite() { - testrig.StandardDBTeardown(suite.db) - testrig.StandardStorageTeardown(suite.storage) -} - -func (suite *StatusFaveTestSuite) SetupTest() { - testrig.StandardDBSetup(suite.db) - testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") suite.testTokens = testrig.NewTestTokens() suite.testClients = testrig.NewTestClients() suite.testApplications = testrig.NewTestApplications() @@ -106,16 +50,23 @@ func (suite *StatusFaveTestSuite) SetupTest() { suite.testStatuses = testrig.NewTestStatuses() } -// TearDownTest drops tables to make sure there's no data in the db +func (suite *StatusFaveTestSuite) SetupTest() { + suite.config = testrig.NewTestConfig() + suite.db = testrig.NewTestDB() + suite.storage = testrig.NewTestStorage() + suite.log = testrig.NewTestLog() + suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil))) + 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) + testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") +} + func (suite *StatusFaveTestSuite) TearDownTest() { testrig.StandardDBTeardown(suite.db) testrig.StandardStorageTeardown(suite.storage) } -/* - ACTUAL TESTS -*/ - // fave a status func (suite *StatusFaveTestSuite) TestPostFave() { @@ -152,14 +103,14 @@ func (suite *StatusFaveTestSuite) TestPostFave() { b, err := ioutil.ReadAll(result.Body) assert.NoError(suite.T(), err) - statusReply := &mastomodel.Status{} + statusReply := &model.Status{} err = json.Unmarshal(b, statusReply) assert.NoError(suite.T(), err) assert.Equal(suite.T(), targetStatus.ContentWarning, statusReply.SpoilerText) assert.Equal(suite.T(), targetStatus.Content, statusReply.Content) assert.True(suite.T(), statusReply.Sensitive) - assert.Equal(suite.T(), mastomodel.VisibilityPublic, statusReply.Visibility) + assert.Equal(suite.T(), model.VisibilityPublic, statusReply.Visibility) assert.True(suite.T(), statusReply.Favourited) assert.Equal(suite.T(), 1, statusReply.FavouritesCount) } @@ -193,13 +144,13 @@ func (suite *StatusFaveTestSuite) TestPostUnfaveable() { suite.statusModule.StatusFavePOSTHandler(ctx) // check response - suite.EqualValues(http.StatusForbidden, recorder.Code) // we 403 unlikeable statuses + suite.EqualValues(http.StatusBadRequest, recorder.Code) result := recorder.Result() defer result.Body.Close() b, err := ioutil.ReadAll(result.Body) assert.NoError(suite.T(), err) - assert.Equal(suite.T(), fmt.Sprintf(`{"error":"status %s not faveable"}`, targetStatus.ID), string(b)) + assert.Equal(suite.T(), `{"error":"bad request"}`, string(b)) } func TestStatusFaveTestSuite(t *testing.T) { diff --git a/internal/api/client/status/statusfavedby.go b/internal/api/client/status/statusfavedby.go new file mode 100644 index 0000000..799acb7 --- /dev/null +++ b/internal/api/client/status/statusfavedby.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 . +*/ + +package status + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// StatusFavedByGETHandler is for serving a list of accounts that have faved a given status +func (m *Module) StatusFavedByGETHandler(c *gin.Context) { + l := m.log.WithFields(logrus.Fields{ + "func": "statusGETHandler", + "request_uri": c.Request.RequestURI, + "user_agent": c.Request.UserAgent(), + "origin_ip": c.ClientIP(), + }) + l.Debugf("entering function") + + authed, err := oauth.Authed(c, false, false, false, false) // we don't really need an app here but we want everything else + if err != nil { + l.Errorf("error authing status faved by request: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "not authed"}) + return + } + + targetStatusID := c.Param(IDKey) + if targetStatusID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"}) + return + } + + mastoAccounts, err := m.processor.StatusFavedBy(authed, targetStatusID) + if err != nil { + l.Debugf("error processing status faved by request: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"}) + return + } + + c.JSON(http.StatusOK, mastoAccounts) +} diff --git a/internal/apimodule/status/test/statusfavedby_test.go b/internal/api/client/status/statusfavedby_test.go similarity index 62% rename from internal/apimodule/status/test/statusfavedby_test.go rename to internal/api/client/status/statusfavedby_test.go index 169543a..7b72df7 100644 --- a/internal/apimodule/status/test/statusfavedby_test.go +++ b/internal/api/client/status/statusfavedby_test.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package status +package status_test import ( "encoding/json" @@ -28,71 +28,19 @@ import ( "testing" "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/apimodule/status" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/distributor" - "github.com/superseriousbusiness/gotosocial/internal/mastotypes" - mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" - "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/api/client/status" + "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/storage" "github.com/superseriousbusiness/gotosocial/testrig" ) type StatusFavedByTestSuite struct { - // standard suite interfaces - suite.Suite - config *config.Config - db db.DB - log *logrus.Logger - storage storage.Storage - mastoConverter mastotypes.Converter - mediaHandler media.Handler - oauthServer oauth.Server - distributor distributor.Distributor - - // standard suite models - testTokens map[string]*oauth.Token - testClients map[string]*oauth.Client - testApplications map[string]*gtsmodel.Application - testUsers map[string]*gtsmodel.User - testAccounts map[string]*gtsmodel.Account - testAttachments map[string]*gtsmodel.MediaAttachment - testStatuses map[string]*gtsmodel.Status - - // module being tested - statusModule *status.Module + StatusStandardTestSuite } -// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout func (suite *StatusFavedByTestSuite) SetupSuite() { - // setup standard items - suite.config = testrig.NewTestConfig() - suite.db = testrig.NewTestDB() - suite.log = testrig.NewTestLog() - suite.storage = testrig.NewTestStorage() - suite.mastoConverter = testrig.NewTestMastoConverter(suite.db) - suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage) - suite.oauthServer = testrig.NewTestOauthServer(suite.db) - suite.distributor = testrig.NewTestDistributor() - - // setup module being tested - suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.Module) -} - -func (suite *StatusFavedByTestSuite) TearDownSuite() { - testrig.StandardDBTeardown(suite.db) - testrig.StandardStorageTeardown(suite.storage) -} - -func (suite *StatusFavedByTestSuite) SetupTest() { - testrig.StandardDBSetup(suite.db) - testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") suite.testTokens = testrig.NewTestTokens() suite.testClients = testrig.NewTestClients() suite.testApplications = testrig.NewTestApplications() @@ -102,16 +50,23 @@ func (suite *StatusFavedByTestSuite) SetupTest() { suite.testStatuses = testrig.NewTestStatuses() } -// TearDownTest drops tables to make sure there's no data in the db +func (suite *StatusFavedByTestSuite) SetupTest() { + suite.config = testrig.NewTestConfig() + suite.db = testrig.NewTestDB() + suite.storage = testrig.NewTestStorage() + suite.log = testrig.NewTestLog() + suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil))) + 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) + testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") +} + func (suite *StatusFavedByTestSuite) TearDownTest() { testrig.StandardDBTeardown(suite.db) testrig.StandardStorageTeardown(suite.storage) } -/* - ACTUAL TESTS -*/ - func (suite *StatusFavedByTestSuite) TestGetFavedBy() { t := suite.testTokens["local_account_2"] oauthToken := oauth.TokenToOauthToken(t) @@ -146,7 +101,7 @@ func (suite *StatusFavedByTestSuite) TestGetFavedBy() { b, err := ioutil.ReadAll(result.Body) assert.NoError(suite.T(), err) - accts := []mastomodel.Account{} + accts := []model.Account{} err = json.Unmarshal(b, &accts) assert.NoError(suite.T(), err) diff --git a/internal/api/client/status/statusget.go b/internal/api/client/status/statusget.go new file mode 100644 index 0000000..c6239cb --- /dev/null +++ b/internal/api/client/status/statusget.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 . +*/ + +package status + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// StatusGETHandler is for handling requests to just get one status based on its ID +func (m *Module) StatusGETHandler(c *gin.Context) { + l := m.log.WithFields(logrus.Fields{ + "func": "statusGETHandler", + "request_uri": c.Request.RequestURI, + "user_agent": c.Request.UserAgent(), + "origin_ip": c.ClientIP(), + }) + l.Debugf("entering function") + + authed, err := oauth.Authed(c, false, false, false, false) // we don't really need an app here but we want everything else + if err != nil { + l.Errorf("error authing status faved by request: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "not authed"}) + return + } + + targetStatusID := c.Param(IDKey) + if targetStatusID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"}) + return + } + + mastoStatus, err := m.processor.StatusGet(authed, targetStatusID) + if err != nil { + l.Debugf("error processing status get: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"}) + return + } + + c.JSON(http.StatusOK, mastoStatus) +} diff --git a/internal/apimodule/status/test/statusget_test.go b/internal/api/client/status/statusget_test.go similarity index 62% rename from internal/apimodule/status/test/statusget_test.go rename to internal/api/client/status/statusget_test.go index ce817d2..b31aceb 100644 --- a/internal/apimodule/status/test/statusget_test.go +++ b/internal/api/client/status/statusget_test.go @@ -16,98 +16,47 @@ along with this program. If not, see . */ -package status +package status_test import ( "testing" - "github.com/sirupsen/logrus" "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/apimodule/status" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/distributor" - "github.com/superseriousbusiness/gotosocial/internal/mastotypes" - "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/storage" + "github.com/superseriousbusiness/gotosocial/internal/api/client/status" "github.com/superseriousbusiness/gotosocial/testrig" ) type StatusGetTestSuite struct { - // standard suite interfaces - suite.Suite - config *config.Config - db db.DB - log *logrus.Logger - storage storage.Storage - mastoConverter mastotypes.Converter - mediaHandler media.Handler - oauthServer oauth.Server - distributor distributor.Distributor - - // standard suite models - testTokens map[string]*oauth.Token - testClients map[string]*oauth.Client - testApplications map[string]*gtsmodel.Application - testUsers map[string]*gtsmodel.User - testAccounts map[string]*gtsmodel.Account - testAttachments map[string]*gtsmodel.MediaAttachment - - // module being tested - statusModule *status.Module + StatusStandardTestSuite } -/* - TEST INFRASTRUCTURE -*/ - -// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout func (suite *StatusGetTestSuite) SetupSuite() { - // setup standard items - suite.config = testrig.NewTestConfig() - suite.db = testrig.NewTestDB() - suite.log = testrig.NewTestLog() - suite.storage = testrig.NewTestStorage() - suite.mastoConverter = testrig.NewTestMastoConverter(suite.db) - suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage) - suite.oauthServer = testrig.NewTestOauthServer(suite.db) - suite.distributor = testrig.NewTestDistributor() - - // setup module being tested - suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.Module) -} - -func (suite *StatusGetTestSuite) TearDownSuite() { - testrig.StandardDBTeardown(suite.db) - testrig.StandardStorageTeardown(suite.storage) -} - -func (suite *StatusGetTestSuite) SetupTest() { - testrig.StandardDBSetup(suite.db) - testrig.StandardStorageSetup(suite.storage, "../../../testrig/media") suite.testTokens = testrig.NewTestTokens() suite.testClients = testrig.NewTestClients() suite.testApplications = testrig.NewTestApplications() suite.testUsers = testrig.NewTestUsers() suite.testAccounts = testrig.NewTestAccounts() suite.testAttachments = testrig.NewTestAttachments() + suite.testStatuses = testrig.NewTestStatuses() +} + +func (suite *StatusGetTestSuite) SetupTest() { + suite.config = testrig.NewTestConfig() + suite.db = testrig.NewTestDB() + suite.storage = testrig.NewTestStorage() + suite.log = testrig.NewTestLog() + suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil))) + 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) + testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") } -// TearDownTest drops tables to make sure there's no data in the db func (suite *StatusGetTestSuite) TearDownTest() { testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) } -/* - ACTUAL TESTS -*/ - -/* - TESTING: StatusGetPOSTHandler -*/ - // Post a new status with some custom visibility settings func (suite *StatusGetTestSuite) TestPostNewStatus() { @@ -143,16 +92,16 @@ func (suite *StatusGetTestSuite) TestPostNewStatus() { // b, err := ioutil.ReadAll(result.Body) // assert.NoError(suite.T(), err) - // statusReply := &mastomodel.Status{} + // statusReply := &mastotypes.Status{} // err = json.Unmarshal(b, statusReply) // assert.NoError(suite.T(), err) // assert.Equal(suite.T(), "hello hello", statusReply.SpoilerText) // assert.Equal(suite.T(), "this is a brand new status! #helloworld", statusReply.Content) // assert.True(suite.T(), statusReply.Sensitive) - // assert.Equal(suite.T(), mastomodel.VisibilityPrivate, statusReply.Visibility) + // assert.Equal(suite.T(), mastotypes.VisibilityPrivate, statusReply.Visibility) // assert.Len(suite.T(), statusReply.Tags, 1) - // assert.Equal(suite.T(), mastomodel.Tag{ + // assert.Equal(suite.T(), mastotypes.Tag{ // Name: "helloworld", // URL: "http://localhost:8080/tags/helloworld", // }, statusReply.Tags[0]) diff --git a/internal/api/client/status/statusunfave.go b/internal/api/client/status/statusunfave.go new file mode 100644 index 0000000..94fd662 --- /dev/null +++ b/internal/api/client/status/statusunfave.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 . +*/ + +package status + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// StatusUnfavePOSTHandler is for undoing a fave on a status with a given ID +func (m *Module) StatusUnfavePOSTHandler(c *gin.Context) { + l := m.log.WithFields(logrus.Fields{ + "func": "StatusUnfavePOSTHandler", + "request_uri": c.Request.RequestURI, + "user_agent": c.Request.UserAgent(), + "origin_ip": c.ClientIP(), + }) + l.Debugf("entering function") + + authed, err := oauth.Authed(c, true, false, true, true) // we don't really need an app here but we want everything else + if err != nil { + l.Debug("not authed so can't unfave status") + c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"}) + return + } + + targetStatusID := c.Param(IDKey) + if targetStatusID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"}) + return + } + + mastoStatus, err := m.processor.StatusUnfave(authed, targetStatusID) + if err != nil { + l.Debugf("error processing status unfave: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"}) + return + } + + c.JSON(http.StatusOK, mastoStatus) +} diff --git a/internal/apimodule/status/test/statusunfave_test.go b/internal/api/client/status/statusunfave_test.go similarity index 70% rename from internal/apimodule/status/test/statusunfave_test.go rename to internal/api/client/status/statusunfave_test.go index 5f52779..44b1dd3 100644 --- a/internal/apimodule/status/test/statusunfave_test.go +++ b/internal/api/client/status/statusunfave_test.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package status +package status_test import ( "encoding/json" @@ -28,75 +28,19 @@ import ( "testing" "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/apimodule/status" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/distributor" - "github.com/superseriousbusiness/gotosocial/internal/mastotypes" - mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" - "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/api/client/status" + "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/storage" "github.com/superseriousbusiness/gotosocial/testrig" ) type StatusUnfaveTestSuite struct { - // standard suite interfaces - suite.Suite - config *config.Config - db db.DB - log *logrus.Logger - storage storage.Storage - mastoConverter mastotypes.Converter - mediaHandler media.Handler - oauthServer oauth.Server - distributor distributor.Distributor - - // standard suite models - testTokens map[string]*oauth.Token - testClients map[string]*oauth.Client - testApplications map[string]*gtsmodel.Application - testUsers map[string]*gtsmodel.User - testAccounts map[string]*gtsmodel.Account - testAttachments map[string]*gtsmodel.MediaAttachment - testStatuses map[string]*gtsmodel.Status - - // module being tested - statusModule *status.Module + StatusStandardTestSuite } -/* - TEST INFRASTRUCTURE -*/ - -// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout func (suite *StatusUnfaveTestSuite) SetupSuite() { - // setup standard items - suite.config = testrig.NewTestConfig() - suite.db = testrig.NewTestDB() - suite.log = testrig.NewTestLog() - suite.storage = testrig.NewTestStorage() - suite.mastoConverter = testrig.NewTestMastoConverter(suite.db) - suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage) - suite.oauthServer = testrig.NewTestOauthServer(suite.db) - suite.distributor = testrig.NewTestDistributor() - - // setup module being tested - suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.Module) -} - -func (suite *StatusUnfaveTestSuite) TearDownSuite() { - testrig.StandardDBTeardown(suite.db) - testrig.StandardStorageTeardown(suite.storage) -} - -func (suite *StatusUnfaveTestSuite) SetupTest() { - testrig.StandardDBSetup(suite.db) - testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") suite.testTokens = testrig.NewTestTokens() suite.testClients = testrig.NewTestClients() suite.testApplications = testrig.NewTestApplications() @@ -106,16 +50,23 @@ func (suite *StatusUnfaveTestSuite) SetupTest() { suite.testStatuses = testrig.NewTestStatuses() } -// TearDownTest drops tables to make sure there's no data in the db +func (suite *StatusUnfaveTestSuite) SetupTest() { + suite.config = testrig.NewTestConfig() + suite.db = testrig.NewTestDB() + suite.storage = testrig.NewTestStorage() + suite.log = testrig.NewTestLog() + suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil))) + 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) + testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") +} + func (suite *StatusUnfaveTestSuite) TearDownTest() { testrig.StandardDBTeardown(suite.db) testrig.StandardStorageTeardown(suite.storage) } -/* - ACTUAL TESTS -*/ - // unfave a status func (suite *StatusUnfaveTestSuite) TestPostUnfave() { @@ -153,14 +104,14 @@ func (suite *StatusUnfaveTestSuite) TestPostUnfave() { b, err := ioutil.ReadAll(result.Body) assert.NoError(suite.T(), err) - statusReply := &mastomodel.Status{} + statusReply := &model.Status{} err = json.Unmarshal(b, statusReply) assert.NoError(suite.T(), err) assert.Equal(suite.T(), targetStatus.ContentWarning, statusReply.SpoilerText) assert.Equal(suite.T(), targetStatus.Content, statusReply.Content) assert.False(suite.T(), statusReply.Sensitive) - assert.Equal(suite.T(), mastomodel.VisibilityPublic, statusReply.Visibility) + assert.Equal(suite.T(), model.VisibilityPublic, statusReply.Visibility) assert.False(suite.T(), statusReply.Favourited) assert.Equal(suite.T(), 0, statusReply.FavouritesCount) } @@ -202,14 +153,14 @@ func (suite *StatusUnfaveTestSuite) TestPostAlreadyNotFaved() { b, err := ioutil.ReadAll(result.Body) assert.NoError(suite.T(), err) - statusReply := &mastomodel.Status{} + statusReply := &model.Status{} err = json.Unmarshal(b, statusReply) assert.NoError(suite.T(), err) assert.Equal(suite.T(), targetStatus.ContentWarning, statusReply.SpoilerText) assert.Equal(suite.T(), targetStatus.Content, statusReply.Content) assert.True(suite.T(), statusReply.Sensitive) - assert.Equal(suite.T(), mastomodel.VisibilityPublic, statusReply.Visibility) + assert.Equal(suite.T(), model.VisibilityPublic, statusReply.Visibility) assert.False(suite.T(), statusReply.Favourited) assert.Equal(suite.T(), 0, statusReply.FavouritesCount) } diff --git a/internal/mastotypes/mastomodel/account.go b/internal/api/model/account.go similarity index 97% rename from internal/mastotypes/mastomodel/account.go rename to internal/api/model/account.go index bbcf9c9..efb69d6 100644 --- a/internal/mastotypes/mastomodel/account.go +++ b/internal/api/model/account.go @@ -16,9 +16,12 @@ along with this program. If not, see . */ -package mastotypes +package model -import "mime/multipart" +import ( + "mime/multipart" + "net" +) // Account represents a mastodon-api Account object, as described here: https://docs.joinmastodon.org/entities/account/ type Account struct { @@ -86,6 +89,8 @@ type AccountCreateRequest struct { Agreement bool `form:"agreement" binding:"required"` // The language of the confirmation email that will be sent Locale string `form:"locale" binding:"required"` + // The IP of the sign up request, will not be parsed from the form but must be added manually + IP net.IP `form:"-"` } // UpdateCredentialsRequest represents the form submitted during a PATCH request to /api/v1/accounts/update_credentials. diff --git a/internal/mastotypes/mastomodel/activity.go b/internal/api/model/activity.go similarity index 98% rename from internal/mastotypes/mastomodel/activity.go rename to internal/api/model/activity.go index b8dbf2c..c1736a8 100644 --- a/internal/mastotypes/mastomodel/activity.go +++ b/internal/api/model/activity.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package mastotypes +package model // Activity represents the mastodon-api Activity type. See here: https://docs.joinmastodon.org/entities/activity/ type Activity struct { diff --git a/internal/mastotypes/mastomodel/admin.go b/internal/api/model/admin.go similarity index 99% rename from internal/mastotypes/mastomodel/admin.go rename to internal/api/model/admin.go index 71c2bb3..036218f 100644 --- a/internal/mastotypes/mastomodel/admin.go +++ b/internal/api/model/admin.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package mastotypes +package model // AdminAccountInfo represents the *admin* view of an account's details. See here: https://docs.joinmastodon.org/entities/admin-account/ type AdminAccountInfo struct { diff --git a/internal/mastotypes/mastomodel/announcement.go b/internal/api/model/announcement.go similarity index 98% rename from internal/mastotypes/mastomodel/announcement.go rename to internal/api/model/announcement.go index 882d6bb..eeb4b87 100644 --- a/internal/mastotypes/mastomodel/announcement.go +++ b/internal/api/model/announcement.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package mastotypes +package model // Announcement represents an admin/moderator announcement for local users. See here: https://docs.joinmastodon.org/entities/announcement/ type Announcement struct { diff --git a/internal/mastotypes/mastomodel/announcementreaction.go b/internal/api/model/announcementreaction.go similarity index 98% rename from internal/mastotypes/mastomodel/announcementreaction.go rename to internal/api/model/announcementreaction.go index 444c57e..81118fe 100644 --- a/internal/mastotypes/mastomodel/announcementreaction.go +++ b/internal/api/model/announcementreaction.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package mastotypes +package model // AnnouncementReaction represents a user reaction to admin/moderator announcement. See here: https://docs.joinmastodon.org/entities/announcementreaction/ type AnnouncementReaction struct { diff --git a/internal/mastotypes/mastomodel/application.go b/internal/api/model/application.go similarity index 94% rename from internal/mastotypes/mastomodel/application.go rename to internal/api/model/application.go index 6140a01..a796c88 100644 --- a/internal/mastotypes/mastomodel/application.go +++ b/internal/api/model/application.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package mastotypes +package model // Application represents a mastodon-api Application, as defined here: https://docs.joinmastodon.org/entities/application/. // Primarily, application is used for allowing apps like Tusky etc to connect to Mastodon on behalf of a user. @@ -38,10 +38,10 @@ type Application struct { VapidKey string `json:"vapid_key,omitempty"` } -// ApplicationPOSTRequest represents a POST request to https://example.org/api/v1/apps. +// ApplicationCreateRequest represents a POST request to https://example.org/api/v1/apps. // See here: https://docs.joinmastodon.org/methods/apps/ // And here: https://docs.joinmastodon.org/client/token/ -type ApplicationPOSTRequest struct { +type ApplicationCreateRequest struct { // A name for your application ClientName string `form:"client_name" binding:"required"` // Where the user should be redirected after authorization. diff --git a/internal/mastotypes/mastomodel/attachment.go b/internal/api/model/attachment.go similarity index 99% rename from internal/mastotypes/mastomodel/attachment.go rename to internal/api/model/attachment.go index bda79a8..d90247f 100644 --- a/internal/mastotypes/mastomodel/attachment.go +++ b/internal/api/model/attachment.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package mastotypes +package model import "mime/multipart" diff --git a/internal/mastotypes/mastomodel/card.go b/internal/api/model/card.go similarity index 99% rename from internal/mastotypes/mastomodel/card.go rename to internal/api/model/card.go index d1147e0..ffa6d53 100644 --- a/internal/mastotypes/mastomodel/card.go +++ b/internal/api/model/card.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package mastotypes +package model // Card represents a rich preview card that is generated using OpenGraph tags from a URL. See here: https://docs.joinmastodon.org/entities/card/ type Card struct { diff --git a/internal/api/model/content.go b/internal/api/model/content.go new file mode 100644 index 0000000..4f004f1 --- /dev/null +++ b/internal/api/model/content.go @@ -0,0 +1,41 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package model + +// Content wraps everything needed to serve a blob of content (some kind of media) through the API. +type Content struct { + // MIME content type + ContentType string + // ContentLength in bytes + ContentLength int64 + // Actual content blob + Content []byte +} + +// GetContentRequestForm describes a piece of content desired by the caller of the fileserver API. +type GetContentRequestForm struct { + // AccountID of the content owner + AccountID string + // MediaType of the content (should be convertible to a media.MediaType) + MediaType string + // MediaSize of the content (should be convertible to a media.MediaSize) + MediaSize string + // Filename of the content + FileName string +} diff --git a/internal/mastotypes/mastomodel/context.go b/internal/api/model/context.go similarity index 98% rename from internal/mastotypes/mastomodel/context.go rename to internal/api/model/context.go index 397522d..d097931 100644 --- a/internal/mastotypes/mastomodel/context.go +++ b/internal/api/model/context.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package mastotypes +package model // Context represents the tree around a given status. Used for reconstructing threads of statuses. See: https://docs.joinmastodon.org/entities/context/ type Context struct { diff --git a/internal/mastotypes/mastomodel/conversation.go b/internal/api/model/conversation.go similarity index 98% rename from internal/mastotypes/mastomodel/conversation.go rename to internal/api/model/conversation.go index ed95c12..b0568c1 100644 --- a/internal/mastotypes/mastomodel/conversation.go +++ b/internal/api/model/conversation.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package mastotypes +package model // Conversation represents a conversation with "direct message" visibility. See https://docs.joinmastodon.org/entities/conversation/ type Conversation struct { diff --git a/internal/mastotypes/mastomodel/emoji.go b/internal/api/model/emoji.go similarity index 98% rename from internal/mastotypes/mastomodel/emoji.go rename to internal/api/model/emoji.go index c50ca63..c283471 100644 --- a/internal/mastotypes/mastomodel/emoji.go +++ b/internal/api/model/emoji.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package mastotypes +package model import "mime/multipart" diff --git a/internal/mastotypes/mastomodel/error.go b/internal/api/model/error.go similarity index 98% rename from internal/mastotypes/mastomodel/error.go rename to internal/api/model/error.go index 3940857..f145d69 100644 --- a/internal/mastotypes/mastomodel/error.go +++ b/internal/api/model/error.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package mastotypes +package model // Error represents an error message returned from the API. See https://docs.joinmastodon.org/entities/error/ type Error struct { diff --git a/internal/mastotypes/mastomodel/featuredtag.go b/internal/api/model/featuredtag.go similarity index 98% rename from internal/mastotypes/mastomodel/featuredtag.go rename to internal/api/model/featuredtag.go index 0e0bbe8..3df3fe4 100644 --- a/internal/mastotypes/mastomodel/featuredtag.go +++ b/internal/api/model/featuredtag.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package mastotypes +package model // FeaturedTag represents a hashtag that is featured on a profile. See https://docs.joinmastodon.org/entities/featuredtag/ type FeaturedTag struct { diff --git a/internal/mastotypes/mastomodel/field.go b/internal/api/model/field.go similarity index 98% rename from internal/mastotypes/mastomodel/field.go rename to internal/api/model/field.go index 29b5a18..2e7662b 100644 --- a/internal/mastotypes/mastomodel/field.go +++ b/internal/api/model/field.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package mastotypes +package model // Field represents a profile field as a name-value pair with optional verification. See https://docs.joinmastodon.org/entities/field/ type Field struct { diff --git a/internal/mastotypes/mastomodel/filter.go b/internal/api/model/filter.go similarity index 99% rename from internal/mastotypes/mastomodel/filter.go rename to internal/api/model/filter.go index 86d9795..519922b 100644 --- a/internal/mastotypes/mastomodel/filter.go +++ b/internal/api/model/filter.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package mastotypes +package model // Filter represents a user-defined filter for determining which statuses should not be shown to the user. See https://docs.joinmastodon.org/entities/filter/ // If whole_word is true , client app should do: diff --git a/internal/mastotypes/mastomodel/history.go b/internal/api/model/history.go similarity index 98% rename from internal/mastotypes/mastomodel/history.go rename to internal/api/model/history.go index 2357613..d8b4d6b 100644 --- a/internal/mastotypes/mastomodel/history.go +++ b/internal/api/model/history.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package mastotypes +package model // History represents daily usage history of a hashtag. See https://docs.joinmastodon.org/entities/history/ type History struct { diff --git a/internal/mastotypes/mastomodel/identityproof.go b/internal/api/model/identityproof.go similarity index 98% rename from internal/mastotypes/mastomodel/identityproof.go rename to internal/api/model/identityproof.go index 7265d46..400835f 100644 --- a/internal/mastotypes/mastomodel/identityproof.go +++ b/internal/api/model/identityproof.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package mastotypes +package model // IdentityProof represents a proof from an external identity provider. See https://docs.joinmastodon.org/entities/identityproof/ type IdentityProof struct { diff --git a/internal/mastotypes/mastomodel/instance.go b/internal/api/model/instance.go similarity index 99% rename from internal/mastotypes/mastomodel/instance.go rename to internal/api/model/instance.go index 10e626a..857a8ac 100644 --- a/internal/mastotypes/mastomodel/instance.go +++ b/internal/api/model/instance.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package mastotypes +package model // Instance represents the software instance of Mastodon running on this domain. See https://docs.joinmastodon.org/entities/instance/ type Instance struct { diff --git a/internal/mastotypes/mastomodel/list.go b/internal/api/model/list.go similarity index 98% rename from internal/mastotypes/mastomodel/list.go rename to internal/api/model/list.go index 5b70436..220cde5 100644 --- a/internal/mastotypes/mastomodel/list.go +++ b/internal/api/model/list.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package mastotypes +package model // List represents a list of some users that the authenticated user follows. See https://docs.joinmastodon.org/entities/list/ type List struct { diff --git a/internal/mastotypes/mastomodel/marker.go b/internal/api/model/marker.go similarity index 98% rename from internal/mastotypes/mastomodel/marker.go rename to internal/api/model/marker.go index 7903223..1e39f15 100644 --- a/internal/mastotypes/mastomodel/marker.go +++ b/internal/api/model/marker.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package mastotypes +package model // Marker represents the last read position within a user's timelines. See https://docs.joinmastodon.org/entities/marker/ type Marker struct { diff --git a/internal/mastotypes/mastomodel/mention.go b/internal/api/model/mention.go similarity index 98% rename from internal/mastotypes/mastomodel/mention.go rename to internal/api/model/mention.go index 81a593d..a7985af 100644 --- a/internal/mastotypes/mastomodel/mention.go +++ b/internal/api/model/mention.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package mastotypes +package model // Mention represents the mastodon-api mention type, as documented here: https://docs.joinmastodon.org/entities/mention/ type Mention struct { diff --git a/internal/mastotypes/mastomodel/notification.go b/internal/api/model/notification.go similarity index 98% rename from internal/mastotypes/mastomodel/notification.go rename to internal/api/model/notification.go index 26d361b..c8d080e 100644 --- a/internal/mastotypes/mastomodel/notification.go +++ b/internal/api/model/notification.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package mastotypes +package model // Notification represents a notification of an event relevant to the user. See https://docs.joinmastodon.org/entities/notification/ type Notification struct { diff --git a/internal/mastotypes/mastomodel/oauth.go b/internal/api/model/oauth.go similarity index 98% rename from internal/mastotypes/mastomodel/oauth.go rename to internal/api/model/oauth.go index d93ea07..250d221 100644 --- a/internal/mastotypes/mastomodel/oauth.go +++ b/internal/api/model/oauth.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package mastotypes +package model // OAuthAuthorize represents a request sent to https://example.org/oauth/authorize // See here: https://docs.joinmastodon.org/methods/apps/oauth/ diff --git a/internal/mastotypes/mastomodel/poll.go b/internal/api/model/poll.go similarity index 99% rename from internal/mastotypes/mastomodel/poll.go rename to internal/api/model/poll.go index bedaebe..b00e768 100644 --- a/internal/mastotypes/mastomodel/poll.go +++ b/internal/api/model/poll.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package mastotypes +package model // Poll represents the mastodon-api poll type, as described here: https://docs.joinmastodon.org/entities/poll/ type Poll struct { diff --git a/internal/mastotypes/mastomodel/preferences.go b/internal/api/model/preferences.go similarity index 98% rename from internal/mastotypes/mastomodel/preferences.go rename to internal/api/model/preferences.go index c28f5d5..9e41009 100644 --- a/internal/mastotypes/mastomodel/preferences.go +++ b/internal/api/model/preferences.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package mastotypes +package model // Preferences represents a user's preferences. See https://docs.joinmastodon.org/entities/preferences/ type Preferences struct { diff --git a/internal/mastotypes/mastomodel/pushsubscription.go b/internal/api/model/pushsubscription.go similarity index 99% rename from internal/mastotypes/mastomodel/pushsubscription.go rename to internal/api/model/pushsubscription.go index 4d75351..f34c633 100644 --- a/internal/mastotypes/mastomodel/pushsubscription.go +++ b/internal/api/model/pushsubscription.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package mastotypes +package model // PushSubscription represents a subscription to the push streaming server. See https://docs.joinmastodon.org/entities/pushsubscription/ type PushSubscription struct { diff --git a/internal/mastotypes/mastomodel/relationship.go b/internal/api/model/relationship.go similarity index 99% rename from internal/mastotypes/mastomodel/relationship.go rename to internal/api/model/relationship.go index 1e0bbab..6e71023 100644 --- a/internal/mastotypes/mastomodel/relationship.go +++ b/internal/api/model/relationship.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package mastotypes +package model // Relationship represents a relationship between accounts. See https://docs.joinmastodon.org/entities/relationship/ type Relationship struct { diff --git a/internal/mastotypes/mastomodel/results.go b/internal/api/model/results.go similarity index 98% rename from internal/mastotypes/mastomodel/results.go rename to internal/api/model/results.go index 3fa7c7a..1b2625a 100644 --- a/internal/mastotypes/mastomodel/results.go +++ b/internal/api/model/results.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package mastotypes +package model // Results represents the results of a search. See https://docs.joinmastodon.org/entities/results/ type Results struct { diff --git a/internal/mastotypes/mastomodel/scheduledstatus.go b/internal/api/model/scheduledstatus.go similarity index 98% rename from internal/mastotypes/mastomodel/scheduledstatus.go rename to internal/api/model/scheduledstatus.go index ff45eaa..deafd22 100644 --- a/internal/mastotypes/mastomodel/scheduledstatus.go +++ b/internal/api/model/scheduledstatus.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package mastotypes +package model // ScheduledStatus represents a status that will be published at a future scheduled date. See https://docs.joinmastodon.org/entities/scheduledstatus/ type ScheduledStatus struct { diff --git a/internal/mastotypes/mastomodel/source.go b/internal/api/model/source.go similarity index 98% rename from internal/mastotypes/mastomodel/source.go rename to internal/api/model/source.go index 0445a1f..441af71 100644 --- a/internal/mastotypes/mastomodel/source.go +++ b/internal/api/model/source.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package mastotypes +package model // Source represents display or publishing preferences of user's own account. // Returned as an additional entity when verifying and updated credentials, as an attribute of Account. diff --git a/internal/mastotypes/mastomodel/status.go b/internal/api/model/status.go similarity index 90% rename from internal/mastotypes/mastomodel/status.go rename to internal/api/model/status.go index f5cc07a..faf88ae 100644 --- a/internal/mastotypes/mastomodel/status.go +++ b/internal/api/model/status.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package mastotypes +package model // Status represents a mastodon-api Status type, as defined here: https://docs.joinmastodon.org/entities/status/ type Status struct { @@ -118,3 +118,21 @@ const ( // VisibilityDirect means visible only to tagged recipients VisibilityDirect Visibility = "direct" ) + +type AdvancedStatusCreateForm struct { + StatusCreateRequest + AdvancedVisibilityFlagsForm +} + +type AdvancedVisibilityFlagsForm struct { + // The gotosocial visibility model + VisibilityAdvanced *string `form:"visibility_advanced"` + // This status will be federated beyond the local timeline(s) + Federated *bool `form:"federated"` + // This status can be boosted/reblogged + Boostable *bool `form:"boostable"` + // This status can be replied to + Replyable *bool `form:"replyable"` + // This status can be liked/faved + Likeable *bool `form:"likeable"` +} diff --git a/internal/mastotypes/mastomodel/tag.go b/internal/api/model/tag.go similarity index 98% rename from internal/mastotypes/mastomodel/tag.go rename to internal/api/model/tag.go index 82e6e66..f009b4c 100644 --- a/internal/mastotypes/mastomodel/tag.go +++ b/internal/api/model/tag.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package mastotypes +package model // Tag represents a hashtag used within the content of a status. See https://docs.joinmastodon.org/entities/tag/ type Tag struct { diff --git a/internal/mastotypes/mastomodel/token.go b/internal/api/model/token.go similarity index 98% rename from internal/mastotypes/mastomodel/token.go rename to internal/api/model/token.go index c9ac1f1..611ab21 100644 --- a/internal/mastotypes/mastomodel/token.go +++ b/internal/api/model/token.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package mastotypes +package model // Token represents an OAuth token used for authenticating with the API and performing actions.. See https://docs.joinmastodon.org/entities/token/ type Token struct { diff --git a/internal/api/s2s/user/user.go b/internal/api/s2s/user/user.go new file mode 100644 index 0000000..693fac7 --- /dev/null +++ b/internal/api/s2s/user/user.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 . +*/ + +package user + +import ( + "net/http" + + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/message" + "github.com/superseriousbusiness/gotosocial/internal/router" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +const ( + // UsernameKey is for account usernames. + UsernameKey = "username" + // UsersBasePath is the base path for serving information about Users eg https://example.org/users + UsersBasePath = "/" + util.UsersPath + // UsersBasePathWithUsername is just the users base path with the Username key in it. + // Use this anywhere you need to know the username of the user being queried. + // Eg https://example.org/users/:username + UsersBasePathWithUsername = UsersBasePath + "/:" + UsernameKey +) + +// ActivityPubAcceptHeaders represents the Accept headers mentioned here: +// https://www.w3.org/TR/activitypub/#retrieving-objects +var ActivityPubAcceptHeaders = []string{ + `application/activity+json`, + `application/ld+json; profile="https://www.w3.org/ns/activitystreams"`, +} + +// Module implements the FederationAPIModule interface +type Module struct { + config *config.Config + processor message.Processor + log *logrus.Logger +} + +// New returns a new auth module +func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.FederationModule { + return &Module{ + config: config, + processor: processor, + log: log, + } +} + +// Route satisfies the RESTAPIModule interface +func (m *Module) Route(s router.Router) error { + s.AttachHandler(http.MethodGet, UsersBasePathWithUsername, m.UsersGETHandler) + return nil +} diff --git a/internal/api/s2s/user/user_test.go b/internal/api/s2s/user/user_test.go new file mode 100644 index 0000000..84e35ab --- /dev/null +++ b/internal/api/s2s/user/user_test.go @@ -0,0 +1,40 @@ +package user_test + +import ( + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/s2s/user" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/message" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/storage" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" +) + +// nolint +type UserStandardTestSuite struct { + // standard suite interfaces + suite.Suite + config *config.Config + db db.DB + log *logrus.Logger + tc typeutils.TypeConverter + federator federation.Federator + processor message.Processor + storage storage.Storage + + // standard suite models + testTokens map[string]*oauth.Token + testClients map[string]*oauth.Client + testApplications map[string]*gtsmodel.Application + testUsers map[string]*gtsmodel.User + testAccounts map[string]*gtsmodel.Account + testAttachments map[string]*gtsmodel.MediaAttachment + testStatuses map[string]*gtsmodel.Status + + // module being tested + userModule *user.Module +} diff --git a/internal/api/s2s/user/userget.go b/internal/api/s2s/user/userget.go new file mode 100644 index 0000000..8df137f --- /dev/null +++ b/internal/api/s2s/user/userget.go @@ -0,0 +1,67 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package user + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" +) + +// UsersGETHandler should be served at https://example.org/users/:username. +// +// The goal here is to return the activitypub representation of an account +// in the form of a vocab.ActivityStreamsPerson. This should only be served +// to REMOTE SERVERS that present a valid signature on the GET request, on +// behalf of a user, otherwise we risk leaking information about users publicly. +// +// And of course, the request should be refused if the account or server making the +// request is blocked. +func (m *Module) UsersGETHandler(c *gin.Context) { + l := m.log.WithFields(logrus.Fields{ + "func": "UsersGETHandler", + "url": c.Request.RequestURI, + }) + + requestedUsername := c.Param(UsernameKey) + if requestedUsername == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "no username specified in request"}) + return + } + + // make sure this actually an AP request + format := c.NegotiateFormat(ActivityPubAcceptHeaders...) + if format == "" { + c.JSON(http.StatusNotAcceptable, gin.H{"error": "could not negotiate format with given Accept header(s)"}) + return + } + l.Tracef("negotiated format: %s", format) + + // make a copy of the context to pass along so we don't break anything + cp := c.Copy() + user, err := m.processor.GetFediUser(requestedUsername, cp.Request) // GetAPUser handles auth as well + if err != nil { + l.Info(err.Error()) + c.JSON(err.Code(), gin.H{"error": err.Safe()}) + return + } + + c.JSON(http.StatusOK, user) +} diff --git a/internal/api/s2s/user/userget_test.go b/internal/api/s2s/user/userget_test.go new file mode 100644 index 0000000..b45b01b --- /dev/null +++ b/internal/api/s2s/user/userget_test.go @@ -0,0 +1,155 @@ +package user_test + +import ( + "bytes" + "context" + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/go-fed/activity/streams" + "github.com/go-fed/activity/streams/vocab" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/s2s/user" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type UserGetTestSuite struct { + UserStandardTestSuite +} + +func (suite *UserGetTestSuite) SetupSuite() { + suite.testTokens = testrig.NewTestTokens() + suite.testClients = testrig.NewTestClients() + suite.testApplications = testrig.NewTestApplications() + suite.testUsers = testrig.NewTestUsers() + suite.testAccounts = testrig.NewTestAccounts() + suite.testAttachments = testrig.NewTestAttachments() + suite.testStatuses = testrig.NewTestStatuses() +} + +func (suite *UserGetTestSuite) SetupTest() { + suite.config = testrig.NewTestConfig() + suite.db = testrig.NewTestDB() + 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.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) + testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") +} + +func (suite *UserGetTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) +} + +func (suite *UserGetTestSuite) TestGetUser() { + // the dereference we're gonna use + signedRequest := testrig.NewTestDereferenceRequests(suite.testAccounts)["foss_satan_dereference_zork"] + + requestingAccount := suite.testAccounts["remote_account_1"] + targetAccount := suite.testAccounts["local_account_1"] + + encodedPublicKey, err := x509.MarshalPKIXPublicKey(requestingAccount.PublicKey) + assert.NoError(suite.T(), err) + publicKeyBytes := pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: encodedPublicKey, + }) + publicKeyString := strings.ReplaceAll(string(publicKeyBytes), "\n", "\\n") + + // for this test we need the client to return the public key of the requester on the 'remote' instance + responseBodyString := fmt.Sprintf(` + { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1" + ], + + "id": "%s", + "type": "Person", + "preferredUsername": "%s", + "inbox": "%s", + + "publicKey": { + "id": "%s", + "owner": "%s", + "publicKeyPem": "%s" + } + }`, requestingAccount.URI, requestingAccount.Username, requestingAccount.InboxURI, requestingAccount.PublicKeyURI, requestingAccount.URI, publicKeyString) + + // create a transport controller whose client will just return the response body string we specified above + tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(func(req *http.Request) (*http.Response, error) { + r := ioutil.NopCloser(bytes.NewReader([]byte(responseBodyString))) + return &http.Response{ + StatusCode: 200, + Body: r, + }, nil + })) + // get this transport controller embedded right in the user module we're testing + federator := testrig.NewTestFederator(suite.db, tc) + processor := testrig.NewTestProcessor(suite.db, suite.storage, federator) + userModule := user.New(suite.config, processor, suite.log).(*user.Module) + + // setup request + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodGet, fmt.Sprintf("http://localhost:8080%s", strings.Replace(user.UsersBasePathWithUsername, ":username", targetAccount.Username, 1)), nil) // the endpoint we're hitting + + // normally the router would populate these params from the path values, + // but because we're calling the function directly, we need to set them manually. + ctx.Params = gin.Params{ + gin.Param{ + Key: user.UsernameKey, + Value: targetAccount.Username, + }, + } + + // we need these headers for the request to be validated + ctx.Request.Header.Set("Signature", signedRequest.SignatureHeader) + ctx.Request.Header.Set("Date", signedRequest.DateHeader) + ctx.Request.Header.Set("Digest", signedRequest.DigestHeader) + + // trigger the function being tested + userModule.UsersGETHandler(ctx) + + // check response + suite.EqualValues(http.StatusOK, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + assert.NoError(suite.T(), err) + + // should be a Person + m := make(map[string]interface{}) + err = json.Unmarshal(b, &m) + assert.NoError(suite.T(), err) + + t, err := streams.ToType(context.Background(), m) + assert.NoError(suite.T(), err) + + person, ok := t.(vocab.ActivityStreamsPerson) + assert.True(suite.T(), ok) + + // convert person to account + // since this account is already known, we should get a pretty full model of it from the conversion + a, err := suite.tc.ASRepresentationToAccount(person) + assert.NoError(suite.T(), err) + assert.EqualValues(suite.T(), targetAccount.Username, a.Username) +} + +func TestUserGetTestSuite(t *testing.T) { + suite.Run(t, new(UserGetTestSuite)) +} diff --git a/internal/apimodule/security/flocblock.go b/internal/api/security/flocblock.go similarity index 100% rename from internal/apimodule/security/flocblock.go rename to internal/api/security/flocblock.go diff --git a/internal/apimodule/security/security.go b/internal/api/security/security.go similarity index 78% rename from internal/apimodule/security/security.go rename to internal/api/security/security.go index 8f805bc..c80b568 100644 --- a/internal/apimodule/security/security.go +++ b/internal/api/security/security.go @@ -20,9 +20,8 @@ package security import ( "github.com/sirupsen/logrus" - "github.com/superseriousbusiness/gotosocial/internal/apimodule" + "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,7 +32,7 @@ type Module struct { } // New returns a new security module -func New(config *config.Config, log *logrus.Logger) apimodule.ClientAPIModule { +func New(config *config.Config, log *logrus.Logger) api.ClientModule { return &Module{ config: config, log: log, @@ -45,8 +44,3 @@ func (m *Module) Route(s router.Router) error { s.AttachMiddleware(m.FlocBlock) return nil } - -// CreateTables doesn't do diddly squat at the moment, it's just for fulfilling the interface -func (m *Module) CreateTables(db db.DB) error { - return nil -} diff --git a/internal/apimodule/account/accountupdate.go b/internal/apimodule/account/accountupdate.go deleted file mode 100644 index 7709697..0000000 --- a/internal/apimodule/account/accountupdate.go +++ /dev/null @@ -1,260 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . -*/ - -package account - -import ( - "bytes" - "errors" - "fmt" - "io" - "mime/multipart" - "net/http" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" - "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/util" -) - -// AccountUpdateCredentialsPATCHHandler allows a user to modify their account/profile settings. -// It should be served as a PATCH at /api/v1/accounts/update_credentials -// -// TODO: this can be optimized massively by building up a picture of what we want the new account -// details to be, and then inserting it all in the database at once. As it is, we do queries one-by-one -// which is not gonna make the database very happy when lots of requests are going through. -// This way it would also be safer because the update won't happen until *all* the fields are validated. -// Otherwise we risk doing a partial update and that's gonna cause probllleeemmmsss. -func (m *Module) AccountUpdateCredentialsPATCHHandler(c *gin.Context) { - l := m.log.WithField("func", "accountUpdateCredentialsPATCHHandler") - authed, err := oauth.MustAuth(c, true, false, false, true) - if err != nil { - l.Debugf("couldn't auth: %s", err) - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) - return - } - l.Tracef("retrieved account %+v", authed.Account.ID) - - l.Trace("parsing request form") - form := &mastotypes.UpdateCredentialsRequest{} - if err := c.ShouldBind(form); err != nil || form == nil { - l.Debugf("could not parse form from request: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // if everything on the form is nil, then nothing has been set and we shouldn't continue - if form.Discoverable == nil && form.Bot == nil && form.DisplayName == nil && form.Note == nil && form.Avatar == nil && form.Header == nil && form.Locked == nil && form.Source == nil && form.FieldsAttributes == nil { - l.Debugf("could not parse form from request") - c.JSON(http.StatusBadRequest, gin.H{"error": "empty form submitted"}) - return - } - - if form.Discoverable != nil { - if err := m.db.UpdateOneByID(authed.Account.ID, "discoverable", *form.Discoverable, >smodel.Account{}); err != nil { - l.Debugf("error updating discoverable: %s", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - } - - if form.Bot != nil { - if err := m.db.UpdateOneByID(authed.Account.ID, "bot", *form.Bot, >smodel.Account{}); err != nil { - l.Debugf("error updating bot: %s", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - } - - if form.DisplayName != nil { - if err := util.ValidateDisplayName(*form.DisplayName); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if err := m.db.UpdateOneByID(authed.Account.ID, "display_name", *form.DisplayName, >smodel.Account{}); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - } - - if form.Note != nil { - if err := util.ValidateNote(*form.Note); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if err := m.db.UpdateOneByID(authed.Account.ID, "note", *form.Note, >smodel.Account{}); err != nil { - l.Debugf("error updating note: %s", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - } - - if form.Avatar != nil && form.Avatar.Size != 0 { - avatarInfo, err := m.UpdateAccountAvatar(form.Avatar, authed.Account.ID) - if err != nil { - l.Debugf("could not update avatar for account %s: %s", authed.Account.ID, err) - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - l.Tracef("new avatar info for account %s is %+v", authed.Account.ID, avatarInfo) - } - - if form.Header != nil && form.Header.Size != 0 { - headerInfo, err := m.UpdateAccountHeader(form.Header, authed.Account.ID) - if err != nil { - l.Debugf("could not update header for account %s: %s", authed.Account.ID, err) - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - l.Tracef("new header info for account %s is %+v", authed.Account.ID, headerInfo) - } - - if form.Locked != nil { - if err := m.db.UpdateOneByID(authed.Account.ID, "locked", *form.Locked, >smodel.Account{}); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - } - - if form.Source != nil { - if form.Source.Language != nil { - if err := util.ValidateLanguage(*form.Source.Language); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if err := m.db.UpdateOneByID(authed.Account.ID, "language", *form.Source.Language, >smodel.Account{}); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - } - - if form.Source.Sensitive != nil { - if err := m.db.UpdateOneByID(authed.Account.ID, "locked", *form.Locked, >smodel.Account{}); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - } - - if form.Source.Privacy != nil { - if err := util.ValidatePrivacy(*form.Source.Privacy); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if err := m.db.UpdateOneByID(authed.Account.ID, "privacy", *form.Source.Privacy, >smodel.Account{}); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - } - } - - // if form.FieldsAttributes != nil { - // // TODO: parse fields attributes nicely and update - // } - - // fetch the account with all updated values set - updatedAccount := >smodel.Account{} - if err := m.db.GetByID(authed.Account.ID, updatedAccount); err != nil { - l.Debugf("could not fetch updated account %s: %s", authed.Account.ID, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - acctSensitive, err := m.mastoConverter.AccountToMastoSensitive(updatedAccount) - if err != nil { - l.Tracef("could not convert account into mastosensitive account: %s", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - l.Tracef("conversion successful, returning OK and mastosensitive account %+v", acctSensitive) - c.JSON(http.StatusOK, acctSensitive) -} - -/* - 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 (m *Module) UpdateAccountAvatar(avatar *multipart.FileHeader, accountID string) (*gtsmodel.MediaAttachment, error) { - var err error - if int(avatar.Size) > m.config.MediaConfig.MaxImageSize { - err = fmt.Errorf("avatar with size %d exceeded max image size of %d bytes", avatar.Size, m.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 := m.mediaHandler.ProcessHeaderOrAvatar(buf.Bytes(), accountID, media.MediaAvatar) - 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 (m *Module) UpdateAccountHeader(header *multipart.FileHeader, accountID string) (*gtsmodel.MediaAttachment, error) { - var err error - if int(header.Size) > m.config.MediaConfig.MaxImageSize { - err = fmt.Errorf("header with size %d exceeded max image size of %d bytes", header.Size, m.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 := m.mediaHandler.ProcessHeaderOrAvatar(buf.Bytes(), accountID, media.MediaHeader) - if err != nil { - return nil, fmt.Errorf("error processing header: %s", err) - } - - return headerInfo, f.Close() -} diff --git a/internal/apimodule/account/test/accountcreate_test.go b/internal/apimodule/account/test/accountcreate_test.go deleted file mode 100644 index 81eab46..0000000 --- a/internal/apimodule/account/test/accountcreate_test.go +++ /dev/null @@ -1,551 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . -*/ - -package account - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "io/ioutil" - "mime/multipart" - "net/http" - "net/http/httptest" - "net/url" - "os" - "testing" - "time" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" - "github.com/sirupsen/logrus" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/apimodule/account" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/mastotypes" - mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" - - "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/storage" - "github.com/superseriousbusiness/oauth2/v4" - "github.com/superseriousbusiness/oauth2/v4/models" - oauthmodels "github.com/superseriousbusiness/oauth2/v4/models" - "golang.org/x/crypto/bcrypt" -) - -type AccountCreateTestSuite struct { - suite.Suite - config *config.Config - log *logrus.Logger - testAccountLocal *gtsmodel.Account - testApplication *gtsmodel.Application - testToken oauth2.TokenInfo - mockOauthServer *oauth.MockServer - mockStorage *storage.MockStorage - mediaHandler media.Handler - mastoConverter mastotypes.Converter - db db.DB - accountModule *account.Module - newUserFormHappyPath url.Values -} - -/* - TEST INFRASTRUCTURE -*/ - -// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout -func (suite *AccountCreateTestSuite) SetupSuite() { - // some of our subsequent entities need a log so create this here - log := logrus.New() - log.SetLevel(logrus.TraceLevel) - suite.log = log - - suite.testAccountLocal = >smodel.Account{ - ID: uuid.NewString(), - Username: "test_user", - } - - // can use this test application throughout - suite.testApplication = >smodel.Application{ - ID: "weeweeeeeeeeeeeeee", - Name: "a test application", - Website: "https://some-application-website.com", - RedirectURI: "http://localhost:8080", - ClientID: "a-known-client-id", - ClientSecret: "some-secret", - Scopes: "read", - VapidKey: "aaaaaa-aaaaaaaa-aaaaaaaaaaa", - } - - // can use this test token throughout - suite.testToken = &oauthmodels.Token{ - ClientID: "a-known-client-id", - RedirectURI: "http://localhost:8080", - Scope: "read", - Code: "123456789", - CodeCreateAt: time.Now(), - CodeExpiresIn: time.Duration(10 * time.Minute), - } - - // Direct config to local postgres instance - c := config.Empty() - c.Protocol = "http" - c.Host = "localhost" - c.DBConfig = &config.DBConfig{ - Type: "postgres", - Address: "localhost", - Port: 5432, - User: "postgres", - Password: "postgres", - Database: "postgres", - ApplicationName: "gotosocial", - } - c.MediaConfig = &config.MediaConfig{ - MaxImageSize: 2 << 20, - } - c.StorageConfig = &config.StorageConfig{ - Backend: "local", - BasePath: "/tmp", - ServeProtocol: "http", - ServeHost: "localhost", - ServeBasePath: "/fileserver/media", - } - suite.config = c - - // use an actual database for this, because it's just easier than mocking one out - database, err := db.New(context.Background(), c, log) - if err != nil { - suite.FailNow(err.Error()) - } - suite.db = database - - // we need to mock the oauth server because account creation needs it to create a new token - suite.mockOauthServer = &oauth.MockServer{} - suite.mockOauthServer.On("GenerateUserAccessToken", suite.testToken, suite.testApplication.ClientSecret, mock.AnythingOfType("string")).Run(func(args mock.Arguments) { - l := suite.log.WithField("func", "GenerateUserAccessToken") - token := args.Get(0).(oauth2.TokenInfo) - l.Infof("received token %+v", token) - clientSecret := args.Get(1).(string) - l.Infof("received clientSecret %+v", clientSecret) - userID := args.Get(2).(string) - l.Infof("received userID %+v", userID) - }).Return(&models.Token{ - Access: "we're authorized now!", - }, nil) - - suite.mockStorage = &storage.MockStorage{} - // We don't need storage to do anything for these tests, so just simulate a success and do nothing -- we won't need to return anything from storage - suite.mockStorage.On("StoreFileAt", mock.AnythingOfType("string"), mock.AnythingOfType("[]uint8")).Return(nil) - - // set a media handler because some handlers (eg update credentials) need to upload media (new header/avatar) - suite.mediaHandler = media.New(suite.config, suite.db, suite.mockStorage, log) - - suite.mastoConverter = mastotypes.New(suite.config, suite.db) - - // and finally here's the thing we're actually testing! - suite.accountModule = account.New(suite.config, suite.db, suite.mockOauthServer, suite.mediaHandler, suite.mastoConverter, suite.log).(*account.Module) -} - -func (suite *AccountCreateTestSuite) TearDownSuite() { - if err := suite.db.Stop(context.Background()); err != nil { - logrus.Panicf("error closing db connection: %s", err) - } -} - -// SetupTest creates a db connection and creates necessary tables before each test -func (suite *AccountCreateTestSuite) SetupTest() { - // create all the tables we might need in thie suite - models := []interface{}{ - >smodel.User{}, - >smodel.Account{}, - >smodel.Follow{}, - >smodel.FollowRequest{}, - >smodel.Status{}, - >smodel.Application{}, - >smodel.EmailDomainBlock{}, - >smodel.MediaAttachment{}, - } - for _, m := range models { - if err := suite.db.CreateTable(m); err != nil { - logrus.Panicf("db connection error: %s", err) - } - } - - // form to submit for happy path account create requests -- this will be changed inside tests so it's better to set it before each test - suite.newUserFormHappyPath = url.Values{ - "reason": []string{"a very good reason that's at least 40 characters i swear"}, - "username": []string{"test_user"}, - "email": []string{"user@example.org"}, - "password": []string{"very-strong-password"}, - "agreement": []string{"true"}, - "locale": []string{"en"}, - } - - // same with accounts config - suite.config.AccountsConfig = &config.AccountsConfig{ - OpenRegistration: true, - RequireApproval: true, - ReasonRequired: true, - } -} - -// TearDownTest drops tables to make sure there's no data in the db -func (suite *AccountCreateTestSuite) TearDownTest() { - - // remove all the tables we might have used so it's clear for the next test - models := []interface{}{ - >smodel.User{}, - >smodel.Account{}, - >smodel.Follow{}, - >smodel.FollowRequest{}, - >smodel.Status{}, - >smodel.Application{}, - >smodel.EmailDomainBlock{}, - >smodel.MediaAttachment{}, - } - for _, m := range models { - if err := suite.db.DropTable(m); err != nil { - logrus.Panicf("error dropping table: %s", err) - } - } -} - -/* - ACTUAL TESTS -*/ - -/* - TESTING: AccountCreatePOSTHandler -*/ - -// TestAccountCreatePOSTHandlerSuccessful checks the happy path for an account creation request: all the fields provided are valid, -// and at the end of it a new user and account should be added into the database. -// -// This is the handler served at /api/v1/accounts as POST -func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerSuccessful() { - - // setup - recorder := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(recorder) - ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication) - ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting - ctx.Request.Form = suite.newUserFormHappyPath - suite.accountModule.AccountCreatePOSTHandler(ctx) - - // check response - - // 1. we should have OK from our call to the function - suite.EqualValues(http.StatusOK, recorder.Code) - - // 2. we should have a token in the result body - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) - t := &mastomodel.Token{} - err = json.Unmarshal(b, t) - assert.NoError(suite.T(), err) - assert.Equal(suite.T(), "we're authorized now!", t.AccessToken) - - // check new account - - // 1. we should be able to get the new account from the db - acct := >smodel.Account{} - err = suite.db.GetWhere("username", "test_user", acct) - assert.NoError(suite.T(), err) - assert.NotNil(suite.T(), acct) - // 2. reason should be set - assert.Equal(suite.T(), suite.newUserFormHappyPath.Get("reason"), acct.Reason) - // 3. display name should be equal to username by default - assert.Equal(suite.T(), suite.newUserFormHappyPath.Get("username"), acct.DisplayName) - // 4. domain should be nil because this is a local account - assert.Nil(suite.T(), nil, acct.Domain) - // 5. id should be set and parseable as a uuid - assert.NotNil(suite.T(), acct.ID) - _, err = uuid.Parse(acct.ID) - assert.Nil(suite.T(), err) - // 6. private and public key should be set - assert.NotNil(suite.T(), acct.PrivateKey) - assert.NotNil(suite.T(), acct.PublicKey) - - // check new user - - // 1. we should be able to get the new user from the db - usr := >smodel.User{} - err = suite.db.GetWhere("unconfirmed_email", suite.newUserFormHappyPath.Get("email"), usr) - assert.Nil(suite.T(), err) - assert.NotNil(suite.T(), usr) - - // 2. user should have account id set to account we got above - assert.Equal(suite.T(), acct.ID, usr.AccountID) - - // 3. id should be set and parseable as a uuid - assert.NotNil(suite.T(), usr.ID) - _, err = uuid.Parse(usr.ID) - assert.Nil(suite.T(), err) - - // 4. locale should be equal to what we requested - assert.Equal(suite.T(), suite.newUserFormHappyPath.Get("locale"), usr.Locale) - - // 5. created by application id should be equal to the app id - assert.Equal(suite.T(), suite.testApplication.ID, usr.CreatedByApplicationID) - - // 6. password should be matcheable to what we set above - err = bcrypt.CompareHashAndPassword([]byte(usr.EncryptedPassword), []byte(suite.newUserFormHappyPath.Get("password"))) - assert.Nil(suite.T(), err) -} - -// TestAccountCreatePOSTHandlerNoAuth makes sure that the handler fails when no authorization is provided: -// only registered applications can create accounts, and we don't provide one here. -func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerNoAuth() { - - // setup - recorder := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(recorder) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting - ctx.Request.Form = suite.newUserFormHappyPath - suite.accountModule.AccountCreatePOSTHandler(ctx) - - // check response - - // 1. we should have forbidden from our call to the function because we didn't auth - suite.EqualValues(http.StatusForbidden, recorder.Code) - - // 2. we should have an error message in the result body - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) - assert.Equal(suite.T(), `{"error":"not authorized"}`, string(b)) -} - -// TestAccountCreatePOSTHandlerNoAuth makes sure that the handler fails when no form is provided at all. -func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerNoForm() { - - // setup - recorder := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(recorder) - ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication) - ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting - suite.accountModule.AccountCreatePOSTHandler(ctx) - - // check response - suite.EqualValues(http.StatusBadRequest, recorder.Code) - - // 2. we should have an error message in the result body - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) - assert.Equal(suite.T(), `{"error":"missing one or more required form values"}`, string(b)) -} - -// TestAccountCreatePOSTHandlerWeakPassword makes sure that the handler fails when a weak password is provided -func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerWeakPassword() { - - // setup - recorder := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(recorder) - ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication) - ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting - ctx.Request.Form = suite.newUserFormHappyPath - // set a weak password - ctx.Request.Form.Set("password", "weak") - suite.accountModule.AccountCreatePOSTHandler(ctx) - - // check response - suite.EqualValues(http.StatusBadRequest, recorder.Code) - - // 2. we should have an error message in the result body - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) - assert.Equal(suite.T(), `{"error":"insecure password, try including more special characters, using uppercase letters, using numbers or using a longer password"}`, string(b)) -} - -// TestAccountCreatePOSTHandlerWeirdLocale makes sure that the handler fails when a weird locale is provided -func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerWeirdLocale() { - - // setup - recorder := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(recorder) - ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication) - ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting - ctx.Request.Form = suite.newUserFormHappyPath - // set an invalid locale - ctx.Request.Form.Set("locale", "neverneverland") - suite.accountModule.AccountCreatePOSTHandler(ctx) - - // check response - suite.EqualValues(http.StatusBadRequest, recorder.Code) - - // 2. we should have an error message in the result body - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) - assert.Equal(suite.T(), `{"error":"language: tag is not well-formed"}`, string(b)) -} - -// TestAccountCreatePOSTHandlerRegistrationsClosed makes sure that the handler fails when registrations are closed -func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerRegistrationsClosed() { - - // setup - recorder := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(recorder) - ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication) - ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting - ctx.Request.Form = suite.newUserFormHappyPath - - // close registrations - suite.config.AccountsConfig.OpenRegistration = false - suite.accountModule.AccountCreatePOSTHandler(ctx) - - // check response - suite.EqualValues(http.StatusBadRequest, recorder.Code) - - // 2. we should have an error message in the result body - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) - assert.Equal(suite.T(), `{"error":"registration is not open for this server"}`, string(b)) -} - -// TestAccountCreatePOSTHandlerReasonNotProvided makes sure that the handler fails when no reason is provided but one is required -func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerReasonNotProvided() { - - // setup - recorder := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(recorder) - ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication) - ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting - ctx.Request.Form = suite.newUserFormHappyPath - - // remove reason - ctx.Request.Form.Set("reason", "") - - suite.accountModule.AccountCreatePOSTHandler(ctx) - - // check response - suite.EqualValues(http.StatusBadRequest, recorder.Code) - - // 2. we should have an error message in the result body - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) - assert.Equal(suite.T(), `{"error":"no reason provided"}`, string(b)) -} - -// TestAccountCreatePOSTHandlerReasonNotProvided makes sure that the handler fails when a crappy reason is presented but a good one is required -func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerInsufficientReason() { - - // setup - recorder := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(recorder) - ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication) - ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", account.BasePath), nil) // the endpoint we're hitting - ctx.Request.Form = suite.newUserFormHappyPath - - // remove reason - ctx.Request.Form.Set("reason", "just cuz") - - suite.accountModule.AccountCreatePOSTHandler(ctx) - - // check response - suite.EqualValues(http.StatusBadRequest, recorder.Code) - - // 2. we should have an error message in the result body - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) - assert.Equal(suite.T(), `{"error":"reason should be at least 40 chars but 'just cuz' was 8"}`, string(b)) -} - -/* - TESTING: AccountUpdateCredentialsPATCHHandler -*/ - -func (suite *AccountCreateTestSuite) TestAccountUpdateCredentialsPATCHHandler() { - - // put test local account in db - err := suite.db.Put(suite.testAccountLocal) - assert.NoError(suite.T(), err) - - // attach avatar to request - aviFile, err := os.Open("../../media/test/test-jpeg.jpg") - assert.NoError(suite.T(), err) - body := &bytes.Buffer{} - writer := multipart.NewWriter(body) - - part, err := writer.CreateFormFile("avatar", "test-jpeg.jpg") - assert.NoError(suite.T(), err) - - _, err = io.Copy(part, aviFile) - assert.NoError(suite.T(), err) - - err = aviFile.Close() - assert.NoError(suite.T(), err) - - err = writer.Close() - assert.NoError(suite.T(), err) - - // setup - recorder := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(recorder) - ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccountLocal) - ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) - ctx.Request = httptest.NewRequest(http.MethodPatch, fmt.Sprintf("http://localhost:8080/%s", account.UpdateCredentialsPath), body) // the endpoint we're hitting - ctx.Request.Header.Set("Content-Type", writer.FormDataContentType()) - suite.accountModule.AccountUpdateCredentialsPATCHHandler(ctx) - - // check response - - // 1. we should have OK because our request was valid - suite.EqualValues(http.StatusOK, recorder.Code) - - // 2. we should have an error message in the result body - result := recorder.Result() - defer result.Body.Close() - // TODO: implement proper checks here - // - // b, err := ioutil.ReadAll(result.Body) - // assert.NoError(suite.T(), err) - // assert.Equal(suite.T(), `{"error":"not authorized"}`, string(b)) -} - -func TestAccountCreateTestSuite(t *testing.T) { - suite.Run(t, new(AccountCreateTestSuite)) -} diff --git a/internal/apimodule/account/test/accountupdate_test.go b/internal/apimodule/account/test/accountupdate_test.go deleted file mode 100644 index 1c6f528..0000000 --- a/internal/apimodule/account/test/accountupdate_test.go +++ /dev/null @@ -1,303 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . -*/ - -package account - -import ( - "bytes" - "context" - "fmt" - "io" - "mime/multipart" - "net/http" - "net/http/httptest" - "net/url" - "os" - "testing" - "time" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" - "github.com/sirupsen/logrus" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/apimodule/account" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/mastotypes" - "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/storage" - "github.com/superseriousbusiness/oauth2/v4" - "github.com/superseriousbusiness/oauth2/v4/models" - oauthmodels "github.com/superseriousbusiness/oauth2/v4/models" -) - -type AccountUpdateTestSuite struct { - suite.Suite - config *config.Config - log *logrus.Logger - testAccountLocal *gtsmodel.Account - testApplication *gtsmodel.Application - testToken oauth2.TokenInfo - mockOauthServer *oauth.MockServer - mockStorage *storage.MockStorage - mediaHandler media.Handler - mastoConverter mastotypes.Converter - db db.DB - accountModule *account.Module - newUserFormHappyPath url.Values -} - -/* - TEST INFRASTRUCTURE -*/ - -// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout -func (suite *AccountUpdateTestSuite) SetupSuite() { - // some of our subsequent entities need a log so create this here - log := logrus.New() - log.SetLevel(logrus.TraceLevel) - suite.log = log - - suite.testAccountLocal = >smodel.Account{ - ID: uuid.NewString(), - Username: "test_user", - } - - // can use this test application throughout - suite.testApplication = >smodel.Application{ - ID: "weeweeeeeeeeeeeeee", - Name: "a test application", - Website: "https://some-application-website.com", - RedirectURI: "http://localhost:8080", - ClientID: "a-known-client-id", - ClientSecret: "some-secret", - Scopes: "read", - VapidKey: "aaaaaa-aaaaaaaa-aaaaaaaaaaa", - } - - // can use this test token throughout - suite.testToken = &oauthmodels.Token{ - ClientID: "a-known-client-id", - RedirectURI: "http://localhost:8080", - Scope: "read", - Code: "123456789", - CodeCreateAt: time.Now(), - CodeExpiresIn: time.Duration(10 * time.Minute), - } - - // Direct config to local postgres instance - c := config.Empty() - c.Protocol = "http" - c.Host = "localhost" - c.DBConfig = &config.DBConfig{ - Type: "postgres", - Address: "localhost", - Port: 5432, - User: "postgres", - Password: "postgres", - Database: "postgres", - ApplicationName: "gotosocial", - } - c.MediaConfig = &config.MediaConfig{ - MaxImageSize: 2 << 20, - } - c.StorageConfig = &config.StorageConfig{ - Backend: "local", - BasePath: "/tmp", - ServeProtocol: "http", - ServeHost: "localhost", - ServeBasePath: "/fileserver/media", - } - suite.config = c - - // use an actual database for this, because it's just easier than mocking one out - database, err := db.New(context.Background(), c, log) - if err != nil { - suite.FailNow(err.Error()) - } - suite.db = database - - // we need to mock the oauth server because account creation needs it to create a new token - suite.mockOauthServer = &oauth.MockServer{} - suite.mockOauthServer.On("GenerateUserAccessToken", suite.testToken, suite.testApplication.ClientSecret, mock.AnythingOfType("string")).Run(func(args mock.Arguments) { - l := suite.log.WithField("func", "GenerateUserAccessToken") - token := args.Get(0).(oauth2.TokenInfo) - l.Infof("received token %+v", token) - clientSecret := args.Get(1).(string) - l.Infof("received clientSecret %+v", clientSecret) - userID := args.Get(2).(string) - l.Infof("received userID %+v", userID) - }).Return(&models.Token{ - Code: "we're authorized now!", - }, nil) - - suite.mockStorage = &storage.MockStorage{} - // We don't need storage to do anything for these tests, so just simulate a success and do nothing -- we won't need to return anything from storage - suite.mockStorage.On("StoreFileAt", mock.AnythingOfType("string"), mock.AnythingOfType("[]uint8")).Return(nil) - - // set a media handler because some handlers (eg update credentials) need to upload media (new header/avatar) - suite.mediaHandler = media.New(suite.config, suite.db, suite.mockStorage, log) - - suite.mastoConverter = mastotypes.New(suite.config, suite.db) - - // and finally here's the thing we're actually testing! - suite.accountModule = account.New(suite.config, suite.db, suite.mockOauthServer, suite.mediaHandler, suite.mastoConverter, suite.log).(*account.Module) -} - -func (suite *AccountUpdateTestSuite) TearDownSuite() { - if err := suite.db.Stop(context.Background()); err != nil { - logrus.Panicf("error closing db connection: %s", err) - } -} - -// SetupTest creates a db connection and creates necessary tables before each test -func (suite *AccountUpdateTestSuite) SetupTest() { - // create all the tables we might need in thie suite - models := []interface{}{ - >smodel.User{}, - >smodel.Account{}, - >smodel.Follow{}, - >smodel.FollowRequest{}, - >smodel.Status{}, - >smodel.Application{}, - >smodel.EmailDomainBlock{}, - >smodel.MediaAttachment{}, - } - for _, m := range models { - if err := suite.db.CreateTable(m); err != nil { - logrus.Panicf("db connection error: %s", err) - } - } - - // form to submit for happy path account create requests -- this will be changed inside tests so it's better to set it before each test - suite.newUserFormHappyPath = url.Values{ - "reason": []string{"a very good reason that's at least 40 characters i swear"}, - "username": []string{"test_user"}, - "email": []string{"user@example.org"}, - "password": []string{"very-strong-password"}, - "agreement": []string{"true"}, - "locale": []string{"en"}, - } - - // same with accounts config - suite.config.AccountsConfig = &config.AccountsConfig{ - OpenRegistration: true, - RequireApproval: true, - ReasonRequired: true, - } -} - -// TearDownTest drops tables to make sure there's no data in the db -func (suite *AccountUpdateTestSuite) TearDownTest() { - - // remove all the tables we might have used so it's clear for the next test - models := []interface{}{ - >smodel.User{}, - >smodel.Account{}, - >smodel.Follow{}, - >smodel.FollowRequest{}, - >smodel.Status{}, - >smodel.Application{}, - >smodel.EmailDomainBlock{}, - >smodel.MediaAttachment{}, - } - for _, m := range models { - if err := suite.db.DropTable(m); err != nil { - logrus.Panicf("error dropping table: %s", err) - } - } -} - -/* - ACTUAL TESTS -*/ - -/* - TESTING: AccountUpdateCredentialsPATCHHandler -*/ - -func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandler() { - - // put test local account in db - err := suite.db.Put(suite.testAccountLocal) - assert.NoError(suite.T(), err) - - // attach avatar to request form - avatarFile, err := os.Open("../../media/test/test-jpeg.jpg") - assert.NoError(suite.T(), err) - body := &bytes.Buffer{} - writer := multipart.NewWriter(body) - - avatarPart, err := writer.CreateFormFile("avatar", "test-jpeg.jpg") - assert.NoError(suite.T(), err) - - _, err = io.Copy(avatarPart, avatarFile) - assert.NoError(suite.T(), err) - - err = avatarFile.Close() - assert.NoError(suite.T(), err) - - // set display name to a new value - displayNamePart, err := writer.CreateFormField("display_name") - assert.NoError(suite.T(), err) - - _, err = io.Copy(displayNamePart, bytes.NewBufferString("test_user_wohoah")) - assert.NoError(suite.T(), err) - - // set locked to true - lockedPart, err := writer.CreateFormField("locked") - assert.NoError(suite.T(), err) - - _, err = io.Copy(lockedPart, bytes.NewBufferString("true")) - assert.NoError(suite.T(), err) - - // close the request writer, the form is now prepared - err = writer.Close() - assert.NoError(suite.T(), err) - - // setup - recorder := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(recorder) - ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccountLocal) - ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) - ctx.Request = httptest.NewRequest(http.MethodPatch, fmt.Sprintf("http://localhost:8080/%s", account.UpdateCredentialsPath), body) // the endpoint we're hitting - ctx.Request.Header.Set("Content-Type", writer.FormDataContentType()) - suite.accountModule.AccountUpdateCredentialsPATCHHandler(ctx) - - // check response - - // 1. we should have OK because our request was valid - suite.EqualValues(http.StatusOK, recorder.Code) - - // 2. we should have an error message in the result body - result := recorder.Result() - defer result.Body.Close() - // TODO: implement proper checks here - // - // b, err := ioutil.ReadAll(result.Body) - // assert.NoError(suite.T(), err) - // assert.Equal(suite.T(), `{"error":"not authorized"}`, string(b)) -} - -func TestAccountUpdateTestSuite(t *testing.T) { - suite.Run(t, new(AccountUpdateTestSuite)) -} diff --git a/internal/apimodule/app/appcreate.go b/internal/apimodule/app/appcreate.go deleted file mode 100644 index 99b79d4..0000000 --- a/internal/apimodule/app/appcreate.go +++ /dev/null @@ -1,119 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . -*/ - -package app - -import ( - "fmt" - "net/http" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// AppsPOSTHandler should be served at https://example.org/api/v1/apps -// It is equivalent to: https://docs.joinmastodon.org/methods/apps/ -func (m *Module) AppsPOSTHandler(c *gin.Context) { - l := m.log.WithField("func", "AppsPOSTHandler") - l.Trace("entering AppsPOSTHandler") - - form := &mastotypes.ApplicationPOSTRequest{} - if err := c.ShouldBind(form); err != nil { - c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) - return - } - - // permitted length for most fields - permittedLength := 64 - // redirect can be a bit bigger because we probably need to encode data in the redirect uri - permittedRedirect := 256 - - // check lengths of fields before proceeding so the user can't spam huge entries into the database - if len(form.ClientName) > permittedLength { - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("client_name must be less than %d bytes", permittedLength)}) - return - } - if len(form.Website) > permittedLength { - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("website must be less than %d bytes", permittedLength)}) - return - } - if len(form.RedirectURIs) > permittedRedirect { - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("redirect_uris must be less than %d bytes", permittedRedirect)}) - return - } - if len(form.Scopes) > permittedLength { - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("scopes must be less than %d bytes", permittedLength)}) - return - } - - // set default 'read' for scopes if it's not set, this follows the default of the mastodon api https://docs.joinmastodon.org/methods/apps/ - var scopes string - if form.Scopes == "" { - scopes = "read" - } else { - scopes = form.Scopes - } - - // generate new IDs for this application and its associated client - clientID := uuid.NewString() - clientSecret := uuid.NewString() - vapidKey := uuid.NewString() - - // generate the application to put in the database - app := >smodel.Application{ - Name: form.ClientName, - Website: form.Website, - RedirectURI: form.RedirectURIs, - ClientID: clientID, - ClientSecret: clientSecret, - Scopes: scopes, - VapidKey: vapidKey, - } - - // chuck it in the db - if err := m.db.Put(app); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - // now we need to model an oauth client from the application that the oauth library can use - oc := &oauth.Client{ - ID: clientID, - Secret: clientSecret, - Domain: form.RedirectURIs, - UserID: "", // This client isn't yet associated with a specific user, it's just an app client right now - } - - // chuck it in the db - if err := m.db.Put(oc); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - mastoApp, err := m.mastoConverter.AppToMastoSensitive(app) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - // done, return the new app information per the spec here: https://docs.joinmastodon.org/methods/apps/ - c.JSON(http.StatusOK, mastoApp) -} diff --git a/internal/apimodule/fileserver/servefile.go b/internal/apimodule/fileserver/servefile.go deleted file mode 100644 index 0421c50..0000000 --- a/internal/apimodule/fileserver/servefile.go +++ /dev/null @@ -1,243 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . -*/ - -package fileserver - -import ( - "bytes" - "net/http" - "strings" - - "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/media" -) - -// ServeFile is for serving attachments, headers, and avatars to the requester from instance storage. -// -// Note: to mitigate scraping attempts, no information should be given out on a bad request except "404 page not found". -// Don't give away account ids or media ids or anything like that; callers shouldn't be able to infer anything. -func (m *FileServer) ServeFile(c *gin.Context) { - l := m.log.WithFields(logrus.Fields{ - "func": "ServeFile", - "request_uri": c.Request.RequestURI, - "user_agent": c.Request.UserAgent(), - "origin_ip": c.ClientIP(), - }) - l.Trace("received request") - - // We use request params to check what to pull out of the database/storage so check everything. A request URL should be formatted as follows: - // "https://example.org/fileserver/[ACCOUNT_ID]/[MEDIA_TYPE]/[MEDIA_SIZE]/[FILE_NAME]" - // "FILE_NAME" consists of two parts, the attachment's database id, a period, and the file extension. - accountID := c.Param(AccountIDKey) - if accountID == "" { - l.Debug("missing accountID from request") - c.String(http.StatusNotFound, "404 page not found") - return - } - - mediaType := c.Param(MediaTypeKey) - if mediaType == "" { - l.Debug("missing mediaType from request") - c.String(http.StatusNotFound, "404 page not found") - return - } - - mediaSize := c.Param(MediaSizeKey) - if mediaSize == "" { - l.Debug("missing mediaSize from request") - c.String(http.StatusNotFound, "404 page not found") - return - } - - fileName := c.Param(FileNameKey) - if fileName == "" { - l.Debug("missing fileName from request") - c.String(http.StatusNotFound, "404 page not found") - return - } - - // Only serve media types that are defined in our internal media module - switch mediaType { - case media.MediaHeader, media.MediaAvatar, media.MediaAttachment: - m.serveAttachment(c, accountID, mediaType, mediaSize, fileName) - return - case media.MediaEmoji: - m.serveEmoji(c, accountID, mediaType, mediaSize, fileName) - return - } - l.Debugf("mediatype %s not recognized", mediaType) - c.String(http.StatusNotFound, "404 page not found") -} - -func (m *FileServer) serveAttachment(c *gin.Context, accountID string, mediaType string, mediaSize string, fileName string) { - l := m.log.WithFields(logrus.Fields{ - "func": "serveAttachment", - "request_uri": c.Request.RequestURI, - "user_agent": c.Request.UserAgent(), - "origin_ip": c.ClientIP(), - }) - - // This corresponds to original-sized image as it was uploaded, small (which is the thumbnail), or static - switch mediaSize { - case media.MediaOriginal, media.MediaSmall, media.MediaStatic: - default: - l.Debugf("mediasize %s not recognized", mediaSize) - c.String(http.StatusNotFound, "404 page not found") - return - } - - // derive the media id and the file extension from the last part of the request - spl := strings.Split(fileName, ".") - if len(spl) != 2 { - l.Debugf("filename %s not parseable", fileName) - c.String(http.StatusNotFound, "404 page not found") - return - } - wantedMediaID := spl[0] - fileExtension := spl[1] - if wantedMediaID == "" || fileExtension == "" { - l.Debugf("filename %s not parseable", fileName) - c.String(http.StatusNotFound, "404 page not found") - return - } - - // now we know the attachment ID that the caller is asking for we can use it to pull the attachment out of the db - attachment := >smodel.MediaAttachment{} - if err := m.db.GetByID(wantedMediaID, attachment); err != nil { - l.Debugf("attachment with id %s not retrievable: %s", wantedMediaID, err) - c.String(http.StatusNotFound, "404 page not found") - return - } - - // make sure the given account id owns the requested attachment - if accountID != attachment.AccountID { - l.Debugf("account %s does not own attachment with id %s", accountID, wantedMediaID) - c.String(http.StatusNotFound, "404 page not found") - return - } - - // now we can start preparing the response depending on whether we're serving a thumbnail or a larger attachment - var storagePath string - var contentType string - var contentLength int - switch mediaSize { - case media.MediaOriginal: - storagePath = attachment.File.Path - contentType = attachment.File.ContentType - contentLength = attachment.File.FileSize - case media.MediaSmall: - storagePath = attachment.Thumbnail.Path - contentType = attachment.Thumbnail.ContentType - contentLength = attachment.Thumbnail.FileSize - } - - // use the path listed on the attachment we pulled out of the database to retrieve the object from storage - attachmentBytes, err := m.storage.RetrieveFileFrom(storagePath) - if err != nil { - l.Debugf("error retrieving from storage: %s", err) - c.String(http.StatusNotFound, "404 page not found") - return - } - - l.Errorf("about to serve content length: %d attachment bytes is: %d", int64(contentLength), int64(len(attachmentBytes))) - - // finally we can return with all the information we derived above - c.DataFromReader(http.StatusOK, int64(contentLength), contentType, bytes.NewReader(attachmentBytes), map[string]string{}) -} - -func (m *FileServer) serveEmoji(c *gin.Context, accountID string, mediaType string, mediaSize string, fileName string) { - l := m.log.WithFields(logrus.Fields{ - "func": "serveEmoji", - "request_uri": c.Request.RequestURI, - "user_agent": c.Request.UserAgent(), - "origin_ip": c.ClientIP(), - }) - - // This corresponds to original-sized emoji as it was uploaded, or static - switch mediaSize { - case media.MediaOriginal, media.MediaStatic: - default: - l.Debugf("mediasize %s not recognized", mediaSize) - c.String(http.StatusNotFound, "404 page not found") - return - } - - // derive the media id and the file extension from the last part of the request - spl := strings.Split(fileName, ".") - if len(spl) != 2 { - l.Debugf("filename %s not parseable", fileName) - c.String(http.StatusNotFound, "404 page not found") - return - } - wantedEmojiID := spl[0] - fileExtension := spl[1] - if wantedEmojiID == "" || fileExtension == "" { - l.Debugf("filename %s not parseable", fileName) - c.String(http.StatusNotFound, "404 page not found") - return - } - - // now we know the attachment ID that the caller is asking for we can use it to pull the attachment out of the db - emoji := >smodel.Emoji{} - if err := m.db.GetByID(wantedEmojiID, emoji); err != nil { - l.Debugf("emoji with id %s not retrievable: %s", wantedEmojiID, err) - c.String(http.StatusNotFound, "404 page not found") - return - } - - // make sure the instance account id owns the requested emoji - instanceAccount := >smodel.Account{} - if err := m.db.GetWhere("username", m.config.Host, instanceAccount); err != nil { - l.Debugf("error fetching instance account: %s", err) - c.String(http.StatusNotFound, "404 page not found") - return - } - if accountID != instanceAccount.ID { - l.Debugf("account %s does not own emoji with id %s", accountID, wantedEmojiID) - c.String(http.StatusNotFound, "404 page not found") - return - } - - // now we can start preparing the response depending on whether we're serving a thumbnail or a larger attachment - var storagePath string - var contentType string - var contentLength int - switch mediaSize { - case media.MediaOriginal: - storagePath = emoji.ImagePath - contentType = emoji.ImageContentType - contentLength = emoji.ImageFileSize - case media.MediaStatic: - storagePath = emoji.ImageStaticPath - contentType = "image/png" - contentLength = emoji.ImageStaticFileSize - } - - // use the path listed on the emoji we pulled out of the database to retrieve the object from storage - emojiBytes, err := m.storage.RetrieveFileFrom(storagePath) - if err != nil { - l.Debugf("error retrieving emoji from storage: %s", err) - c.String(http.StatusNotFound, "404 page not found") - return - } - - // finally we can return with all the information we derived above - c.DataFromReader(http.StatusOK, int64(contentLength), contentType, bytes.NewReader(emojiBytes), map[string]string{}) -} diff --git a/internal/apimodule/media/mediacreate.go b/internal/apimodule/media/mediacreate.go deleted file mode 100644 index ee713a4..0000000 --- a/internal/apimodule/media/mediacreate.go +++ /dev/null @@ -1,193 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . -*/ - -package media - -import ( - "bytes" - "errors" - "fmt" - "io" - "net/http" - "strconv" - "strings" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/config" - mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// MediaCreatePOSTHandler handles requests to create/upload media attachments -func (m *Module) MediaCreatePOSTHandler(c *gin.Context) { - l := m.log.WithField("func", "statusCreatePOSTHandler") - authed, err := oauth.MustAuth(c, true, true, true, true) // posting new media is serious business so we want *everything* - if err != nil { - l.Debugf("couldn't auth: %s", err) - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) - return - } - - // First check this user/account is permitted to create media - // There's no point continuing otherwise. - if authed.User.Disabled || !authed.User.Approved || !authed.Account.SuspendedAt.IsZero() { - l.Debugf("couldn't auth: %s", err) - c.JSON(http.StatusForbidden, gin.H{"error": "account is disabled, not yet approved, or suspended"}) - return - } - - // extract the media create form from the request context - l.Tracef("parsing request form: %s", c.Request.Form) - form := &mastotypes.AttachmentRequest{} - if err := c.ShouldBind(form); err != nil || form == nil { - l.Debugf("could not parse form from request: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"}) - return - } - - // Give the fields on the request form a first pass to make sure the request is superficially valid. - l.Tracef("validating form %+v", form) - if err := validateCreateMedia(form, m.config.MediaConfig); err != nil { - l.Debugf("error validating form: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // open the attachment and extract the bytes from it - f, err := form.File.Open() - if err != nil { - l.Debugf("error opening attachment: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not open provided attachment: %s", err)}) - return - } - buf := new(bytes.Buffer) - size, err := io.Copy(buf, f) - if err != nil { - l.Debugf("error reading attachment: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not read provided attachment: %s", err)}) - return - } - if size == 0 { - l.Debug("could not read provided attachment: size 0 bytes") - c.JSON(http.StatusBadRequest, gin.H{"error": "could not read provided attachment: size 0 bytes"}) - return - } - - // allow the mediaHandler to work its magic of processing the attachment bytes, and putting them in whatever storage backend we're using - attachment, err := m.mediaHandler.ProcessLocalAttachment(buf.Bytes(), authed.Account.ID) - if err != nil { - l.Debugf("error reading attachment: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not process attachment: %s", err)}) - return - } - - // 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 - // TODO: tidy this up into a separate function and just return an error so all the c.JSON and return calls are obviated - var focusx, focusy float32 - if form.Focus != "" { - spl := strings.Split(form.Focus, ",") - if len(spl) != 2 { - l.Debugf("improperly formatted focus %s", form.Focus) - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)}) - return - } - xStr := spl[0] - yStr := spl[1] - if xStr == "" || yStr == "" { - l.Debugf("improperly formatted focus %s", form.Focus) - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)}) - return - } - fx, err := strconv.ParseFloat(xStr, 32) - if err != nil { - l.Debugf("improperly formatted focus %s: %s", form.Focus, err) - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)}) - return - } - if fx > 1 || fx < -1 { - l.Debugf("improperly formatted focus %s", form.Focus) - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)}) - return - } - focusx = float32(fx) - fy, err := strconv.ParseFloat(yStr, 32) - if err != nil { - l.Debugf("improperly formatted focus %s: %s", form.Focus, err) - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)}) - return - } - if fy > 1 || fy < -1 { - l.Debugf("improperly formatted focus %s", form.Focus) - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)}) - return - } - focusy = float32(fy) - } - 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 := m.mastoConverter.AttachmentToMasto(attachment) - if err != nil { - l.Debugf("error parsing media attachment to frontend type: %s", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("error parsing media attachment to frontend type: %s", err)}) - return - } - - // now we can confidently put the attachment in the database - if err := m.db.Put(attachment); err != nil { - l.Debugf("error storing media attachment in db: %s", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("error storing media attachment in db: %s", err)}) - return - } - - // and return its frontend representation - c.JSON(http.StatusAccepted, mastoAttachment) -} - -func validateCreateMedia(form *mastotypes.AttachmentRequest, config *config.MediaConfig) error { - // check there actually is a file attached and it's not size 0 - if form.File == nil || form.File.Size == 0 { - return errors.New("no attachment given") - } - - // a very superficial check to see if no size limits are exceeded - // we still don't actually know which media types we're dealing with but the other handlers will go into more detail there - maxSize := config.MaxVideoSize - if config.MaxImageSize > maxSize { - maxSize = config.MaxImageSize - } - if form.File.Size > int64(maxSize) { - return fmt.Errorf("file size limit exceeded: limit is %d bytes but attachment was %d bytes", maxSize, form.File.Size) - } - - if len(form.Description) < config.MinDescriptionChars || len(form.Description) > config.MaxDescriptionChars { - return fmt.Errorf("image description length must be between %d and %d characters (inclusive), but provided image description was %d chars", config.MinDescriptionChars, config.MaxDescriptionChars, len(form.Description)) - } - - // TODO: validate focus here - - return nil -} diff --git a/internal/apimodule/mock_ClientAPIModule.go b/internal/apimodule/mock_ClientAPIModule.go deleted file mode 100644 index 2d4293d..0000000 --- a/internal/apimodule/mock_ClientAPIModule.go +++ /dev/null @@ -1,43 +0,0 @@ -// Code generated by mockery v2.7.4. DO NOT EDIT. - -package apimodule - -import ( - mock "github.com/stretchr/testify/mock" - db "github.com/superseriousbusiness/gotosocial/internal/db" - - router "github.com/superseriousbusiness/gotosocial/internal/router" -) - -// MockClientAPIModule is an autogenerated mock type for the ClientAPIModule type -type MockClientAPIModule struct { - mock.Mock -} - -// CreateTables provides a mock function with given fields: _a0 -func (_m *MockClientAPIModule) CreateTables(_a0 db.DB) error { - ret := _m.Called(_a0) - - var r0 error - if rf, ok := ret.Get(0).(func(db.DB) error); ok { - r0 = rf(_a0) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// Route provides a mock function with given fields: s -func (_m *MockClientAPIModule) Route(s router.Router) error { - ret := _m.Called(s) - - var r0 error - if rf, ok := ret.Get(0).(func(router.Router) error); ok { - r0 = rf(s) - } else { - r0 = ret.Error(0) - } - - return r0 -} diff --git a/internal/apimodule/status/statuscreate.go b/internal/apimodule/status/statuscreate.go deleted file mode 100644 index 97354e7..0000000 --- a/internal/apimodule/status/statuscreate.go +++ /dev/null @@ -1,462 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . -*/ - -package status - -import ( - "errors" - "fmt" - "net/http" - "time" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/distributor" - mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/util" -) - -type advancedStatusCreateForm struct { - mastotypes.StatusCreateRequest - advancedVisibilityFlagsForm -} - -type advancedVisibilityFlagsForm struct { - // The gotosocial visibility model - VisibilityAdvanced *gtsmodel.Visibility `form:"visibility_advanced"` - // This status will be federated beyond the local timeline(s) - Federated *bool `form:"federated"` - // This status can be boosted/reblogged - Boostable *bool `form:"boostable"` - // This status can be replied to - Replyable *bool `form:"replyable"` - // This status can be liked/faved - Likeable *bool `form:"likeable"` -} - -// StatusCreatePOSTHandler deals with the creation of new statuses -func (m *Module) StatusCreatePOSTHandler(c *gin.Context) { - l := m.log.WithField("func", "statusCreatePOSTHandler") - authed, err := oauth.MustAuth(c, true, true, true, true) // posting a status is serious business so we want *everything* - if err != nil { - l.Debugf("couldn't auth: %s", err) - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) - return - } - - // First check this user/account is permitted to post new statuses. - // There's no point continuing otherwise. - if authed.User.Disabled || !authed.User.Approved || !authed.Account.SuspendedAt.IsZero() { - l.Debugf("couldn't auth: %s", err) - c.JSON(http.StatusForbidden, gin.H{"error": "account is disabled, not yet approved, or suspended"}) - return - } - - // extract the status create form from the request context - l.Tracef("parsing request form: %s", c.Request.Form) - form := &advancedStatusCreateForm{} - if err := c.ShouldBind(form); err != nil || form == nil { - l.Debugf("could not parse form from request: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"}) - 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 := validateCreateStatus(form, m.config.StatusesConfig); err != nil { - l.Debugf("error validating form: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // At this point we know the account is permitted to post, and we know the request form - // is valid (at least according to the API specifications and the instance configuration). - // So now we can start digging a bit deeper into the form and building up the new status from it. - - // first we create a new status and add some basic info to it - uris := util.GenerateURIs(authed.Account.Username, m.config.Protocol, m.config.Host) - thisStatusID := uuid.NewString() - thisStatusURI := fmt.Sprintf("%s/%s", uris.StatusesURI, thisStatusID) - thisStatusURL := fmt.Sprintf("%s/%s", uris.StatusesURL, thisStatusID) - newStatus := >smodel.Status{ - ID: thisStatusID, - URI: thisStatusURI, - URL: thisStatusURL, - Content: util.HTMLFormat(form.Status), - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - Local: true, - AccountID: authed.Account.ID, - ContentWarning: form.SpoilerText, - ActivityStreamsType: gtsmodel.ActivityStreamsNote, - Sensitive: form.Sensitive, - Language: form.Language, - CreatedWithApplicationID: authed.Application.ID, - Text: form.Status, - } - - // check if replyToID is ok - if err := m.parseReplyToID(form, authed.Account.ID, newStatus); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // check if mediaIDs are ok - if err := m.parseMediaIDs(form, authed.Account.ID, newStatus); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // check if visibility settings are ok - if err := parseVisibility(form, authed.Account.Privacy, newStatus); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // handle language settings - if err := parseLanguage(form, authed.Account.Language, newStatus); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // handle mentions - if err := m.parseMentions(form, authed.Account.ID, newStatus); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if err := m.parseTags(form, authed.Account.ID, newStatus); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if err := m.parseEmojis(form, authed.Account.ID, newStatus); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - /* - FROM THIS POINT ONWARDS WE ARE HAPPY WITH THE STATUS -- it is valid and we will try to create it - */ - - // put the new status in the database, generating an ID for it in the process - if err := m.db.Put(newStatus); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - // change the status ID of the media attachments to the new status - for _, a := range newStatus.GTSMediaAttachments { - a.StatusID = newStatus.ID - a.UpdatedAt = time.Now() - if err := m.db.UpdateByID(a.ID, a); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - } - - // pass to the distributor to take care of side effects asynchronously -- federation, mentions, updating metadata, etc, etc - m.distributor.FromClientAPI() <- distributor.FromClientAPI{ - APObjectType: gtsmodel.ActivityStreamsNote, - APActivityType: gtsmodel.ActivityStreamsCreate, - Activity: newStatus, - } - - // return the frontend representation of the new status to the submitter - mastoStatus, err := m.mastoConverter.StatusToMasto(newStatus, authed.Account, authed.Account, nil, newStatus.GTSReplyToAccount, nil) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, mastoStatus) -} - -func validateCreateStatus(form *advancedStatusCreateForm, config *config.StatusesConfig) error { - // validate that, structurally, we have a valid status/post - if form.Status == "" && form.MediaIDs == nil && form.Poll == nil { - return errors.New("no status, media, or poll provided") - } - - if form.MediaIDs != nil && form.Poll != nil { - return errors.New("can't post media + poll in same status") - } - - // validate status - if form.Status != "" { - if len(form.Status) > config.MaxChars { - return fmt.Errorf("status too long, %d characters provided but limit is %d", len(form.Status), config.MaxChars) - } - } - - // validate media attachments - if len(form.MediaIDs) > config.MaxMediaFiles { - return fmt.Errorf("too many media files attached to status, %d attached but limit is %d", len(form.MediaIDs), config.MaxMediaFiles) - } - - // validate poll - if form.Poll != nil { - if form.Poll.Options == nil { - return errors.New("poll with no options") - } - if len(form.Poll.Options) > config.PollMaxOptions { - return fmt.Errorf("too many poll options provided, %d provided but limit is %d", len(form.Poll.Options), config.PollMaxOptions) - } - for _, p := range form.Poll.Options { - if len(p) > config.PollOptionMaxChars { - return fmt.Errorf("poll option too long, %d characters provided but limit is %d", len(p), config.PollOptionMaxChars) - } - } - } - - // validate spoiler text/cw - if form.SpoilerText != "" { - if len(form.SpoilerText) > config.CWMaxChars { - return fmt.Errorf("content-warning/spoilertext too long, %d characters provided but limit is %d", len(form.SpoilerText), config.CWMaxChars) - } - } - - // validate post language - if form.Language != "" { - if err := util.ValidateLanguage(form.Language); err != nil { - return err - } - } - - return nil -} - -func parseVisibility(form *advancedStatusCreateForm, accountDefaultVis gtsmodel.Visibility, status *gtsmodel.Status) error { - // by default all flags are set to true - gtsAdvancedVis := >smodel.VisibilityAdvanced{ - Federated: true, - Boostable: true, - Replyable: true, - Likeable: true, - } - - var gtsBasicVis gtsmodel.Visibility - // Advanced takes priority if it's set. - // If it's not set, take whatever masto visibility is set. - // If *that's* not set either, then just take the account default. - // If that's also not set, take the default for the whole instance. - if form.VisibilityAdvanced != nil { - gtsBasicVis = *form.VisibilityAdvanced - } else if form.Visibility != "" { - gtsBasicVis = util.ParseGTSVisFromMastoVis(form.Visibility) - } else if accountDefaultVis != "" { - gtsBasicVis = accountDefaultVis - } else { - gtsBasicVis = gtsmodel.VisibilityDefault - } - - switch gtsBasicVis { - case gtsmodel.VisibilityPublic: - // for public, there's no need to change any of the advanced flags from true regardless of what the user filled out - break - case gtsmodel.VisibilityUnlocked: - // for unlocked the user can set any combination of flags they like so look at them all to see if they're set and then apply them - if form.Federated != nil { - gtsAdvancedVis.Federated = *form.Federated - } - - if form.Boostable != nil { - gtsAdvancedVis.Boostable = *form.Boostable - } - - if form.Replyable != nil { - gtsAdvancedVis.Replyable = *form.Replyable - } - - if form.Likeable != nil { - gtsAdvancedVis.Likeable = *form.Likeable - } - - case gtsmodel.VisibilityFollowersOnly, gtsmodel.VisibilityMutualsOnly: - // for followers or mutuals only, boostable will *always* be false, but the other fields can be set so check and apply them - gtsAdvancedVis.Boostable = false - - if form.Federated != nil { - gtsAdvancedVis.Federated = *form.Federated - } - - if form.Replyable != nil { - gtsAdvancedVis.Replyable = *form.Replyable - } - - if form.Likeable != nil { - gtsAdvancedVis.Likeable = *form.Likeable - } - - case gtsmodel.VisibilityDirect: - // direct is pretty easy: there's only one possible setting so return it - gtsAdvancedVis.Federated = true - gtsAdvancedVis.Boostable = false - gtsAdvancedVis.Federated = true - gtsAdvancedVis.Likeable = true - } - - status.Visibility = gtsBasicVis - status.VisibilityAdvanced = gtsAdvancedVis - return nil -} - -func (m *Module) parseReplyToID(form *advancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) error { - if form.InReplyToID == "" { - return nil - } - - // If this status is a reply to another status, we need to do a bit of work to establish whether or not this status can be posted: - // - // 1. Does the replied status exist in the database? - // 2. Is the replied status marked as replyable? - // 3. Does a block exist between either the current account or the account that posted the status it's replying to? - // - // If this is all OK, then we fetch the repliedStatus and the repliedAccount for later processing. - repliedStatus := >smodel.Status{} - repliedAccount := >smodel.Account{} - // check replied status exists + is replyable - if err := m.db.GetByID(form.InReplyToID, repliedStatus); err != nil { - if _, ok := err.(db.ErrNoEntries); ok { - return fmt.Errorf("status with id %s not replyable because it doesn't exist", form.InReplyToID) - } - return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err) - } - - if !repliedStatus.VisibilityAdvanced.Replyable { - return fmt.Errorf("status with id %s is marked as not replyable", form.InReplyToID) - } - - // check replied account is known to us - if err := m.db.GetByID(repliedStatus.AccountID, repliedAccount); err != nil { - if _, ok := err.(db.ErrNoEntries); ok { - return fmt.Errorf("status with id %s not replyable because account id %s is not known", form.InReplyToID, repliedStatus.AccountID) - } - return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err) - } - // check if a block exists - if blocked, err := m.db.Blocked(thisAccountID, repliedAccount.ID); err != nil { - if _, ok := err.(db.ErrNoEntries); !ok { - return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err) - } - } else if blocked { - return fmt.Errorf("status with id %s not replyable", form.InReplyToID) - } - status.InReplyToID = repliedStatus.ID - status.InReplyToAccountID = repliedAccount.ID - - return nil -} - -func (m *Module) parseMediaIDs(form *advancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) error { - if form.MediaIDs == nil { - return nil - } - - gtsMediaAttachments := []*gtsmodel.MediaAttachment{} - attachments := []string{} - for _, mediaID := range form.MediaIDs { - // check these attachments exist - a := >smodel.MediaAttachment{} - if err := m.db.GetByID(mediaID, a); err != nil { - return fmt.Errorf("invalid media type or media not found for media id %s", mediaID) - } - // check they belong to the requesting account id - if a.AccountID != thisAccountID { - return fmt.Errorf("media with id %s does not belong to account %s", mediaID, thisAccountID) - } - // check they're not already used in a status - if a.StatusID != "" || a.ScheduledStatusID != "" { - return fmt.Errorf("media with id %s is already attached to a status", mediaID) - } - gtsMediaAttachments = append(gtsMediaAttachments, a) - attachments = append(attachments, a.ID) - } - status.GTSMediaAttachments = gtsMediaAttachments - status.Attachments = attachments - return nil -} - -func parseLanguage(form *advancedStatusCreateForm, accountDefaultLanguage string, status *gtsmodel.Status) error { - if form.Language != "" { - status.Language = form.Language - } else { - status.Language = accountDefaultLanguage - } - if status.Language == "" { - return errors.New("no language given either in status create form or account default") - } - return nil -} - -func (m *Module) parseMentions(form *advancedStatusCreateForm, accountID string, status *gtsmodel.Status) error { - menchies := []string{} - gtsMenchies, err := m.db.MentionStringsToMentions(util.DeriveMentions(form.Status), accountID, status.ID) - if err != nil { - return fmt.Errorf("error generating mentions from status: %s", err) - } - for _, menchie := range gtsMenchies { - if err := m.db.Put(menchie); err != nil { - return fmt.Errorf("error putting mentions in db: %s", err) - } - menchies = append(menchies, menchie.TargetAccountID) - } - // add full populated gts menchies to the status for passing them around conveniently - status.GTSMentions = gtsMenchies - // add just the ids of the mentioned accounts to the status for putting in the db - status.Mentions = menchies - return nil -} - -func (m *Module) parseTags(form *advancedStatusCreateForm, accountID string, status *gtsmodel.Status) error { - tags := []string{} - gtsTags, err := m.db.TagStringsToTags(util.DeriveHashtags(form.Status), accountID, status.ID) - if err != nil { - return fmt.Errorf("error generating hashtags from status: %s", err) - } - for _, tag := range gtsTags { - if err := m.db.Upsert(tag, "name"); err != nil { - return fmt.Errorf("error putting tags in db: %s", err) - } - tags = append(tags, tag.ID) - } - // add full populated gts tags to the status for passing them around conveniently - status.GTSTags = gtsTags - // add just the ids of the used tags to the status for putting in the db - status.Tags = tags - return nil -} - -func (m *Module) parseEmojis(form *advancedStatusCreateForm, accountID string, status *gtsmodel.Status) error { - emojis := []string{} - gtsEmojis, err := m.db.EmojiStringsToEmojis(util.DeriveEmojis(form.Status), accountID, status.ID) - if err != nil { - return fmt.Errorf("error generating emojis from status: %s", err) - } - for _, e := range gtsEmojis { - emojis = append(emojis, e.ID) - } - // add full populated gts emojis to the status for passing them around conveniently - status.GTSEmojis = gtsEmojis - // add just the ids of the used emojis to the status for putting in the db - status.Emojis = emojis - return nil -} diff --git a/internal/apimodule/status/statusdelete.go b/internal/apimodule/status/statusdelete.go deleted file mode 100644 index 01dfe81..0000000 --- a/internal/apimodule/status/statusdelete.go +++ /dev/null @@ -1,107 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . -*/ - -package status - -import ( - "fmt" - "net/http" - - "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/distributor" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// StatusDELETEHandler verifies and handles deletion of a status -func (m *Module) StatusDELETEHandler(c *gin.Context) { - l := m.log.WithFields(logrus.Fields{ - "func": "StatusDELETEHandler", - "request_uri": c.Request.RequestURI, - "user_agent": c.Request.UserAgent(), - "origin_ip": c.ClientIP(), - }) - l.Debugf("entering function") - - authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else - if err != nil { - l.Debug("not authed so can't delete status") - c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"}) - return - } - - targetStatusID := c.Param(IDKey) - if targetStatusID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"}) - return - } - - l.Tracef("going to search for target status %s", targetStatusID) - targetStatus := >smodel.Status{} - if err := m.db.GetByID(targetStatusID, targetStatus); err != nil { - l.Errorf("error fetching status %s: %s", targetStatusID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - if targetStatus.AccountID != authed.Account.ID { - l.Debug("status doesn't belong to requesting account") - c.JSON(http.StatusForbidden, gin.H{"error": "not allowed"}) - return - } - - l.Trace("going to get relevant accounts") - relevantAccounts, err := m.db.PullRelevantAccountsFromStatus(targetStatus) - if err != nil { - l.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - var boostOfStatus *gtsmodel.Status - if targetStatus.BoostOfID != "" { - boostOfStatus = >smodel.Status{} - if err := m.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { - l.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - } - - mastoStatus, err := m.mastoConverter.StatusToMasto(targetStatus, authed.Account, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) - if err != nil { - l.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - if err := m.db.DeleteByID(targetStatus.ID, targetStatus); err != nil { - l.Errorf("error deleting status from the database: %s", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - m.distributor.FromClientAPI() <- distributor.FromClientAPI{ - APObjectType: gtsmodel.ActivityStreamsNote, - APActivityType: gtsmodel.ActivityStreamsDelete, - Activity: targetStatus, - } - - c.JSON(http.StatusOK, mastoStatus) -} diff --git a/internal/apimodule/status/statusfave.go b/internal/apimodule/status/statusfave.go deleted file mode 100644 index 9ce68af..0000000 --- a/internal/apimodule/status/statusfave.go +++ /dev/null @@ -1,137 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . -*/ - -package status - -import ( - "fmt" - "net/http" - - "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/distributor" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// StatusFavePOSTHandler handles fave requests against a given status ID -func (m *Module) StatusFavePOSTHandler(c *gin.Context) { - l := m.log.WithFields(logrus.Fields{ - "func": "StatusFavePOSTHandler", - "request_uri": c.Request.RequestURI, - "user_agent": c.Request.UserAgent(), - "origin_ip": c.ClientIP(), - }) - l.Debugf("entering function") - - authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else - if err != nil { - l.Debug("not authed so can't fave status") - c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"}) - return - } - - targetStatusID := c.Param(IDKey) - if targetStatusID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"}) - return - } - - l.Tracef("going to search for target status %s", targetStatusID) - targetStatus := >smodel.Status{} - if err := m.db.GetByID(targetStatusID, targetStatus); err != nil { - l.Errorf("error fetching status %s: %s", targetStatusID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - l.Tracef("going to search for target account %s", targetStatus.AccountID) - targetAccount := >smodel.Account{} - if err := m.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { - l.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - l.Trace("going to get relevant accounts") - relevantAccounts, err := m.db.PullRelevantAccountsFromStatus(targetStatus) - if err != nil { - l.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - l.Trace("going to see if status is visible") - visible, err := m.db.StatusVisible(targetStatus, targetAccount, authed.Account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that - if err != nil { - l.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - if !visible { - l.Trace("status is not visible") - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - // is the status faveable? - if !targetStatus.VisibilityAdvanced.Likeable { - l.Debug("status is not faveable") - c.JSON(http.StatusForbidden, gin.H{"error": fmt.Sprintf("status %s not faveable", targetStatusID)}) - return - } - - // it's visible! it's faveable! so let's fave the FUCK out of it - fave, err := m.db.FaveStatus(targetStatus, authed.Account.ID) - if err != nil { - l.Debugf("error faveing status: %s", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - var boostOfStatus *gtsmodel.Status - if targetStatus.BoostOfID != "" { - boostOfStatus = >smodel.Status{} - if err := m.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { - l.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - } - - mastoStatus, err := m.mastoConverter.StatusToMasto(targetStatus, targetAccount, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) - if err != nil { - l.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - // if the targeted status was already faved, faved will be nil - // only put the fave in the distributor if something actually changed - if fave != nil { - fave.FavedStatus = targetStatus // attach the status pointer to the fave for easy retrieval in the distributor - m.distributor.FromClientAPI() <- distributor.FromClientAPI{ - APObjectType: gtsmodel.ActivityStreamsNote, // status is a note - APActivityType: gtsmodel.ActivityStreamsLike, // we're creating a like/fave on the note - Activity: fave, // pass the fave along for processing - } - } - - c.JSON(http.StatusOK, mastoStatus) -} diff --git a/internal/apimodule/status/statusfavedby.go b/internal/apimodule/status/statusfavedby.go deleted file mode 100644 index 58236ed..0000000 --- a/internal/apimodule/status/statusfavedby.go +++ /dev/null @@ -1,129 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . -*/ - -package status - -import ( - "fmt" - "net/http" - - "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// StatusFavedByGETHandler is for serving a list of accounts that have faved a given status -func (m *Module) StatusFavedByGETHandler(c *gin.Context) { - l := m.log.WithFields(logrus.Fields{ - "func": "statusGETHandler", - "request_uri": c.Request.RequestURI, - "user_agent": c.Request.UserAgent(), - "origin_ip": c.ClientIP(), - }) - l.Debugf("entering function") - - var requestingAccount *gtsmodel.Account - authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else - if err != nil { - l.Debug("not authed but will continue to serve anyway if public status") - requestingAccount = nil - } else { - requestingAccount = authed.Account - } - - targetStatusID := c.Param(IDKey) - if targetStatusID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"}) - return - } - - l.Tracef("going to search for target status %s", targetStatusID) - targetStatus := >smodel.Status{} - if err := m.db.GetByID(targetStatusID, targetStatus); err != nil { - l.Errorf("error fetching status %s: %s", targetStatusID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - l.Tracef("going to search for target account %s", targetStatus.AccountID) - targetAccount := >smodel.Account{} - if err := m.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { - l.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - l.Trace("going to get relevant accounts") - relevantAccounts, err := m.db.PullRelevantAccountsFromStatus(targetStatus) - if err != nil { - l.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - l.Trace("going to see if status is visible") - visible, err := m.db.StatusVisible(targetStatus, targetAccount, requestingAccount, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that - if err != nil { - l.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - if !visible { - l.Trace("status is not visible") - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - // get ALL accounts that faved a status -- doesn't take account of blocks and mutes and stuff - favingAccounts, err := m.db.WhoFavedStatus(targetStatus) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - // filter the list so the user doesn't see accounts they blocked or which blocked them - filteredAccounts := []*gtsmodel.Account{} - for _, acc := range favingAccounts { - blocked, err := m.db.Blocked(authed.Account.ID, acc.ID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if !blocked { - filteredAccounts = append(filteredAccounts, acc) - } - } - - // TODO: filter other things here? suspended? muted? silenced? - - // now we can return the masto representation of those accounts - mastoAccounts := []*mastotypes.Account{} - for _, acc := range filteredAccounts { - mastoAccount, err := m.mastoConverter.AccountToMastoPublic(acc) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - mastoAccounts = append(mastoAccounts, mastoAccount) - } - - c.JSON(http.StatusOK, mastoAccounts) -} diff --git a/internal/apimodule/status/statusget.go b/internal/apimodule/status/statusget.go deleted file mode 100644 index 76918c7..0000000 --- a/internal/apimodule/status/statusget.go +++ /dev/null @@ -1,112 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . -*/ - -package status - -import ( - "fmt" - "net/http" - - "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// StatusGETHandler is for handling requests to just get one status based on its ID -func (m *Module) StatusGETHandler(c *gin.Context) { - l := m.log.WithFields(logrus.Fields{ - "func": "statusGETHandler", - "request_uri": c.Request.RequestURI, - "user_agent": c.Request.UserAgent(), - "origin_ip": c.ClientIP(), - }) - l.Debugf("entering function") - - var requestingAccount *gtsmodel.Account - authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else - if err != nil { - l.Debug("not authed but will continue to serve anyway if public status") - requestingAccount = nil - } else { - requestingAccount = authed.Account - } - - targetStatusID := c.Param(IDKey) - if targetStatusID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"}) - return - } - - l.Tracef("going to search for target status %s", targetStatusID) - targetStatus := >smodel.Status{} - if err := m.db.GetByID(targetStatusID, targetStatus); err != nil { - l.Errorf("error fetching status %s: %s", targetStatusID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - l.Tracef("going to search for target account %s", targetStatus.AccountID) - targetAccount := >smodel.Account{} - if err := m.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { - l.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - l.Trace("going to get relevant accounts") - relevantAccounts, err := m.db.PullRelevantAccountsFromStatus(targetStatus) - if err != nil { - l.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - l.Trace("going to see if status is visible") - visible, err := m.db.StatusVisible(targetStatus, targetAccount, requestingAccount, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that - if err != nil { - l.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - if !visible { - l.Trace("status is not visible") - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - var boostOfStatus *gtsmodel.Status - if targetStatus.BoostOfID != "" { - boostOfStatus = >smodel.Status{} - if err := m.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { - l.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - } - - mastoStatus, err := m.mastoConverter.StatusToMasto(targetStatus, targetAccount, requestingAccount, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) - if err != nil { - l.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - c.JSON(http.StatusOK, mastoStatus) -} diff --git a/internal/apimodule/status/statusunfave.go b/internal/apimodule/status/statusunfave.go deleted file mode 100644 index 9c06eaf..0000000 --- a/internal/apimodule/status/statusunfave.go +++ /dev/null @@ -1,137 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . -*/ - -package status - -import ( - "fmt" - "net/http" - - "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/distributor" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// StatusUnfavePOSTHandler is for undoing a fave on a status with a given ID -func (m *Module) StatusUnfavePOSTHandler(c *gin.Context) { - l := m.log.WithFields(logrus.Fields{ - "func": "StatusUnfavePOSTHandler", - "request_uri": c.Request.RequestURI, - "user_agent": c.Request.UserAgent(), - "origin_ip": c.ClientIP(), - }) - l.Debugf("entering function") - - authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else - if err != nil { - l.Debug("not authed so can't unfave status") - c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"}) - return - } - - targetStatusID := c.Param(IDKey) - if targetStatusID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"}) - return - } - - l.Tracef("going to search for target status %s", targetStatusID) - targetStatus := >smodel.Status{} - if err := m.db.GetByID(targetStatusID, targetStatus); err != nil { - l.Errorf("error fetching status %s: %s", targetStatusID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - l.Tracef("going to search for target account %s", targetStatus.AccountID) - targetAccount := >smodel.Account{} - if err := m.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { - l.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - l.Trace("going to get relevant accounts") - relevantAccounts, err := m.db.PullRelevantAccountsFromStatus(targetStatus) - if err != nil { - l.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - l.Trace("going to see if status is visible") - visible, err := m.db.StatusVisible(targetStatus, targetAccount, authed.Account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that - if err != nil { - l.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - if !visible { - l.Trace("status is not visible") - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - // is the status faveable? - if !targetStatus.VisibilityAdvanced.Likeable { - l.Debug("status is not faveable") - c.JSON(http.StatusForbidden, gin.H{"error": fmt.Sprintf("status %s not faveable so therefore not unfave-able", targetStatusID)}) - return - } - - // it's visible! it's faveable! so let's unfave the FUCK out of it - fave, err := m.db.UnfaveStatus(targetStatus, authed.Account.ID) - if err != nil { - l.Debugf("error unfaveing status: %s", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - var boostOfStatus *gtsmodel.Status - if targetStatus.BoostOfID != "" { - boostOfStatus = >smodel.Status{} - if err := m.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { - l.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - } - - mastoStatus, err := m.mastoConverter.StatusToMasto(targetStatus, targetAccount, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) - if err != nil { - l.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err) - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) - return - } - - // fave might be nil if this status wasn't faved in the first place - // we only want to pass the message to the distributor if something actually changed - if fave != nil { - fave.FavedStatus = targetStatus // attach the status pointer to the fave for easy retrieval in the distributor - m.distributor.FromClientAPI() <- distributor.FromClientAPI{ - APObjectType: gtsmodel.ActivityStreamsNote, // status is a note - APActivityType: gtsmodel.ActivityStreamsUndo, // undo the fave - Activity: fave, // pass the undone fave along - } - } - - c.JSON(http.StatusOK, mastoStatus) -} diff --git a/internal/cache/mock_Cache.go b/internal/cache/mock_Cache.go deleted file mode 100644 index d8d18d6..0000000 --- a/internal/cache/mock_Cache.go +++ /dev/null @@ -1,47 +0,0 @@ -// Code generated by mockery v2.7.4. DO NOT EDIT. - -package cache - -import mock "github.com/stretchr/testify/mock" - -// MockCache is an autogenerated mock type for the Cache type -type MockCache struct { - mock.Mock -} - -// Fetch provides a mock function with given fields: k -func (_m *MockCache) Fetch(k string) (interface{}, error) { - ret := _m.Called(k) - - var r0 interface{} - if rf, ok := ret.Get(0).(func(string) interface{}); ok { - r0 = rf(k) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(interface{}) - } - } - - var r1 error - if rf, ok := ret.Get(1).(func(string) error); ok { - r1 = rf(k) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// Store provides a mock function with given fields: k, v -func (_m *MockCache) Store(k string, v interface{}) error { - ret := _m.Called(k, v) - - var r0 error - if rf, ok := ret.Get(0).(func(string, interface{}) error); ok { - r0 = rf(k, v) - } else { - r0 = ret.Error(0) - } - - return r0 -} diff --git a/internal/config/mock_KeyedFlags.go b/internal/config/mock_KeyedFlags.go deleted file mode 100644 index 95057d1..0000000 --- a/internal/config/mock_KeyedFlags.go +++ /dev/null @@ -1,66 +0,0 @@ -// Code generated by mockery v2.7.4. DO NOT EDIT. - -package config - -import mock "github.com/stretchr/testify/mock" - -// MockKeyedFlags is an autogenerated mock type for the KeyedFlags type -type MockKeyedFlags struct { - mock.Mock -} - -// Bool provides a mock function with given fields: k -func (_m *MockKeyedFlags) Bool(k string) bool { - ret := _m.Called(k) - - var r0 bool - if rf, ok := ret.Get(0).(func(string) bool); ok { - r0 = rf(k) - } else { - r0 = ret.Get(0).(bool) - } - - return r0 -} - -// Int provides a mock function with given fields: k -func (_m *MockKeyedFlags) Int(k string) int { - ret := _m.Called(k) - - var r0 int - if rf, ok := ret.Get(0).(func(string) int); ok { - r0 = rf(k) - } else { - r0 = ret.Get(0).(int) - } - - return r0 -} - -// IsSet provides a mock function with given fields: k -func (_m *MockKeyedFlags) IsSet(k string) bool { - ret := _m.Called(k) - - var r0 bool - if rf, ok := ret.Get(0).(func(string) bool); ok { - r0 = rf(k) - } else { - r0 = ret.Get(0).(bool) - } - - return r0 -} - -// String provides a mock function with given fields: k -func (_m *MockKeyedFlags) String(k string) string { - ret := _m.Called(k) - - var r0 string - if rf, ok := ret.Get(0).(func(string) string); ok { - r0 = rf(k) - } else { - r0 = ret.Get(0).(string) - } - - return r0 -} diff --git a/internal/db/db.go b/internal/db/db.go index 69ad7b8..3e085e1 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -20,17 +20,13 @@ package db import ( "context" - "fmt" "net" - "strings" "github.com/go-fed/activity/pub" - "github.com/sirupsen/logrus" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" ) -const dbTypePostgres string = "POSTGRES" +const DBTypePostgres string = "POSTGRES" // ErrNoEntries is to be returned from the DB interface when no entries are found for a given query. type ErrNoEntries struct{} @@ -126,6 +122,12 @@ type DB interface { // In case of no entries, a 'no entries' error will be returned GetAccountByUserID(userID string, account *gtsmodel.Account) error + // GetLocalAccountByUsername is a shortcut for the common action of fetching an account ON THIS INSTANCE + // according to its username, which should be unique. + // The given account pointer will be set to the result of the query, whatever it is. + // In case of no entries, a 'no entries' error will be returned + GetLocalAccountByUsername(username string, account *gtsmodel.Account) error + // GetFollowRequestsForAccountID is a shortcut for the common action of fetching a list of follow requests targeting the given account ID. // The given slice 'followRequests' will be set to the result of the query, whatever it is. // In case of no entries, a 'no entries' error will be returned @@ -277,14 +279,3 @@ type DB interface { // if they exist in the db and conveniently returning them if they do. EmojiStringsToEmojis(emojis []string, originAccountID string, statusID string) ([]*gtsmodel.Emoji, error) } - -// New returns a new database service that satisfies the DB interface and, by extension, -// the go-fed database interface described here: https://github.com/go-fed/activity/blob/master/pub/database.go -func New(ctx context.Context, c *config.Config, log *logrus.Logger) (DB, error) { - switch strings.ToUpper(c.DBConfig.Type) { - case dbTypePostgres: - return newPostgresService(ctx, c, log.WithField("service", "db")) - default: - return nil, fmt.Errorf("database type %s not supported", c.DBConfig.Type) - } -} diff --git a/internal/db/federating_db.go b/internal/db/federating_db.go index 16e3262..ab66b19 100644 --- a/internal/db/federating_db.go +++ b/internal/db/federating_db.go @@ -21,12 +21,16 @@ package db import ( "context" "errors" + "fmt" "net/url" "sync" "github.com/go-fed/activity/pub" "github.com/go-fed/activity/streams/vocab" + "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/util" ) // FederatingDB uses the underlying DB interface to implement the go-fed pub.Database interface. @@ -35,13 +39,15 @@ type federatingDB struct { locks *sync.Map db DB config *config.Config + log *logrus.Logger } -func newFederatingDB(db DB, config *config.Config) pub.Database { +func NewFederatingDB(db DB, config *config.Config, log *logrus.Logger) pub.Database { return &federatingDB{ locks: new(sync.Map), db: db, config: config, + log: log, } } @@ -98,7 +104,30 @@ func (f *federatingDB) Unlock(c context.Context, id *url.URL) error { // // The library makes this call only after acquiring a lock first. func (f *federatingDB) InboxContains(c context.Context, inbox, id *url.URL) (contains bool, err error) { - return false, nil + + if !util.IsInboxPath(inbox) { + return false, fmt.Errorf("%s is not an inbox URI", inbox.String()) + } + + if !util.IsStatusesPath(id) { + return false, fmt.Errorf("%s is not a status URI", id.String()) + } + _, statusID, err := util.ParseStatusesPath(inbox) + if err != nil { + return false, fmt.Errorf("status URI %s was not parseable: %s", id.String(), err) + } + + if err := f.db.GetByID(statusID, >smodel.Status{}); err != nil { + if _, ok := err.(ErrNoEntries); ok { + // we don't have it + return false, nil + } + // actual error + return false, fmt.Errorf("error getting status from db: %s", err) + } + + // we must have it + return true, nil } // GetInbox returns the first ordered collection page of the outbox at @@ -118,26 +147,86 @@ func (f *federatingDB) SetInbox(c context.Context, inbox vocab.ActivityStreamsOr return nil } -// Owns returns true if the database has an entry for the IRI and it -// exists in the database. -// +// Owns returns true if the IRI belongs to this instance, and if +// the database has an entry for the IRI. // The library makes this call only after acquiring a lock first. -func (f *federatingDB) Owns(c context.Context, id *url.URL) (owns bool, err error) { - return false, nil +func (f *federatingDB) Owns(c context.Context, id *url.URL) (bool, error) { + // if the id host isn't this instance host, we don't own this IRI + if id.Host != f.config.Host { + return false, nil + } + + // apparently we own it, so what *is* it? + + // check if it's a status, eg /users/example_username/statuses/SOME_UUID_OF_A_STATUS + if util.IsStatusesPath(id) { + _, uid, err := util.ParseStatusesPath(id) + if err != nil { + return false, fmt.Errorf("error parsing statuses path for url %s: %s", id.String(), err) + } + if err := f.db.GetWhere("uri", uid, >smodel.Status{}); err != nil { + if _, ok := err.(ErrNoEntries); ok { + // there are no entries for this status + return false, nil + } + // an actual error happened + return false, fmt.Errorf("database error fetching status with id %s: %s", uid, err) + } + return true, nil + } + + // check if it's a user, eg /users/example_username + if util.IsUserPath(id) { + username, err := util.ParseUserPath(id) + if err != nil { + return false, fmt.Errorf("error parsing statuses path for url %s: %s", id.String(), err) + } + if err := f.db.GetLocalAccountByUsername(username, >smodel.Account{}); err != nil { + if _, ok := err.(ErrNoEntries); ok { + // there are no entries for this username + return false, nil + } + // an actual error happened + return false, fmt.Errorf("database error fetching account with username %s: %s", username, err) + } + return true, nil + } + + return false, fmt.Errorf("could not match activityID: %s", id.String()) } // ActorForOutbox fetches the actor's IRI for the given outbox IRI. // // The library makes this call only after acquiring a lock first. func (f *federatingDB) ActorForOutbox(c context.Context, outboxIRI *url.URL) (actorIRI *url.URL, err error) { - return nil, nil + if !util.IsOutboxPath(outboxIRI) { + return nil, fmt.Errorf("%s is not an outbox URI", outboxIRI.String()) + } + acct := >smodel.Account{} + if err := f.db.GetWhere("outbox_uri", outboxIRI.String(), acct); err != nil { + if _, ok := err.(ErrNoEntries); ok { + return nil, fmt.Errorf("no actor found that corresponds to outbox %s", outboxIRI.String()) + } + return nil, fmt.Errorf("db error searching for actor with outbox %s", outboxIRI.String()) + } + return url.Parse(acct.URI) } // ActorForInbox fetches the actor's IRI for the given outbox IRI. // // The library makes this call only after acquiring a lock first. func (f *federatingDB) ActorForInbox(c context.Context, inboxIRI *url.URL) (actorIRI *url.URL, err error) { - return nil, nil + if !util.IsInboxPath(inboxIRI) { + return nil, fmt.Errorf("%s is not an inbox URI", inboxIRI.String()) + } + acct := >smodel.Account{} + if err := f.db.GetWhere("inbox_uri", inboxIRI.String(), acct); err != nil { + if _, ok := err.(ErrNoEntries); ok { + return nil, fmt.Errorf("no actor found that corresponds to inbox %s", inboxIRI.String()) + } + return nil, fmt.Errorf("db error searching for actor with inbox %s", inboxIRI.String()) + } + return url.Parse(acct.URI) } // OutboxForInbox fetches the corresponding actor's outbox IRI for the @@ -145,7 +234,17 @@ func (f *federatingDB) ActorForInbox(c context.Context, inboxIRI *url.URL) (acto // // The library makes this call only after acquiring a lock first. func (f *federatingDB) OutboxForInbox(c context.Context, inboxIRI *url.URL) (outboxIRI *url.URL, err error) { - return nil, nil + if !util.IsInboxPath(inboxIRI) { + return nil, fmt.Errorf("%s is not an inbox URI", inboxIRI.String()) + } + acct := >smodel.Account{} + if err := f.db.GetWhere("inbox_uri", inboxIRI.String(), acct); err != nil { + if _, ok := err.(ErrNoEntries); ok { + return nil, fmt.Errorf("no actor found that corresponds to inbox %s", inboxIRI.String()) + } + return nil, fmt.Errorf("db error searching for actor with inbox %s", inboxIRI.String()) + } + return url.Parse(acct.OutboxURI) } // Exists returns true if the database has an entry for the specified diff --git a/internal/db/mock_DB.go b/internal/db/mock_DB.go deleted file mode 100644 index df2e419..0000000 --- a/internal/db/mock_DB.go +++ /dev/null @@ -1,484 +0,0 @@ -// Code generated by mockery v2.7.4. DO NOT EDIT. - -package db - -import ( - context "context" - - mock "github.com/stretchr/testify/mock" - gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - - net "net" - - pub "github.com/go-fed/activity/pub" -) - -// MockDB is an autogenerated mock type for the DB type -type MockDB struct { - mock.Mock -} - -// Blocked provides a mock function with given fields: account1, account2 -func (_m *MockDB) Blocked(account1 string, account2 string) (bool, error) { - ret := _m.Called(account1, account2) - - var r0 bool - if rf, ok := ret.Get(0).(func(string, string) bool); ok { - r0 = rf(account1, account2) - } else { - r0 = ret.Get(0).(bool) - } - - var r1 error - if rf, ok := ret.Get(1).(func(string, string) error); ok { - r1 = rf(account1, account2) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// CreateTable provides a mock function with given fields: i -func (_m *MockDB) CreateTable(i interface{}) error { - ret := _m.Called(i) - - var r0 error - if rf, ok := ret.Get(0).(func(interface{}) error); ok { - r0 = rf(i) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// DeleteByID provides a mock function with given fields: id, i -func (_m *MockDB) DeleteByID(id string, i interface{}) error { - ret := _m.Called(id, i) - - var r0 error - if rf, ok := ret.Get(0).(func(string, interface{}) error); ok { - r0 = rf(id, i) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// DeleteWhere provides a mock function with given fields: key, value, i -func (_m *MockDB) DeleteWhere(key string, value interface{}, i interface{}) error { - ret := _m.Called(key, value, i) - - var r0 error - if rf, ok := ret.Get(0).(func(string, interface{}, interface{}) error); ok { - r0 = rf(key, value, i) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// DropTable provides a mock function with given fields: i -func (_m *MockDB) DropTable(i interface{}) error { - ret := _m.Called(i) - - var r0 error - if rf, ok := ret.Get(0).(func(interface{}) error); ok { - r0 = rf(i) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// EmojiStringsToEmojis provides a mock function with given fields: emojis, originAccountID, statusID -func (_m *MockDB) EmojiStringsToEmojis(emojis []string, originAccountID string, statusID string) ([]*gtsmodel.Emoji, error) { - ret := _m.Called(emojis, originAccountID, statusID) - - var r0 []*gtsmodel.Emoji - if rf, ok := ret.Get(0).(func([]string, string, string) []*gtsmodel.Emoji); ok { - r0 = rf(emojis, originAccountID, statusID) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).([]*gtsmodel.Emoji) - } - } - - var r1 error - if rf, ok := ret.Get(1).(func([]string, string, string) error); ok { - r1 = rf(emojis, originAccountID, statusID) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// Federation provides a mock function with given fields: -func (_m *MockDB) Federation() pub.Database { - ret := _m.Called() - - var r0 pub.Database - if rf, ok := ret.Get(0).(func() pub.Database); ok { - r0 = rf() - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(pub.Database) - } - } - - return r0 -} - -// GetAccountByUserID provides a mock function with given fields: userID, account -func (_m *MockDB) GetAccountByUserID(userID string, account *gtsmodel.Account) error { - ret := _m.Called(userID, account) - - var r0 error - if rf, ok := ret.Get(0).(func(string, *gtsmodel.Account) error); ok { - r0 = rf(userID, account) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// GetAll provides a mock function with given fields: i -func (_m *MockDB) GetAll(i interface{}) error { - ret := _m.Called(i) - - var r0 error - if rf, ok := ret.Get(0).(func(interface{}) error); ok { - r0 = rf(i) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// GetAvatarForAccountID provides a mock function with given fields: avatar, accountID -func (_m *MockDB) GetAvatarForAccountID(avatar *gtsmodel.MediaAttachment, accountID string) error { - ret := _m.Called(avatar, accountID) - - var r0 error - if rf, ok := ret.Get(0).(func(*gtsmodel.MediaAttachment, string) error); ok { - r0 = rf(avatar, accountID) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// GetByID provides a mock function with given fields: id, i -func (_m *MockDB) GetByID(id string, i interface{}) error { - ret := _m.Called(id, i) - - var r0 error - if rf, ok := ret.Get(0).(func(string, interface{}) error); ok { - r0 = rf(id, i) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// GetFollowRequestsForAccountID provides a mock function with given fields: accountID, followRequests -func (_m *MockDB) GetFollowRequestsForAccountID(accountID string, followRequests *[]gtsmodel.FollowRequest) error { - ret := _m.Called(accountID, followRequests) - - var r0 error - if rf, ok := ret.Get(0).(func(string, *[]gtsmodel.FollowRequest) error); ok { - r0 = rf(accountID, followRequests) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// GetFollowersByAccountID provides a mock function with given fields: accountID, followers -func (_m *MockDB) GetFollowersByAccountID(accountID string, followers *[]gtsmodel.Follow) error { - ret := _m.Called(accountID, followers) - - var r0 error - if rf, ok := ret.Get(0).(func(string, *[]gtsmodel.Follow) error); ok { - r0 = rf(accountID, followers) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// GetFollowingByAccountID provides a mock function with given fields: accountID, following -func (_m *MockDB) GetFollowingByAccountID(accountID string, following *[]gtsmodel.Follow) error { - ret := _m.Called(accountID, following) - - var r0 error - if rf, ok := ret.Get(0).(func(string, *[]gtsmodel.Follow) error); ok { - r0 = rf(accountID, following) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// GetHeaderForAccountID provides a mock function with given fields: header, accountID -func (_m *MockDB) GetHeaderForAccountID(header *gtsmodel.MediaAttachment, accountID string) error { - ret := _m.Called(header, accountID) - - var r0 error - if rf, ok := ret.Get(0).(func(*gtsmodel.MediaAttachment, string) error); ok { - r0 = rf(header, accountID) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// GetLastStatusForAccountID provides a mock function with given fields: accountID, status -func (_m *MockDB) GetLastStatusForAccountID(accountID string, status *gtsmodel.Status) error { - ret := _m.Called(accountID, status) - - var r0 error - if rf, ok := ret.Get(0).(func(string, *gtsmodel.Status) error); ok { - r0 = rf(accountID, status) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// GetStatusesByAccountID provides a mock function with given fields: accountID, statuses -func (_m *MockDB) GetStatusesByAccountID(accountID string, statuses *[]gtsmodel.Status) error { - ret := _m.Called(accountID, statuses) - - var r0 error - if rf, ok := ret.Get(0).(func(string, *[]gtsmodel.Status) error); ok { - r0 = rf(accountID, statuses) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// GetStatusesByTimeDescending provides a mock function with given fields: accountID, statuses, limit -func (_m *MockDB) GetStatusesByTimeDescending(accountID string, statuses *[]gtsmodel.Status, limit int) error { - ret := _m.Called(accountID, statuses, limit) - - var r0 error - if rf, ok := ret.Get(0).(func(string, *[]gtsmodel.Status, int) error); ok { - r0 = rf(accountID, statuses, limit) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// GetWhere provides a mock function with given fields: key, value, i -func (_m *MockDB) GetWhere(key string, value interface{}, i interface{}) error { - ret := _m.Called(key, value, i) - - var r0 error - if rf, ok := ret.Get(0).(func(string, interface{}, interface{}) error); ok { - r0 = rf(key, value, i) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// IsEmailAvailable provides a mock function with given fields: email -func (_m *MockDB) IsEmailAvailable(email string) error { - ret := _m.Called(email) - - var r0 error - if rf, ok := ret.Get(0).(func(string) error); ok { - r0 = rf(email) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// IsHealthy provides a mock function with given fields: ctx -func (_m *MockDB) IsHealthy(ctx context.Context) error { - ret := _m.Called(ctx) - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context) error); ok { - r0 = rf(ctx) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// IsUsernameAvailable provides a mock function with given fields: username -func (_m *MockDB) IsUsernameAvailable(username string) error { - ret := _m.Called(username) - - var r0 error - if rf, ok := ret.Get(0).(func(string) error); ok { - r0 = rf(username) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// MentionStringsToMentions provides a mock function with given fields: targetAccounts, originAccountID, statusID -func (_m *MockDB) MentionStringsToMentions(targetAccounts []string, originAccountID string, statusID string) ([]*gtsmodel.Mention, error) { - ret := _m.Called(targetAccounts, originAccountID, statusID) - - var r0 []*gtsmodel.Mention - if rf, ok := ret.Get(0).(func([]string, string, string) []*gtsmodel.Mention); ok { - r0 = rf(targetAccounts, originAccountID, statusID) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).([]*gtsmodel.Mention) - } - } - - var r1 error - if rf, ok := ret.Get(1).(func([]string, string, string) error); ok { - r1 = rf(targetAccounts, originAccountID, statusID) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// NewSignup provides a mock function with given fields: username, reason, requireApproval, email, password, signUpIP, locale, appID -func (_m *MockDB) NewSignup(username string, reason string, requireApproval bool, email string, password string, signUpIP net.IP, locale string, appID string) (*gtsmodel.User, error) { - ret := _m.Called(username, reason, requireApproval, email, password, signUpIP, locale, appID) - - var r0 *gtsmodel.User - if rf, ok := ret.Get(0).(func(string, string, bool, string, string, net.IP, string, string) *gtsmodel.User); ok { - r0 = rf(username, reason, requireApproval, email, password, signUpIP, locale, appID) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*gtsmodel.User) - } - } - - var r1 error - if rf, ok := ret.Get(1).(func(string, string, bool, string, string, net.IP, string, string) error); ok { - r1 = rf(username, reason, requireApproval, email, password, signUpIP, locale, appID) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// Put provides a mock function with given fields: i -func (_m *MockDB) Put(i interface{}) error { - ret := _m.Called(i) - - var r0 error - if rf, ok := ret.Get(0).(func(interface{}) error); ok { - r0 = rf(i) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// SetHeaderOrAvatarForAccountID provides a mock function with given fields: mediaAttachment, accountID -func (_m *MockDB) SetHeaderOrAvatarForAccountID(mediaAttachment *gtsmodel.MediaAttachment, accountID string) error { - ret := _m.Called(mediaAttachment, accountID) - - var r0 error - if rf, ok := ret.Get(0).(func(*gtsmodel.MediaAttachment, string) error); ok { - r0 = rf(mediaAttachment, accountID) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// Stop provides a mock function with given fields: ctx -func (_m *MockDB) Stop(ctx context.Context) error { - ret := _m.Called(ctx) - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context) error); ok { - r0 = rf(ctx) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// TagStringsToTags provides a mock function with given fields: tags, originAccountID, statusID -func (_m *MockDB) TagStringsToTags(tags []string, originAccountID string, statusID string) ([]*gtsmodel.Tag, error) { - ret := _m.Called(tags, originAccountID, statusID) - - var r0 []*gtsmodel.Tag - if rf, ok := ret.Get(0).(func([]string, string, string) []*gtsmodel.Tag); ok { - r0 = rf(tags, originAccountID, statusID) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).([]*gtsmodel.Tag) - } - } - - var r1 error - if rf, ok := ret.Get(1).(func([]string, string, string) error); ok { - r1 = rf(tags, originAccountID, statusID) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// UpdateByID provides a mock function with given fields: id, i -func (_m *MockDB) UpdateByID(id string, i interface{}) error { - ret := _m.Called(id, i) - - var r0 error - if rf, ok := ret.Get(0).(func(string, interface{}) error); ok { - r0 = rf(id, i) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// UpdateOneByID provides a mock function with given fields: id, key, value, i -func (_m *MockDB) UpdateOneByID(id string, key string, value interface{}, i interface{}) error { - ret := _m.Called(id, key, value, i) - - var r0 error - if rf, ok := ret.Get(0).(func(string, string, interface{}, interface{}) error); ok { - r0 = rf(id, key, value, i) - } else { - r0 = ret.Error(0) - } - - return r0 -} diff --git a/internal/db/pg.go b/internal/db/pg.go index 24a57d8..6472850 100644 --- a/internal/db/pg.go +++ b/internal/db/pg.go @@ -37,7 +37,7 @@ import ( "github.com/google/uuid" "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/util" "golang.org/x/crypto/bcrypt" ) @@ -46,14 +46,14 @@ import ( type postgresService struct { config *config.Config conn *pg.DB - log *logrus.Entry + log *logrus.Logger cancel context.CancelFunc federationDB pub.Database } -// newPostgresService returns a postgresService derived from the provided config, which implements the go-fed DB interface. +// NewPostgresService returns a postgresService derived from the provided config, which implements the go-fed DB interface. // Under the hood, it uses https://github.com/go-pg/pg to create and maintain a database connection. -func newPostgresService(ctx context.Context, c *config.Config, log *logrus.Entry) (DB, error) { +func NewPostgresService(ctx context.Context, c *config.Config, log *logrus.Logger) (DB, error) { opts, err := derivePGOptions(c) if err != nil { return nil, fmt.Errorf("could not create postgres service: %s", err) @@ -67,7 +67,7 @@ func newPostgresService(ctx context.Context, c *config.Config, log *logrus.Entry // this will break the logfmt format we normally log in, // since we can't choose where pg outputs to and it defaults to // stdout. So use this option with care! - if log.Logger.GetLevel() >= logrus.TraceLevel { + if log.GetLevel() >= logrus.TraceLevel { conn.AddQueryHook(pgdebug.DebugHook{ // Print all queries. Verbose: true, @@ -95,7 +95,7 @@ func newPostgresService(ctx context.Context, c *config.Config, log *logrus.Entry cancel: cancel, } - federatingDB := newFederatingDB(ps, c) + federatingDB := NewFederatingDB(ps, c, log) ps.federationDB = federatingDB // we can confidently return this useable postgres service now @@ -109,8 +109,8 @@ func newPostgresService(ctx context.Context, c *config.Config, log *logrus.Entry // derivePGOptions takes an application config and returns either a ready-to-use *pg.Options // with sensible defaults, or an error if it's not satisfied by the provided config. func derivePGOptions(c *config.Config) (*pg.Options, error) { - if strings.ToUpper(c.DBConfig.Type) != dbTypePostgres { - return nil, fmt.Errorf("expected db type of %s but got %s", dbTypePostgres, c.DBConfig.Type) + if strings.ToUpper(c.DBConfig.Type) != DBTypePostgres { + return nil, fmt.Errorf("expected db type of %s but got %s", DBTypePostgres, c.DBConfig.Type) } // validate port @@ -341,6 +341,16 @@ func (ps *postgresService) GetAccountByUserID(userID string, account *gtsmodel.A return nil } +func (ps *postgresService) GetLocalAccountByUsername(username string, account *gtsmodel.Account) error { + if err := ps.conn.Model(account).Where("username = ?", username).Where("? IS NULL", pg.Ident("domain")).Select(); err != nil { + if err == pg.ErrNoRows { + return ErrNoEntries{} + } + return err + } + return nil +} + func (ps *postgresService) GetFollowRequestsForAccountID(accountID string, followRequests *[]gtsmodel.FollowRequest) error { if err := ps.conn.Model(followRequests).Where("target_account_id = ?", accountID).Select(); err != nil { if err == pg.ErrNoRows { @@ -456,21 +466,23 @@ func (ps *postgresService) NewSignup(username string, reason string, requireAppr return nil, err } - uris := util.GenerateURIs(username, ps.config.Protocol, ps.config.Host) + newAccountURIs := util.GenerateURIsForAccount(username, ps.config.Protocol, ps.config.Host) a := >smodel.Account{ Username: username, DisplayName: username, Reason: reason, - URL: uris.UserURL, + URL: newAccountURIs.UserURL, PrivateKey: key, PublicKey: &key.PublicKey, + PublicKeyURI: newAccountURIs.PublicKeyURI, ActorType: gtsmodel.ActivityStreamsPerson, - URI: uris.UserURI, - InboxURL: uris.InboxURI, - OutboxURL: uris.OutboxURI, - FollowersURL: uris.FollowersURI, - FeaturedCollectionURL: uris.CollectionURI, + URI: newAccountURIs.UserURI, + InboxURI: newAccountURIs.InboxURI, + OutboxURI: newAccountURIs.OutboxURI, + FollowersURI: newAccountURIs.FollowersURI, + FollowingURI: newAccountURIs.FollowingURI, + FeaturedCollectionURI: newAccountURIs.CollectionURI, } if _, err = ps.conn.Model(a).Insert(); err != nil { return nil, err @@ -566,6 +578,7 @@ func (ps *postgresService) GetAvatarForAccountID(avatar *gtsmodel.MediaAttachmen } func (ps *postgresService) Blocked(account1 string, account2 string) (bool, error) { + // TODO: check domain blocks as well var blocked bool if err := ps.conn.Model(>smodel.Block{}). Where("account_id = ?", account1).Where("target_account_id = ?", account2). diff --git a/internal/db/pg_test.go b/internal/db/pg_test.go index f9bd21c..a547840 100644 --- a/internal/db/pg_test.go +++ b/internal/db/pg_test.go @@ -16,6 +16,6 @@ along with this program. If not, see . */ -package db +package db_test // TODO: write tests for postgres diff --git a/internal/distributor/distributor.go b/internal/distributor/distributor.go deleted file mode 100644 index 151c1b5..0000000 --- a/internal/distributor/distributor.go +++ /dev/null @@ -1,110 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . -*/ - -package distributor - -import ( - "github.com/sirupsen/logrus" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" -) - -// Distributor should be passed to api modules (see internal/apimodule/...). It is used for -// passing messages back and forth from the client API and the federating interface, via channels. -// It also contains logic for filtering which messages should end up where. -// It is designed to be used asynchronously: the client API and the federating API should just be able to -// fire messages into the distributor and not wait for a reply before proceeding with other work. This allows -// for clean distribution of messages without slowing down the client API and harming the user experience. -type Distributor interface { - // FromClientAPI returns a channel for accepting messages that come from the gts client API. - FromClientAPI() chan FromClientAPI - // ClientAPIOut returns a channel for putting in messages that need to go to the gts client API. - ToClientAPI() chan ToClientAPI - // Start starts the Distributor, reading from its channels and passing messages back and forth. - Start() error - // Stop stops the distributor cleanly, finishing handling any remaining messages before closing down. - Stop() error -} - -// distributor just implements the Distributor interface -type distributor struct { - // federator pub.FederatingActor - fromClientAPI chan FromClientAPI - toClientAPI chan ToClientAPI - stop chan interface{} - log *logrus.Logger -} - -// New returns a new Distributor that uses the given federator and logger -func New(log *logrus.Logger) Distributor { - return &distributor{ - // federator: federator, - fromClientAPI: make(chan FromClientAPI, 100), - toClientAPI: make(chan ToClientAPI, 100), - stop: make(chan interface{}), - log: log, - } -} - -// ClientAPIIn returns a channel for accepting messages that come from the gts client API. -func (d *distributor) FromClientAPI() chan FromClientAPI { - return d.fromClientAPI -} - -// ClientAPIOut returns a channel for putting in messages that need to go to the gts client API. -func (d *distributor) ToClientAPI() chan ToClientAPI { - return d.toClientAPI -} - -// Start starts the Distributor, reading from its channels and passing messages back and forth. -func (d *distributor) Start() error { - go func() { - DistLoop: - for { - select { - case clientMsg := <-d.fromClientAPI: - d.log.Infof("received message FROM client API: %+v", clientMsg) - case clientMsg := <-d.toClientAPI: - d.log.Infof("received message TO client API: %+v", clientMsg) - case <-d.stop: - break DistLoop - } - } - }() - return nil -} - -// Stop stops the distributor cleanly, finishing handling any remaining messages before closing down. -// TODO: empty message buffer properly before stopping otherwise we'll lose federating messages. -func (d *distributor) Stop() error { - close(d.stop) - return nil -} - -// FromClientAPI wraps a message that travels from the client API into the distributor -type FromClientAPI struct { - APObjectType gtsmodel.ActivityStreamsObject - APActivityType gtsmodel.ActivityStreamsActivity - Activity interface{} -} - -// ToClientAPI wraps a message that travels from the distributor into the client API -type ToClientAPI struct { - APObjectType gtsmodel.ActivityStreamsObject - APActivityType gtsmodel.ActivityStreamsActivity - Activity interface{} -} diff --git a/internal/distributor/mock_Distributor.go b/internal/distributor/mock_Distributor.go deleted file mode 100644 index 42248c3..0000000 --- a/internal/distributor/mock_Distributor.go +++ /dev/null @@ -1,70 +0,0 @@ -// Code generated by mockery v2.7.4. DO NOT EDIT. - -package distributor - -import mock "github.com/stretchr/testify/mock" - -// MockDistributor is an autogenerated mock type for the Distributor type -type MockDistributor struct { - mock.Mock -} - -// FromClientAPI provides a mock function with given fields: -func (_m *MockDistributor) FromClientAPI() chan FromClientAPI { - ret := _m.Called() - - var r0 chan FromClientAPI - if rf, ok := ret.Get(0).(func() chan FromClientAPI); ok { - r0 = rf() - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(chan FromClientAPI) - } - } - - return r0 -} - -// Start provides a mock function with given fields: -func (_m *MockDistributor) Start() error { - ret := _m.Called() - - var r0 error - if rf, ok := ret.Get(0).(func() error); ok { - r0 = rf() - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// Stop provides a mock function with given fields: -func (_m *MockDistributor) Stop() error { - ret := _m.Called() - - var r0 error - if rf, ok := ret.Get(0).(func() error); ok { - r0 = rf() - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// ToClientAPI provides a mock function with given fields: -func (_m *MockDistributor) ToClientAPI() chan ToClientAPI { - ret := _m.Called() - - var r0 chan ToClientAPI - if rf, ok := ret.Get(0).(func() chan ToClientAPI); ok { - r0 = rf() - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(chan ToClientAPI) - } - } - - return r0 -} diff --git a/testrig/distributor.go b/internal/federation/clock.go similarity index 69% rename from testrig/distributor.go rename to internal/federation/clock.go index a7206e5..f0d6f5e 100644 --- a/testrig/distributor.go +++ b/internal/federation/clock.go @@ -16,11 +16,27 @@ along with this program. If not, see . */ -package testrig +package federation -import "github.com/superseriousbusiness/gotosocial/internal/distributor" +import ( + "time" -// NewTestDistributor returns a Distributor suitable for testing purposes -func NewTestDistributor() distributor.Distributor { - return distributor.New(NewTestLog()) + "github.com/go-fed/activity/pub" +) + +/* + GOFED CLOCK INTERFACE + Determines the time. +*/ + +// Clock implements the Clock interface of go-fed +type Clock struct{} + +// Now just returns the time now +func (c *Clock) Now() time.Time { + return time.Now() +} + +func NewClock() pub.Clock { + return &Clock{} } diff --git a/internal/federation/commonbehavior.go b/internal/federation/commonbehavior.go new file mode 100644 index 0000000..9274e78 --- /dev/null +++ b/internal/federation/commonbehavior.go @@ -0,0 +1,152 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package federation + +import ( + "context" + "fmt" + "net/http" + "net/url" + + "github.com/go-fed/activity/pub" + "github.com/go-fed/activity/streams/vocab" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +/* + GOFED COMMON BEHAVIOR INTERFACE + Contains functions required for both the Social API and Federating Protocol. + It is passed to the library as a dependency injection from the client + application. +*/ + +// AuthenticateGetInbox delegates the authentication of a GET to an +// inbox. +// +// Always called, regardless whether the Federated Protocol or Social +// API is enabled. +// +// If an error is returned, it is passed back to the caller of +// GetInbox. In this case, the implementation must not write a +// response to the ResponseWriter as is expected that the client will +// do so when handling the error. The 'authenticated' is ignored. +// +// If no error is returned, but authentication or authorization fails, +// then authenticated must be false and error nil. It is expected that +// the implementation handles writing to the ResponseWriter in this +// case. +// +// Finally, if the authentication and authorization succeeds, then +// authenticated must be true and error nil. The request will continue +// to be processed. +func (f *federator) AuthenticateGetInbox(ctx context.Context, w http.ResponseWriter, r *http.Request) (context.Context, bool, error) { + // IMPLEMENTATION NOTE: For GoToSocial, we serve outboxes and inboxes through + // the CLIENT API, not through the federation API, so we just do nothing here. + return nil, false, nil +} + +// AuthenticateGetOutbox delegates the authentication of a GET to an +// outbox. +// +// Always called, regardless whether the Federated Protocol or Social +// API is enabled. +// +// If an error is returned, it is passed back to the caller of +// GetOutbox. In this case, the implementation must not write a +// response to the ResponseWriter as is expected that the client will +// do so when handling the error. The 'authenticated' is ignored. +// +// If no error is returned, but authentication or authorization fails, +// then authenticated must be false and error nil. It is expected that +// the implementation handles writing to the ResponseWriter in this +// case. +// +// Finally, if the authentication and authorization succeeds, then +// authenticated must be true and error nil. The request will continue +// to be processed. +func (f *federator) AuthenticateGetOutbox(ctx context.Context, w http.ResponseWriter, r *http.Request) (context.Context, bool, error) { + // IMPLEMENTATION NOTE: For GoToSocial, we serve outboxes and inboxes through + // the CLIENT API, not through the federation API, so we just do nothing here. + return nil, false, nil +} + +// GetOutbox returns the OrderedCollection inbox of the actor for this +// context. It is up to the implementation to provide the correct +// collection for the kind of authorization given in the request. +// +// AuthenticateGetOutbox will be called prior to this. +// +// Always called, regardless whether the Federated Protocol or Social +// API is enabled. +func (f *federator) GetOutbox(ctx context.Context, r *http.Request) (vocab.ActivityStreamsOrderedCollectionPage, error) { + // IMPLEMENTATION NOTE: For GoToSocial, we serve outboxes and inboxes through + // the CLIENT API, not through the federation API, so we just do nothing here. + return nil, nil +} + +// NewTransport returns a new Transport on behalf of a specific actor. +// +// The actorBoxIRI will be either the inbox or outbox of an actor who is +// attempting to do the dereferencing or delivery. Any authentication +// scheme applied on the request must be based on this actor. The +// request must contain some sort of credential of the user, such as a +// HTTP Signature. +// +// The gofedAgent passed in should be used by the Transport +// implementation in the User-Agent, as well as the application-specific +// user agent string. The gofedAgent will indicate this library's use as +// well as the library's version number. +// +// Any server-wide rate-limiting that needs to occur should happen in a +// Transport implementation. This factory function allows this to be +// created, so peer servers are not DOS'd. +// +// Any retry logic should also be handled by the Transport +// implementation. +// +// Note that the library will not maintain a long-lived pointer to the +// returned Transport so that any private credentials are able to be +// garbage collected. +func (f *federator) NewTransport(ctx context.Context, actorBoxIRI *url.URL, gofedAgent string) (pub.Transport, error) { + + var username string + var err error + + if util.IsInboxPath(actorBoxIRI) { + username, err = util.ParseInboxPath(actorBoxIRI) + if err != nil { + return nil, fmt.Errorf("couldn't parse path %s as an inbox: %s", actorBoxIRI.String(), err) + } + } else if util.IsOutboxPath(actorBoxIRI) { + username, err = util.ParseOutboxPath(actorBoxIRI) + if err != nil { + return nil, fmt.Errorf("couldn't parse path %s as an outbox: %s", actorBoxIRI.String(), err) + } + } else { + return nil, fmt.Errorf("id %s was neither an inbox path nor an outbox path", actorBoxIRI.String()) + } + + account := >smodel.Account{} + if err := f.db.GetLocalAccountByUsername(username, account); err != nil { + return nil, fmt.Errorf("error getting account with username %s from the db: %s", username, err) + } + + return f.transportController.NewTransport(account.PublicKeyURI, account.PrivateKey) +} diff --git a/internal/federation/federatingactor.go b/internal/federation/federatingactor.go new file mode 100644 index 0000000..f105d91 --- /dev/null +++ b/internal/federation/federatingactor.go @@ -0,0 +1,136 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package federation + +import ( + "context" + "net/http" + "net/url" + + "github.com/go-fed/activity/pub" + "github.com/go-fed/activity/streams/vocab" +) + +// federatingActor implements the go-fed federating protocol interface +type federatingActor struct { + actor pub.FederatingActor +} + +// newFederatingProtocol returns the gotosocial implementation of the GTSFederatingProtocol interface +func newFederatingActor(c pub.CommonBehavior, s2s pub.FederatingProtocol, db pub.Database, clock pub.Clock) pub.FederatingActor { + actor := pub.NewFederatingActor(c, s2s, db, clock) + + return &federatingActor{ + actor: actor, + } +} + +// Send a federated activity. +// +// The provided url must be the outbox of the sender. All processing of +// the activity occurs similarly to the C2S flow: +// - If t is not an Activity, it is wrapped in a Create activity. +// - A new ID is generated for the activity. +// - The activity is added to the specified outbox. +// - The activity is prepared and delivered to recipients. +// +// Note that this function will only behave as expected if the +// implementation has been constructed to support federation. This +// method will guaranteed work for non-custom Actors. For custom actors, +// care should be used to not call this method if only C2S is supported. +func (f *federatingActor) Send(c context.Context, outbox *url.URL, t vocab.Type) (pub.Activity, error) { + return f.actor.Send(c, outbox, t) +} + +// PostInbox returns true if the request was handled as an ActivityPub +// POST to an actor's inbox. If false, the request was not an +// ActivityPub request and may still be handled by the caller in +// another way, such as serving a web page. +// +// If the error is nil, then the ResponseWriter's headers and response +// has already been written. If a non-nil error is returned, then no +// response has been written. +// +// If the Actor was constructed with the Federated Protocol enabled, +// side effects will occur. +// +// If the Federated Protocol is not enabled, writes the +// http.StatusMethodNotAllowed status code in the response. No side +// effects occur. +func (f *federatingActor) PostInbox(c context.Context, w http.ResponseWriter, r *http.Request) (bool, error) { + return f.actor.PostInbox(c, w, r) +} + +// GetInbox returns true if the request was handled as an ActivityPub +// GET to an actor's inbox. If false, the request was not an ActivityPub +// request and may still be handled by the caller in another way, such +// as serving a web page. +// +// If the error is nil, then the ResponseWriter's headers and response +// has already been written. If a non-nil error is returned, then no +// response has been written. +// +// If the request is an ActivityPub request, the Actor will defer to the +// application to determine the correct authorization of the request and +// the resulting OrderedCollection to respond with. The Actor handles +// serializing this OrderedCollection and responding with the correct +// headers and http.StatusOK. +func (f *federatingActor) GetInbox(c context.Context, w http.ResponseWriter, r *http.Request) (bool, error) { + return f.actor.GetInbox(c, w, r) +} + +// PostOutbox returns true if the request was handled as an ActivityPub +// POST to an actor's outbox. If false, the request was not an +// ActivityPub request and may still be handled by the caller in another +// way, such as serving a web page. +// +// If the error is nil, then the ResponseWriter's headers and response +// has already been written. If a non-nil error is returned, then no +// response has been written. +// +// If the Actor was constructed with the Social Protocol enabled, side +// effects will occur. +// +// If the Social Protocol is not enabled, writes the +// http.StatusMethodNotAllowed status code in the response. No side +// effects occur. +// +// If the Social and Federated Protocol are both enabled, it will handle +// the side effects of receiving an ActivityStream Activity, and then +// federate the Activity to peers. +func (f *federatingActor) PostOutbox(c context.Context, w http.ResponseWriter, r *http.Request) (bool, error) { + return f.actor.PostOutbox(c, w, r) +} + +// GetOutbox returns true if the request was handled as an ActivityPub +// GET to an actor's outbox. If false, the request was not an +// ActivityPub request. +// +// If the error is nil, then the ResponseWriter's headers and response +// has already been written. If a non-nil error is returned, then no +// response has been written. +// +// If the request is an ActivityPub request, the Actor will defer to the +// application to determine the correct authorization of the request and +// the resulting OrderedCollection to respond with. The Actor handles +// serializing this OrderedCollection and responding with the correct +// headers and http.StatusOK. +func (f *federatingActor) GetOutbox(c context.Context, w http.ResponseWriter, r *http.Request) (bool, error) { + return f.actor.GetOutbox(c, w, r) +} diff --git a/internal/federation/federation.go b/internal/federation/federatingprotocol.go similarity index 55% rename from internal/federation/federation.go rename to internal/federation/federatingprotocol.go index a2aba3f..1764eb7 100644 --- a/internal/federation/federation.go +++ b/internal/federation/federatingprotocol.go @@ -16,34 +16,23 @@ along with this program. If not, see . */ -// Package federation provides ActivityPub/federation functionality for GoToSocial package federation import ( "context" + "errors" + "fmt" "net/http" "net/url" - "time" "github.com/go-fed/activity/pub" "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/util" ) -// New returns a go-fed compatible federating actor -func New(db db.DB, log *logrus.Logger) pub.FederatingActor { - f := &Federator{ - db: db, - } - return pub.NewFederatingActor(f, f, db.Federation(), f) -} - -// Federator implements several go-fed interfaces in one convenient location -type Federator struct { - db db.DB -} - /* GO FED FEDERATING PROTOCOL INTERFACE FederatingProtocol contains behaviors an application needs to satisfy for the @@ -70,9 +59,21 @@ type Federator struct { // PostInbox. In this case, the DelegateActor implementation must not // write a response to the ResponseWriter as is expected that the caller // to PostInbox will do so when handling the error. -func (f *Federator) PostInboxRequestBodyHook(ctx context.Context, r *http.Request, activity pub.Activity) (context.Context, error) { - // TODO - return nil, nil +func (f *federator) PostInboxRequestBodyHook(ctx context.Context, r *http.Request, activity pub.Activity) (context.Context, error) { + l := f.log.WithFields(logrus.Fields{ + "func": "PostInboxRequestBodyHook", + "useragent": r.UserAgent(), + "url": r.URL.String(), + }) + + if activity == nil { + err := errors.New("nil activity in PostInboxRequestBodyHook") + l.Debug(err) + return nil, err + } + + ctxWithActivity := context.WithValue(ctx, util.APActivity, activity) + return ctxWithActivity, nil } // AuthenticatePostInbox delegates the authentication of a POST to an @@ -91,9 +92,54 @@ func (f *Federator) PostInboxRequestBodyHook(ctx context.Context, r *http.Reques // Finally, if the authentication and authorization succeeds, then // authenticated must be true and error nil. The request will continue // to be processed. -func (f *Federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWriter, r *http.Request) (context.Context, bool, error) { - // TODO - return nil, false, nil +func (f *federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWriter, r *http.Request) (context.Context, bool, error) { + l := f.log.WithFields(logrus.Fields{ + "func": "AuthenticatePostInbox", + "useragent": r.UserAgent(), + "url": r.URL.String(), + }) + l.Trace("received request to authenticate") + + requestedAccountI := ctx.Value(util.APAccount) + if requestedAccountI == nil { + return ctx, false, errors.New("requested account not set in context") + } + + requestedAccount, ok := requestedAccountI.(*gtsmodel.Account) + if !ok || requestedAccount == nil { + return ctx, false, errors.New("requested account not parsebale from context") + } + + publicKeyOwnerURI, err := f.AuthenticateFederatedRequest(requestedAccount.Username, r) + if err != nil { + l.Debugf("request not authenticated: %s", err) + return ctx, false, fmt.Errorf("not authenticated: %s", err) + } + + requestingAccount := >smodel.Account{} + if err := f.db.GetWhere("uri", publicKeyOwnerURI.String(), requestingAccount); err != nil { + // there's been a proper error so return it + if _, ok := err.(db.ErrNoEntries); !ok { + return ctx, false, fmt.Errorf("error getting requesting account with public key id %s: %s", publicKeyOwnerURI.String(), err) + } + + // we don't know this account (yet) so let's dereference it right now + // TODO: slow-fed + person, err := f.DereferenceRemoteAccount(requestedAccount.Username, publicKeyOwnerURI) + if err != nil { + return ctx, false, fmt.Errorf("error dereferencing account with public key id %s: %s", publicKeyOwnerURI.String(), err) + } + + a, err := f.typeConverter.ASRepresentationToAccount(person) + if err != nil { + return ctx, false, fmt.Errorf("error converting person with public key id %s to account: %s", publicKeyOwnerURI.String(), err) + } + requestingAccount = a + } + + contextWithRequestingAccount := context.WithValue(ctx, util.APRequestingAccount, requestingAccount) + + return contextWithRequestingAccount, true, nil } // Blocked should determine whether to permit a set of actors given by @@ -110,7 +156,7 @@ func (f *Federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWr // Finally, if the authentication and authorization succeeds, then // blocked must be false and error nil. The request will continue // to be processed. -func (f *Federator) Blocked(ctx context.Context, actorIRIs []*url.URL) (bool, error) { +func (f *federator) Blocked(ctx context.Context, actorIRIs []*url.URL) (bool, error) { // TODO return false, nil } @@ -134,7 +180,7 @@ func (f *Federator) Blocked(ctx context.Context, actorIRIs []*url.URL) (bool, er // // Applications are not expected to handle every single ActivityStreams // type and extension. The unhandled ones are passed to DefaultCallback. -func (f *Federator) FederatingCallbacks(ctx context.Context) (pub.FederatingWrappedCallbacks, []interface{}, error) { +func (f *federator) FederatingCallbacks(ctx context.Context) (pub.FederatingWrappedCallbacks, []interface{}, error) { // TODO return pub.FederatingWrappedCallbacks{}, nil, nil } @@ -146,8 +192,12 @@ func (f *Federator) FederatingCallbacks(ctx context.Context) (pub.FederatingWrap // Applications are not expected to handle every single ActivityStreams // type and extension, so the unhandled ones are passed to // DefaultCallback. -func (f *Federator) DefaultCallback(ctx context.Context, activity pub.Activity) error { - // TODO +func (f *federator) DefaultCallback(ctx context.Context, activity pub.Activity) error { + l := f.log.WithFields(logrus.Fields{ + "func": "DefaultCallback", + "aptype": activity.GetTypeName(), + }) + l.Debugf("received unhandle-able activity type so ignoring it") return nil } @@ -155,7 +205,7 @@ func (f *Federator) DefaultCallback(ctx context.Context, activity pub.Activity) // an activity to determine if inbox forwarding needs to occur. // // Zero or negative numbers indicate infinite recursion. -func (f *Federator) MaxInboxForwardingRecursionDepth(ctx context.Context) int { +func (f *federator) MaxInboxForwardingRecursionDepth(ctx context.Context) int { // TODO return 0 } @@ -165,7 +215,7 @@ func (f *Federator) MaxInboxForwardingRecursionDepth(ctx context.Context) int { // delivery. // // Zero or negative numbers indicate infinite recursion. -func (f *Federator) MaxDeliveryRecursionDepth(ctx context.Context) int { +func (f *federator) MaxDeliveryRecursionDepth(ctx context.Context) int { // TODO return 0 } @@ -177,7 +227,7 @@ func (f *Federator) MaxDeliveryRecursionDepth(ctx context.Context) int { // // The activity is provided as a reference for more intelligent // logic to be used, but the implementation must not modify it. -func (f *Federator) FilterForwarding(ctx context.Context, potentialRecipients []*url.URL, a pub.Activity) ([]*url.URL, error) { +func (f *federator) FilterForwarding(ctx context.Context, potentialRecipients []*url.URL, a pub.Activity) ([]*url.URL, error) { // TODO return nil, nil } @@ -190,114 +240,8 @@ func (f *Federator) FilterForwarding(ctx context.Context, potentialRecipients [] // // Always called, regardless whether the Federated Protocol or Social // API is enabled. -func (f *Federator) GetInbox(ctx context.Context, r *http.Request) (vocab.ActivityStreamsOrderedCollectionPage, error) { - // TODO +func (f *federator) GetInbox(ctx context.Context, r *http.Request) (vocab.ActivityStreamsOrderedCollectionPage, error) { + // IMPLEMENTATION NOTE: For GoToSocial, we serve outboxes and inboxes through + // the CLIENT API, not through the federation API, so we just do nothing here. return nil, nil } - -/* - GOFED COMMON BEHAVIOR INTERFACE - Contains functions required for both the Social API and Federating Protocol. - It is passed to the library as a dependency injection from the client - application. -*/ - -// AuthenticateGetInbox delegates the authentication of a GET to an -// inbox. -// -// Always called, regardless whether the Federated Protocol or Social -// API is enabled. -// -// If an error is returned, it is passed back to the caller of -// GetInbox. In this case, the implementation must not write a -// response to the ResponseWriter as is expected that the client will -// do so when handling the error. The 'authenticated' is ignored. -// -// If no error is returned, but authentication or authorization fails, -// then authenticated must be false and error nil. It is expected that -// the implementation handles writing to the ResponseWriter in this -// case. -// -// Finally, if the authentication and authorization succeeds, then -// authenticated must be true and error nil. The request will continue -// to be processed. -func (f *Federator) AuthenticateGetInbox(ctx context.Context, w http.ResponseWriter, r *http.Request) (context.Context, bool, error) { - // TODO - // use context.WithValue() and context.Value() to set and get values through here - return nil, false, nil -} - -// AuthenticateGetOutbox delegates the authentication of a GET to an -// outbox. -// -// Always called, regardless whether the Federated Protocol or Social -// API is enabled. -// -// If an error is returned, it is passed back to the caller of -// GetOutbox. In this case, the implementation must not write a -// response to the ResponseWriter as is expected that the client will -// do so when handling the error. The 'authenticated' is ignored. -// -// If no error is returned, but authentication or authorization fails, -// then authenticated must be false and error nil. It is expected that -// the implementation handles writing to the ResponseWriter in this -// case. -// -// Finally, if the authentication and authorization succeeds, then -// authenticated must be true and error nil. The request will continue -// to be processed. -func (f *Federator) AuthenticateGetOutbox(ctx context.Context, w http.ResponseWriter, r *http.Request) (context.Context, bool, error) { - // TODO - return nil, false, nil -} - -// GetOutbox returns the OrderedCollection inbox of the actor for this -// context. It is up to the implementation to provide the correct -// collection for the kind of authorization given in the request. -// -// AuthenticateGetOutbox will be called prior to this. -// -// Always called, regardless whether the Federated Protocol or Social -// API is enabled. -func (f *Federator) GetOutbox(ctx context.Context, r *http.Request) (vocab.ActivityStreamsOrderedCollectionPage, error) { - // TODO - return nil, nil -} - -// NewTransport returns a new Transport on behalf of a specific actor. -// -// The actorBoxIRI will be either the inbox or outbox of an actor who is -// attempting to do the dereferencing or delivery. Any authentication -// scheme applied on the request must be based on this actor. The -// request must contain some sort of credential of the user, such as a -// HTTP Signature. -// -// The gofedAgent passed in should be used by the Transport -// implementation in the User-Agent, as well as the application-specific -// user agent string. The gofedAgent will indicate this library's use as -// well as the library's version number. -// -// Any server-wide rate-limiting that needs to occur should happen in a -// Transport implementation. This factory function allows this to be -// created, so peer servers are not DOS'd. -// -// Any retry logic should also be handled by the Transport -// implementation. -// -// Note that the library will not maintain a long-lived pointer to the -// returned Transport so that any private credentials are able to be -// garbage collected. -func (f *Federator) NewTransport(ctx context.Context, actorBoxIRI *url.URL, gofedAgent string) (pub.Transport, error) { - // TODO - return nil, nil -} - -/* - GOFED CLOCK INTERFACE - Determines the time. -*/ - -// Now returns the current time. -func (f *Federator) Now() time.Time { - return time.Now() -} diff --git a/internal/federation/federator.go b/internal/federation/federator.go new file mode 100644 index 0000000..4fe0369 --- /dev/null +++ b/internal/federation/federator.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 . +*/ + +package federation + +import ( + "net/http" + "net/url" + + "github.com/go-fed/activity/pub" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/transport" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" +) + +// Federator wraps various interfaces and functions to manage activitypub federation from gotosocial +type Federator interface { + // FederatingActor returns the underlying pub.FederatingActor, which can be used to send activities, and serve actors at inboxes/outboxes. + FederatingActor() pub.FederatingActor + // 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) + // DereferenceRemoteAccount can be used to get the representation of a remote account, based on the account ID (which is a URI). + // The given username will be used to create a transport for making outgoing requests. See the implementation for more detailed comments. + DereferenceRemoteAccount(username string, remoteAccountID *url.URL) (typeutils.Accountable, 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. + GetTransportForUser(username string) (pub.Transport, error) + pub.CommonBehavior + pub.FederatingProtocol +} + +type federator struct { + config *config.Config + db db.DB + clock pub.Clock + typeConverter typeutils.TypeConverter + transportController transport.Controller + actor pub.FederatingActor + log *logrus.Logger +} + +// NewFederator returns a new federator +func NewFederator(db db.DB, transportController transport.Controller, config *config.Config, log *logrus.Logger, typeConverter typeutils.TypeConverter) Federator { + + clock := &Clock{} + f := &federator{ + config: config, + db: db, + clock: &Clock{}, + typeConverter: typeConverter, + transportController: transportController, + log: log, + } + actor := newFederatingActor(f, f, db.Federation(), clock) + f.actor = actor + return f +} + +func (f *federator) FederatingActor() pub.FederatingActor { + return f.actor +} diff --git a/internal/federation/federator_test.go b/internal/federation/federator_test.go new file mode 100644 index 0000000..2eab095 --- /dev/null +++ b/internal/federation/federator_test.go @@ -0,0 +1,190 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package federation_test + +import ( + "bytes" + "context" + "crypto/x509" + "encoding/pem" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-fed/activity/pub" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/storage" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "github.com/superseriousbusiness/gotosocial/internal/util" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type ProtocolTestSuite struct { + suite.Suite + config *config.Config + db db.DB + log *logrus.Logger + storage storage.Storage + typeConverter typeutils.TypeConverter + accounts map[string]*gtsmodel.Account + activities map[string]testrig.ActivityWithSignature +} + +// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout +func (suite *ProtocolTestSuite) SetupSuite() { + // setup standard items + suite.config = testrig.NewTestConfig() + suite.db = testrig.NewTestDB() + suite.log = testrig.NewTestLog() + suite.storage = testrig.NewTestStorage() + suite.typeConverter = testrig.NewTestTypeConverter(suite.db) + suite.accounts = testrig.NewTestAccounts() + suite.activities = testrig.NewTestActivities(suite.accounts) +} + +func (suite *ProtocolTestSuite) SetupTest() { + testrig.StandardDBSetup(suite.db) + +} + +// TearDownTest drops tables to make sure there's no data in the db +func (suite *ProtocolTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) +} + +// make sure PostInboxRequestBodyHook properly sets the inbox username and activity on the context +func (suite *ProtocolTestSuite) TestPostInboxRequestBodyHook() { + + // the activity we're gonna use + activity := suite.activities["dm_for_zork"] + + // setup transport controller with a no-op client so we don't make external calls + tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(func(req *http.Request) (*http.Response, error) { + return nil, nil + })) + // setup module being tested + federator := federation.NewFederator(suite.db, tc, suite.config, suite.log, suite.typeConverter) + + // setup request + ctx := context.Background() + request := httptest.NewRequest(http.MethodPost, "http://localhost:8080/users/the_mighty_zork/inbox", nil) // the endpoint we're hitting + request.Header.Set("Signature", activity.SignatureHeader) + + // trigger the function being tested, and return the new context it creates + newContext, err := federator.PostInboxRequestBodyHook(ctx, request, activity.Activity) + assert.NoError(suite.T(), err) + assert.NotNil(suite.T(), newContext) + + // activity should be set on context now + activityI := newContext.Value(util.APActivity) + assert.NotNil(suite.T(), activityI) + returnedActivity, ok := activityI.(pub.Activity) + assert.True(suite.T(), ok) + assert.NotNil(suite.T(), returnedActivity) + assert.EqualValues(suite.T(), activity.Activity, returnedActivity) +} + +func (suite *ProtocolTestSuite) TestAuthenticatePostInbox() { + + // the activity we're gonna use + activity := suite.activities["dm_for_zork"] + sendingAccount := suite.accounts["remote_account_1"] + inboxAccount := suite.accounts["local_account_1"] + + encodedPublicKey, err := x509.MarshalPKIXPublicKey(sendingAccount.PublicKey) + assert.NoError(suite.T(), err) + publicKeyBytes := pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: encodedPublicKey, + }) + publicKeyString := strings.ReplaceAll(string(publicKeyBytes), "\n", "\\n") + + // for this test we need the client to return the public key of the activity creator on the 'remote' instance + responseBodyString := fmt.Sprintf(` + { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1" + ], + + "id": "%s", + "type": "Person", + "preferredUsername": "%s", + "inbox": "%s", + + "publicKey": { + "id": "%s", + "owner": "%s", + "publicKeyPem": "%s" + } + }`, sendingAccount.URI, sendingAccount.Username, sendingAccount.InboxURI, sendingAccount.PublicKeyURI, sendingAccount.URI, publicKeyString) + + // create a transport controller whose client will just return the response body string we specified above + tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(func(req *http.Request) (*http.Response, error) { + r := ioutil.NopCloser(bytes.NewReader([]byte(responseBodyString))) + return &http.Response{ + StatusCode: 200, + Body: r, + }, nil + })) + + // now setup module being tested, with the mock transport controller + federator := federation.NewFederator(suite.db, tc, suite.config, suite.log, suite.typeConverter) + + // setup request + ctx := context.Background() + // by the time AuthenticatePostInbox is called, PostInboxRequestBodyHook should have already been called, + // which should have set the account and username onto the request. We can replicate that behavior here: + ctxWithAccount := context.WithValue(ctx, util.APAccount, inboxAccount) + ctxWithActivity := context.WithValue(ctxWithAccount, util.APActivity, activity) + + request := httptest.NewRequest(http.MethodPost, "http://localhost:8080/users/the_mighty_zork/inbox", nil) // the endpoint we're hitting + // we need these headers for the request to be validated + request.Header.Set("Signature", activity.SignatureHeader) + request.Header.Set("Date", activity.DateHeader) + request.Header.Set("Digest", activity.DigestHeader) + // we can pass this recorder as a writer and read it back after + recorder := httptest.NewRecorder() + + // trigger the function being tested, and return the new context it creates + newContext, authed, err := federator.AuthenticatePostInbox(ctxWithActivity, recorder, request) + assert.NoError(suite.T(), err) + assert.True(suite.T(), authed) + + // since we know this account already it should be set on the context + requestingAccountI := newContext.Value(util.APRequestingAccount) + assert.NotNil(suite.T(), requestingAccountI) + requestingAccount, ok := requestingAccountI.(*gtsmodel.Account) + assert.True(suite.T(), ok) + assert.Equal(suite.T(), sendingAccount.Username, requestingAccount.Username) +} + +func TestProtocolTestSuite(t *testing.T) { + suite.Run(t, new(ProtocolTestSuite)) +} diff --git a/internal/federation/util.go b/internal/federation/util.go new file mode 100644 index 0000000..ab854db --- /dev/null +++ b/internal/federation/util.go @@ -0,0 +1,237 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package federation + +import ( + "context" + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "net/http" + "net/url" + + "github.com/go-fed/activity/pub" + "github.com/go-fed/activity/streams" + "github.com/go-fed/activity/streams/vocab" + "github.com/go-fed/httpsig" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" +) + +/* + publicKeyer is BORROWED DIRECTLY FROM https://github.com/go-fed/apcore/blob/master/ap/util.go + Thank you @cj@mastodon.technology ! <3 +*/ +type publicKeyer interface { + GetW3IDSecurityV1PublicKey() vocab.W3IDSecurityV1PublicKeyProperty +} + +/* + getPublicKeyFromResponse is adapted from https://github.com/go-fed/apcore/blob/master/ap/util.go + Thank you @cj@mastodon.technology ! <3 +*/ +func getPublicKeyFromResponse(c context.Context, b []byte, keyID *url.URL) (vocab.W3IDSecurityV1PublicKey, error) { + m := make(map[string]interface{}) + if err := json.Unmarshal(b, &m); err != nil { + return nil, err + } + + t, err := streams.ToType(c, m) + if err != nil { + return nil, err + } + + pker, ok := t.(publicKeyer) + if !ok { + return nil, fmt.Errorf("ActivityStreams type cannot be converted to one known to have publicKey property: %T", t) + } + + pkp := pker.GetW3IDSecurityV1PublicKey() + if pkp == nil { + return nil, errors.New("publicKey property is not provided") + } + + var pkpFound vocab.W3IDSecurityV1PublicKey + for pkpIter := pkp.Begin(); pkpIter != pkp.End(); pkpIter = pkpIter.Next() { + if !pkpIter.IsW3IDSecurityV1PublicKey() { + continue + } + pkValue := pkpIter.Get() + var pkID *url.URL + pkID, err = pub.GetId(pkValue) + if err != nil { + return nil, err + } + if pkID.String() != keyID.String() { + continue + } + pkpFound = pkValue + break + } + + if pkpFound == nil { + return nil, fmt.Errorf("cannot find publicKey with id: %s", keyID) + } + + return pkpFound, nil +} + +// AuthenticateFederatedRequest authenticates any kind of incoming federated request from a remote server. This includes things like +// GET requests for dereferencing our users or statuses etc, and POST requests for delivering new Activities. The function returns +// the URL of the owner of the public key used in the http signature. +// +// Authenticate in this case is defined as just making sure that the http request is actually signed by whoever claims +// to have signed it, by fetching the public key from the signature and checking it against the remote public key. This function +// *does not* check whether the request is authorized, only whether it's authentic. +// +// The provided username will be used to generate a transport for making remote requests/derefencing the public key ID of the request signature. +// Ideally you should pass in the username of the user *being requested*, so that the remote server can decide how to handle the request based on who's making it. +// Ie., if the request on this server is for https://example.org/users/some_username then you should pass in the username 'some_username'. +// The remote server will then know that this is the user making the dereferencing request, and they can decide to allow or deny the request depending on their settings. +// +// Note that it is also valid to pass in an empty string here, in which case the keys of the instance account will be used. +// +// 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(username string, r *http.Request) (*url.URL, error) { + verifier, err := httpsig.NewVerifier(r) + if err != nil { + return nil, fmt.Errorf("could not create http sig verifier: %s", err) + } + + // 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) + } + + transport, err := f.GetTransportForUser(username) + if err != nil { + return nil, fmt.Errorf("transport err: %s", err) + } + + // The actual http call to the remote server is made right here in the Dereference function. + b, err := transport.Dereference(context.Background(), requestingPublicKeyID) + if err != nil { + return nil, fmt.Errorf("error deferencing key %s: %s", requestingPublicKeyID.String(), err) + } + + // if the key isn't in the response, we can't authenticate the request + requestingPublicKey, err := getPublicKeyFromResponse(context.Background(), b, requestingPublicKeyID) + if err != nil { + return nil, fmt.Errorf("error getting key %s from response %s: %s", requestingPublicKeyID.String(), string(b), err) + } + + // we should be able to get the actual key embedded in the vocab.W3IDSecurityV1PublicKey + pkPemProp := requestingPublicKey.GetW3IDSecurityV1PublicKeyPem() + if pkPemProp == nil || !pkPemProp.IsXMLSchemaString() { + return nil, errors.New("publicKeyPem property is not provided or it is not embedded as a value") + } + + // and decode the PEM so that we can parse it as a golang public key + pubKeyPem := pkPemProp.Get() + block, _ := pem.Decode([]byte(pubKeyPem)) + if block == nil || block.Type != "PUBLIC KEY" { + return nil, errors.New("could not decode publicKeyPem to PUBLIC KEY pem block type") + } + + p, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("could not parse public key from block bytes: %s", err) + } + if p == nil { + return nil, errors.New("returned public key was empty") + } + + // do the actual authentication here! + algo := httpsig.RSA_SHA256 // TODO: make this more robust + if err := verifier.Verify(p, algo); err != nil { + return nil, fmt.Errorf("error verifying key %s: %s", requestingPublicKeyID.String(), err) + } + + // all good! we just need the URI of the key owner to return + pkOwnerProp := requestingPublicKey.GetW3IDSecurityV1Owner() + if pkOwnerProp == nil || !pkOwnerProp.IsIRI() { + return nil, errors.New("publicKeyOwner property is not provided or it is not embedded as a value") + } + pkOwnerURI := pkOwnerProp.GetIRI() + + return pkOwnerURI, nil +} + +func (f *federator) DereferenceRemoteAccount(username string, remoteAccountID *url.URL) (typeutils.Accountable, error) { + + transport, err := f.GetTransportForUser(username) + if err != nil { + return nil, fmt.Errorf("transport err: %s", err) + } + + b, err := transport.Dereference(context.Background(), remoteAccountID) + if err != nil { + return nil, fmt.Errorf("error deferencing %s: %s", remoteAccountID.String(), err) + } + + m := make(map[string]interface{}) + if err := json.Unmarshal(b, &m); err != nil { + return nil, fmt.Errorf("error unmarshalling bytes into json: %s", err) + } + + t, err := streams.ToType(context.Background(), m) + if err != nil { + return nil, fmt.Errorf("error resolving json into ap vocab type: %s", err) + } + + switch t.GetTypeName() { + case string(gtsmodel.ActivityStreamsPerson): + p, ok := t.(vocab.ActivityStreamsPerson) + if !ok { + return nil, errors.New("error resolving type as activitystreams person") + } + return p, nil + case string(gtsmodel.ActivityStreamsApplication): + // TODO: convert application into person + } + + return nil, fmt.Errorf("type name %s not supported", t.GetTypeName()) +} + +func (f *federator) GetTransportForUser(username string) (pub.Transport, error) { + // We need an account to use to create a transport for dereferecing the signature. + // If a username has been given, we can fetch the account with that username and use it. + // Otherwise, we can take the instance account and use those credentials to make the request. + ourAccount := >smodel.Account{} + var u string + if username == "" { + u = f.config.Host + } else { + u = username + } + if err := f.db.GetLocalAccountByUsername(u, ourAccount); err != nil { + return nil, fmt.Errorf("error getting account %s from db: %s", username, err) + } + + transport, err := f.transportController.NewTransport(ourAccount.PublicKeyURI, ourAccount.PrivateKey) + if err != nil { + return nil, fmt.Errorf("error creating transport for user %s: %s", username, err) + } + return transport, nil +} diff --git a/internal/gotosocial/actions.go b/internal/gotosocial/actions.go index 2f90858..8d3142f 100644 --- a/internal/gotosocial/actions.go +++ b/internal/gotosocial/actions.go @@ -21,36 +21,37 @@ package gotosocial import ( "context" "fmt" + "net/http" "os" "os/signal" "syscall" "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/action" - "github.com/superseriousbusiness/gotosocial/internal/apimodule" - "github.com/superseriousbusiness/gotosocial/internal/apimodule/account" - "github.com/superseriousbusiness/gotosocial/internal/apimodule/admin" - "github.com/superseriousbusiness/gotosocial/internal/apimodule/app" - "github.com/superseriousbusiness/gotosocial/internal/apimodule/auth" - "github.com/superseriousbusiness/gotosocial/internal/apimodule/fileserver" - mediaModule "github.com/superseriousbusiness/gotosocial/internal/apimodule/media" - "github.com/superseriousbusiness/gotosocial/internal/apimodule/security" - "github.com/superseriousbusiness/gotosocial/internal/apimodule/status" - "github.com/superseriousbusiness/gotosocial/internal/cache" + "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/api/client/account" + "github.com/superseriousbusiness/gotosocial/internal/api/client/admin" + "github.com/superseriousbusiness/gotosocial/internal/api/client/app" + "github.com/superseriousbusiness/gotosocial/internal/api/client/auth" + "github.com/superseriousbusiness/gotosocial/internal/api/client/fileserver" + mediaModule "github.com/superseriousbusiness/gotosocial/internal/api/client/media" + "github.com/superseriousbusiness/gotosocial/internal/api/client/status" + "github.com/superseriousbusiness/gotosocial/internal/api/security" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/distributor" "github.com/superseriousbusiness/gotosocial/internal/federation" - "github.com/superseriousbusiness/gotosocial/internal/mastotypes" "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/message" "github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/router" "github.com/superseriousbusiness/gotosocial/internal/storage" + "github.com/superseriousbusiness/gotosocial/internal/transport" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" ) // Run creates and starts a gotosocial server var Run action.GTSAction = func(ctx context.Context, c *config.Config, log *logrus.Logger) error { - dbService, err := db.New(ctx, c, log) + dbService, err := db.NewPostgresService(ctx, c, log) if err != nil { return fmt.Errorf("error creating dbservice: %s", err) } @@ -65,28 +66,30 @@ var Run action.GTSAction = func(ctx context.Context, c *config.Config, log *logr return fmt.Errorf("error creating storage backend: %s", err) } + // build converters and util + typeConverter := typeutils.NewConverter(c, dbService) + // build backend handlers mediaHandler := media.New(c, dbService, storageBackend, log) oauthServer := oauth.New(dbService, log) - distributor := distributor.New(log) - if err := distributor.Start(); err != nil { - return fmt.Errorf("error starting distributor: %s", err) + transportController := transport.NewController(c, &federation.Clock{}, http.DefaultClient, log) + federator := federation.NewFederator(dbService, transportController, c, log, typeConverter) + processor := message.NewProcessor(c, typeConverter, federator, oauthServer, mediaHandler, storageBackend, dbService, log) + if err := processor.Start(); err != nil { + return fmt.Errorf("error starting processor: %s", err) } - // build converters and util - mastoConverter := mastotypes.New(c, dbService) - // build client api modules - authModule := auth.New(oauthServer, dbService, log) - accountModule := account.New(c, dbService, oauthServer, mediaHandler, mastoConverter, log) - appsModule := app.New(oauthServer, dbService, mastoConverter, log) - mm := mediaModule.New(dbService, mediaHandler, mastoConverter, c, log) - fileServerModule := fileserver.New(c, dbService, storageBackend, log) - adminModule := admin.New(c, dbService, mediaHandler, mastoConverter, log) - statusModule := status.New(c, dbService, mediaHandler, mastoConverter, distributor, log) + authModule := auth.New(c, dbService, oauthServer, log) + accountModule := account.New(c, processor, log) + appsModule := app.New(c, processor, log) + mm := mediaModule.New(c, processor, log) + fileServerModule := fileserver.New(c, processor, log) + adminModule := admin.New(c, processor, log) + statusModule := status.New(c, processor, log) securityModule := security.New(c, log) - apiModules := []apimodule.ClientAPIModule{ + apis := []api.ClientModule{ // modules with middleware go first securityModule, authModule, @@ -100,20 +103,17 @@ var Run action.GTSAction = func(ctx context.Context, c *config.Config, log *logr statusModule, } - for _, m := range apiModules { + for _, m := range apis { if err := m.Route(router); err != nil { return fmt.Errorf("routing error: %s", err) } - if err := m.CreateTables(dbService); err != nil { - return fmt.Errorf("table creation error: %s", err) - } } if err := dbService.CreateInstanceAccount(); err != nil { return fmt.Errorf("error creating instance account: %s", err) } - gts, err := New(dbService, &cache.MockCache{}, router, federation.New(dbService, log), c) + gts, err := New(dbService, router, federator, c) if err != nil { return fmt.Errorf("error creating gotosocial service: %s", err) } diff --git a/internal/gotosocial/gotosocial.go b/internal/gotosocial/gotosocial.go index d8f46f8..f20e116 100644 --- a/internal/gotosocial/gotosocial.go +++ b/internal/gotosocial/gotosocial.go @@ -21,10 +21,9 @@ package gotosocial import ( "context" - "github.com/go-fed/activity/pub" - "github.com/superseriousbusiness/gotosocial/internal/cache" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/federation" "github.com/superseriousbusiness/gotosocial/internal/router" ) @@ -38,23 +37,21 @@ type Gotosocial interface { // New returns a new gotosocial server, initialized with the given configuration. // An error will be returned the caller if something goes wrong during initialization // eg., no db or storage connection, port for router already in use, etc. -func New(db db.DB, cache cache.Cache, apiRouter router.Router, federationAPI pub.FederatingActor, config *config.Config) (Gotosocial, error) { +func New(db db.DB, apiRouter router.Router, federator federation.Federator, config *config.Config) (Gotosocial, error) { return &gotosocial{ - db: db, - cache: cache, - apiRouter: apiRouter, - federationAPI: federationAPI, - config: config, + db: db, + apiRouter: apiRouter, + federator: federator, + config: config, }, nil } // gotosocial fulfils the gotosocial interface. type gotosocial struct { - db db.DB - cache cache.Cache - apiRouter router.Router - federationAPI pub.FederatingActor - config *config.Config + db db.DB + apiRouter router.Router + federator federation.Federator + config *config.Config } // Start starts up the gotosocial server. If something goes wrong diff --git a/internal/gotosocial/mock_Gotosocial.go b/internal/gotosocial/mock_Gotosocial.go deleted file mode 100644 index 66f776e..0000000 --- a/internal/gotosocial/mock_Gotosocial.go +++ /dev/null @@ -1,42 +0,0 @@ -// Code generated by mockery v2.7.4. DO NOT EDIT. - -package gotosocial - -import ( - context "context" - - mock "github.com/stretchr/testify/mock" -) - -// MockGotosocial is an autogenerated mock type for the Gotosocial type -type MockGotosocial struct { - mock.Mock -} - -// Start provides a mock function with given fields: _a0 -func (_m *MockGotosocial) Start(_a0 context.Context) error { - ret := _m.Called(_a0) - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context) error); ok { - r0 = rf(_a0) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// Stop provides a mock function with given fields: _a0 -func (_m *MockGotosocial) Stop(_a0 context.Context) error { - ret := _m.Called(_a0) - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context) error); ok { - r0 = rf(_a0) - } else { - r0 = ret.Error(0) - } - - return r0 -} diff --git a/internal/db/gtsmodel/README.md b/internal/gtsmodel/README.md similarity index 100% rename from internal/db/gtsmodel/README.md rename to internal/gtsmodel/README.md diff --git a/internal/db/gtsmodel/account.go b/internal/gtsmodel/account.go similarity index 90% rename from internal/db/gtsmodel/account.go rename to internal/gtsmodel/account.go index 4bf5a9d..181b061 100644 --- a/internal/db/gtsmodel/account.go +++ b/internal/gtsmodel/account.go @@ -46,8 +46,12 @@ type Account struct { // ID of the avatar as a media attachment AvatarMediaAttachmentID string + // For a non-local account, where can the header be fetched? + AvatarRemoteURL string // ID of the header as a media attachment HeaderMediaAttachmentID string + // For a non-local account, where can the header be fetched? + HeaderRemoteURL string // DisplayName for this account. Can be empty, then just the Username will be used for display purposes. DisplayName string // a key/value map of fields that this account has added to their profile @@ -93,15 +97,15 @@ type Account struct { // Last time this account was located using the webfinger API. LastWebfingeredAt time.Time `pg:"type:timestamp"` // Address of this account's activitypub inbox, for sending activity to - InboxURL string `pg:",unique"` + InboxURI string `pg:",unique"` // Address of this account's activitypub outbox - OutboxURL string `pg:",unique"` - // Don't support shared inbox right now so this is just a stub for a future implementation - SharedInboxURL string `pg:",unique"` - // URL for getting the followers list of this account - FollowersURL string `pg:",unique"` + OutboxURI string `pg:",unique"` + // URI for getting the following list of this account + FollowingURI string `pg:",unique"` + // URI for getting the followers list of this account + FollowersURI string `pg:",unique"` // URL for getting the featured collection list of this account - FeaturedCollectionURL string `pg:",unique"` + FeaturedCollectionURI string `pg:",unique"` // What type of activitypub actor is this account? ActorType ActivityStreamsActor // This account is associated with x account id @@ -115,6 +119,8 @@ type Account struct { PrivateKey *rsa.PrivateKey // Publickey for encoding activitypub requests, will be defined for both local and remote accounts PublicKey *rsa.PublicKey + // Web-reachable location of this account's public key + PublicKeyURI string /* ADMIN FIELDS diff --git a/internal/db/gtsmodel/activitystreams.go b/internal/gtsmodel/activitystreams.go similarity index 100% rename from internal/db/gtsmodel/activitystreams.go rename to internal/gtsmodel/activitystreams.go diff --git a/internal/db/gtsmodel/application.go b/internal/gtsmodel/application.go similarity index 100% rename from internal/db/gtsmodel/application.go rename to internal/gtsmodel/application.go diff --git a/internal/db/gtsmodel/block.go b/internal/gtsmodel/block.go similarity index 100% rename from internal/db/gtsmodel/block.go rename to internal/gtsmodel/block.go diff --git a/internal/db/gtsmodel/domainblock.go b/internal/gtsmodel/domainblock.go similarity index 100% rename from internal/db/gtsmodel/domainblock.go rename to internal/gtsmodel/domainblock.go diff --git a/internal/db/gtsmodel/emaildomainblock.go b/internal/gtsmodel/emaildomainblock.go similarity index 100% rename from internal/db/gtsmodel/emaildomainblock.go rename to internal/gtsmodel/emaildomainblock.go diff --git a/internal/db/gtsmodel/emoji.go b/internal/gtsmodel/emoji.go similarity index 97% rename from internal/db/gtsmodel/emoji.go rename to internal/gtsmodel/emoji.go index c11e2e6..c175a1c 100644 --- a/internal/db/gtsmodel/emoji.go +++ b/internal/gtsmodel/emoji.go @@ -58,6 +58,8 @@ type Emoji struct { // MIME content type of the emoji image // Probably "image/png" ImageContentType string `pg:",notnull"` + // MIME content type of the static version of the emoji image. + ImageStaticContentType string `pg:",notnull"` // Size of the emoji image file in bytes, for serving purposes. ImageFileSize int `pg:",notnull"` // Size of the static version of the emoji image file in bytes, for serving purposes. diff --git a/internal/db/gtsmodel/follow.go b/internal/gtsmodel/follow.go similarity index 100% rename from internal/db/gtsmodel/follow.go rename to internal/gtsmodel/follow.go diff --git a/internal/db/gtsmodel/followrequest.go b/internal/gtsmodel/followrequest.go similarity index 100% rename from internal/db/gtsmodel/followrequest.go rename to internal/gtsmodel/followrequest.go diff --git a/internal/db/gtsmodel/mediaattachment.go b/internal/gtsmodel/mediaattachment.go similarity index 96% rename from internal/db/gtsmodel/mediaattachment.go rename to internal/gtsmodel/mediaattachment.go index 7519562..e986028 100644 --- a/internal/db/gtsmodel/mediaattachment.go +++ b/internal/gtsmodel/mediaattachment.go @@ -108,15 +108,15 @@ type FileType string const ( // FileTypeImage is for jpegs and pngs - FileTypeImage FileType = "image" + FileTypeImage FileType = "Image" // FileTypeGif is for native gifs and soundless videos that have been converted to gifs - FileTypeGif FileType = "gif" + FileTypeGif FileType = "Gif" // FileTypeAudio is for audio-only files (no video) - FileTypeAudio FileType = "audio" + FileTypeAudio FileType = "Audio" // FileTypeVideo is for files with audio + visual - FileTypeVideo FileType = "video" + FileTypeVideo FileType = "Video" // FileTypeUnknown is for unknown file types (surprise surprise!) - FileTypeUnknown FileType = "unknown" + FileTypeUnknown FileType = "Unknown" ) // FileMeta describes metadata about the actual contents of the file. diff --git a/internal/db/gtsmodel/mention.go b/internal/gtsmodel/mention.go similarity index 100% rename from internal/db/gtsmodel/mention.go rename to internal/gtsmodel/mention.go diff --git a/internal/db/gtsmodel/poll.go b/internal/gtsmodel/poll.go similarity index 100% rename from internal/db/gtsmodel/poll.go rename to internal/gtsmodel/poll.go diff --git a/internal/db/gtsmodel/status.go b/internal/gtsmodel/status.go similarity index 100% rename from internal/db/gtsmodel/status.go rename to internal/gtsmodel/status.go diff --git a/internal/db/gtsmodel/statusbookmark.go b/internal/gtsmodel/statusbookmark.go similarity index 100% rename from internal/db/gtsmodel/statusbookmark.go rename to internal/gtsmodel/statusbookmark.go diff --git a/internal/db/gtsmodel/statusfave.go b/internal/gtsmodel/statusfave.go similarity index 100% rename from internal/db/gtsmodel/statusfave.go rename to internal/gtsmodel/statusfave.go diff --git a/internal/db/gtsmodel/statusmute.go b/internal/gtsmodel/statusmute.go similarity index 100% rename from internal/db/gtsmodel/statusmute.go rename to internal/gtsmodel/statusmute.go diff --git a/internal/db/gtsmodel/statuspin.go b/internal/gtsmodel/statuspin.go similarity index 100% rename from internal/db/gtsmodel/statuspin.go rename to internal/gtsmodel/statuspin.go diff --git a/internal/db/gtsmodel/tag.go b/internal/gtsmodel/tag.go similarity index 100% rename from internal/db/gtsmodel/tag.go rename to internal/gtsmodel/tag.go diff --git a/internal/db/gtsmodel/user.go b/internal/gtsmodel/user.go similarity index 100% rename from internal/db/gtsmodel/user.go rename to internal/gtsmodel/user.go diff --git a/internal/mastotypes/mastomodel/README.md b/internal/mastotypes/mastomodel/README.md deleted file mode 100644 index 38f9e89..0000000 --- a/internal/mastotypes/mastomodel/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Mastotypes - -This package contains Go types/structs for Mastodon's REST API. - -See [here](https://docs.joinmastodon.org/methods/apps/). diff --git a/internal/mastotypes/mock_Converter.go b/internal/mastotypes/mock_Converter.go deleted file mode 100644 index 732d933..0000000 --- a/internal/mastotypes/mock_Converter.go +++ /dev/null @@ -1,148 +0,0 @@ -// Code generated by mockery v2.7.4. DO NOT EDIT. - -package mastotypes - -import ( - mock "github.com/stretchr/testify/mock" - gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" -) - -// MockConverter is an autogenerated mock type for the Converter type -type MockConverter struct { - mock.Mock -} - -// AccountToMastoPublic provides a mock function with given fields: account -func (_m *MockConverter) AccountToMastoPublic(account *gtsmodel.Account) (*mastotypes.Account, error) { - ret := _m.Called(account) - - var r0 *mastotypes.Account - if rf, ok := ret.Get(0).(func(*gtsmodel.Account) *mastotypes.Account); ok { - r0 = rf(account) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*mastotypes.Account) - } - } - - var r1 error - if rf, ok := ret.Get(1).(func(*gtsmodel.Account) error); ok { - r1 = rf(account) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// AccountToMastoSensitive provides a mock function with given fields: account -func (_m *MockConverter) AccountToMastoSensitive(account *gtsmodel.Account) (*mastotypes.Account, error) { - ret := _m.Called(account) - - var r0 *mastotypes.Account - if rf, ok := ret.Get(0).(func(*gtsmodel.Account) *mastotypes.Account); ok { - r0 = rf(account) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*mastotypes.Account) - } - } - - var r1 error - if rf, ok := ret.Get(1).(func(*gtsmodel.Account) error); ok { - r1 = rf(account) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// AppToMastoPublic provides a mock function with given fields: application -func (_m *MockConverter) AppToMastoPublic(application *gtsmodel.Application) (*mastotypes.Application, error) { - ret := _m.Called(application) - - var r0 *mastotypes.Application - if rf, ok := ret.Get(0).(func(*gtsmodel.Application) *mastotypes.Application); ok { - r0 = rf(application) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*mastotypes.Application) - } - } - - var r1 error - if rf, ok := ret.Get(1).(func(*gtsmodel.Application) error); ok { - r1 = rf(application) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// AppToMastoSensitive provides a mock function with given fields: application -func (_m *MockConverter) AppToMastoSensitive(application *gtsmodel.Application) (*mastotypes.Application, error) { - ret := _m.Called(application) - - var r0 *mastotypes.Application - if rf, ok := ret.Get(0).(func(*gtsmodel.Application) *mastotypes.Application); ok { - r0 = rf(application) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*mastotypes.Application) - } - } - - var r1 error - if rf, ok := ret.Get(1).(func(*gtsmodel.Application) error); ok { - r1 = rf(application) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// AttachmentToMasto provides a mock function with given fields: attachment -func (_m *MockConverter) AttachmentToMasto(attachment *gtsmodel.MediaAttachment) (mastotypes.Attachment, error) { - ret := _m.Called(attachment) - - var r0 mastotypes.Attachment - if rf, ok := ret.Get(0).(func(*gtsmodel.MediaAttachment) mastotypes.Attachment); ok { - r0 = rf(attachment) - } else { - r0 = ret.Get(0).(mastotypes.Attachment) - } - - var r1 error - if rf, ok := ret.Get(1).(func(*gtsmodel.MediaAttachment) error); ok { - r1 = rf(attachment) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// MentionToMasto provides a mock function with given fields: m -func (_m *MockConverter) MentionToMasto(m *gtsmodel.Mention) (mastotypes.Mention, error) { - ret := _m.Called(m) - - var r0 mastotypes.Mention - if rf, ok := ret.Get(0).(func(*gtsmodel.Mention) mastotypes.Mention); ok { - r0 = rf(m) - } else { - r0 = ret.Get(0).(mastotypes.Mention) - } - - var r1 error - if rf, ok := ret.Get(1).(func(*gtsmodel.Mention) error); ok { - r1 = rf(m) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} diff --git a/internal/media/media.go b/internal/media/media.go index df8c01e..c6403fc 100644 --- a/internal/media/media.go +++ b/internal/media/media.go @@ -28,25 +28,32 @@ import ( "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/storage" ) +// Size describes the *size* of a piece of media +type Size string + +// Type describes the *type* of a piece of media +type Type string + const ( - // MediaSmall is the key for small/thumbnail versions of media - MediaSmall = "small" - // MediaOriginal is the key for original/fullsize versions of media and emoji - MediaOriginal = "original" - // MediaStatic is the key for static (non-animated) versions of emoji - MediaStatic = "static" - // MediaAttachment is the key for media attachments - MediaAttachment = "attachment" - // MediaHeader is the key for profile header requests - MediaHeader = "header" - // MediaAvatar is the key for profile avatar requests - MediaAvatar = "avatar" - // MediaEmoji is the key for emoji type requests - MediaEmoji = "emoji" + // Small is the key for small/thumbnail versions of media + Small Size = "small" + // Original is the key for original/fullsize versions of media and emoji + Original Size = "original" + // Static is the key for static (non-animated) versions of emoji + Static Size = "static" + + // Attachment is the key for media attachments + Attachment Type = "attachment" + // Header is the key for profile header requests + Header Type = "header" + // Avatar is the key for profile avatar requests + Avatar Type = "avatar" + // Emoji is the key for emoji type requests + Emoji Type = "emoji" // EmojiMaxBytes is the maximum permitted bytes of an emoji upload (50kb) EmojiMaxBytes = 51200 @@ -57,7 +64,7 @@ type Handler interface { // ProcessHeaderOrAvatar takes a new header image for an account, checks it out, removes exif data from it, // puts it in whatever storage backend we're using, sets the relevant fields in the database for the new image, // and then returns information to the caller about the new header. - ProcessHeaderOrAvatar(img []byte, accountID string, headerOrAvi string) (*gtsmodel.MediaAttachment, error) + ProcessHeaderOrAvatar(img []byte, accountID string, mediaType Type) (*gtsmodel.MediaAttachment, error) // ProcessLocalAttachment takes a new attachment and the requesting account, checks it out, removes exif data from it, // puts it in whatever storage backend we're using, sets the relevant fields in the database for the new media, @@ -94,10 +101,10 @@ func New(config *config.Config, database db.DB, storage storage.Storage, log *lo // ProcessHeaderOrAvatar takes a new header image for an account, checks it out, removes exif data from it, // puts it in whatever storage backend we're using, sets the relevant fields in the database for the new image, // and then returns information to the caller about the new header. -func (mh *mediaHandler) ProcessHeaderOrAvatar(attachment []byte, accountID string, headerOrAvi string) (*gtsmodel.MediaAttachment, error) { +func (mh *mediaHandler) ProcessHeaderOrAvatar(attachment []byte, accountID string, mediaType Type) (*gtsmodel.MediaAttachment, error) { l := mh.log.WithField("func", "SetHeaderForAccountID") - if headerOrAvi != MediaHeader && headerOrAvi != MediaAvatar { + if mediaType != Header && mediaType != Avatar { return nil, errors.New("header or avatar not selected") } @@ -106,7 +113,7 @@ func (mh *mediaHandler) ProcessHeaderOrAvatar(attachment []byte, accountID strin if err != nil { return nil, err } - if !supportedImageType(contentType) { + if !SupportedImageType(contentType) { return nil, fmt.Errorf("%s is not an accepted image type", contentType) } @@ -116,14 +123,14 @@ func (mh *mediaHandler) ProcessHeaderOrAvatar(attachment []byte, accountID strin l.Tracef("read %d bytes of file", len(attachment)) // process it - ma, err := mh.processHeaderOrAvi(attachment, contentType, headerOrAvi, accountID) + ma, err := mh.processHeaderOrAvi(attachment, contentType, mediaType, accountID) if err != nil { - return nil, fmt.Errorf("error processing %s: %s", headerOrAvi, err) + return nil, fmt.Errorf("error processing %s: %s", mediaType, err) } // set it in the database if err := mh.db.SetHeaderOrAvatarForAccountID(ma, accountID); err != nil { - return nil, fmt.Errorf("error putting %s in database: %s", headerOrAvi, err) + return nil, fmt.Errorf("error putting %s in database: %s", mediaType, err) } return ma, nil @@ -139,8 +146,8 @@ func (mh *mediaHandler) ProcessLocalAttachment(attachment []byte, accountID stri } mainType := strings.Split(contentType, "/")[0] switch mainType { - case "video": - if !supportedVideoType(contentType) { + case MIMEVideo: + if !SupportedVideoType(contentType) { return nil, fmt.Errorf("video type %s not supported", contentType) } if len(attachment) == 0 { @@ -150,8 +157,8 @@ func (mh *mediaHandler) ProcessLocalAttachment(attachment []byte, accountID stri return nil, fmt.Errorf("video size %d bytes exceeded max video size of %d bytes", len(attachment), mh.config.MediaConfig.MaxVideoSize) } return mh.processVideoAttachment(attachment, accountID, contentType) - case "image": - if !supportedImageType(contentType) { + case MIMEImage: + if !SupportedImageType(contentType) { return nil, fmt.Errorf("image type %s not supported", contentType) } if len(attachment) == 0 { @@ -192,13 +199,13 @@ func (mh *mediaHandler) ProcessLocalEmoji(emojiBytes []byte, shortcode string) ( return nil, fmt.Errorf("emoji size %d bytes exceeded max emoji size of %d bytes", len(emojiBytes), EmojiMaxBytes) } - // clean any exif data from image/png type but leave gifs alone + // clean any exif data from png but leave gifs alone switch contentType { - case "image/png": + case MIMEPng: if clean, err = purgeExif(emojiBytes); err != nil { return nil, fmt.Errorf("error cleaning exif data: %s", err) } - case "image/gif": + case MIMEGif: clean = emojiBytes default: return nil, errors.New("media type unrecognized") @@ -218,7 +225,7 @@ func (mh *mediaHandler) ProcessLocalEmoji(emojiBytes []byte, shortcode string) ( // (ie., fileserver/ACCOUNT_ID/etc etc) we need to fetch the INSTANCE ACCOUNT from the database. That is, the account that's created // with the same username as the instance hostname, which doesn't belong to any particular user. instanceAccount := >smodel.Account{} - if err := mh.db.GetWhere("username", mh.config.Host, instanceAccount); err != nil { + if err := mh.db.GetLocalAccountByUsername(mh.config.Host, instanceAccount); err != nil { return nil, fmt.Errorf("error fetching instance account: %s", err) } @@ -234,15 +241,15 @@ func (mh *mediaHandler) ProcessLocalEmoji(emojiBytes []byte, shortcode string) ( // webfinger uri for the emoji -- unrelated to actually serving the image // will be something like https://example.org/emoji/70a7f3d7-7e35-4098-8ce3-9b5e8203bb9c - emojiURI := fmt.Sprintf("%s://%s/%s/%s", mh.config.Protocol, mh.config.Host, MediaEmoji, newEmojiID) + emojiURI := fmt.Sprintf("%s://%s/%s/%s", mh.config.Protocol, mh.config.Host, Emoji, newEmojiID) // serve url and storage path for the original emoji -- can be png or gif - emojiURL := fmt.Sprintf("%s/%s/%s/%s/%s.%s", URLbase, instanceAccount.ID, MediaEmoji, MediaOriginal, newEmojiID, extension) - emojiPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, instanceAccount.ID, MediaEmoji, MediaOriginal, newEmojiID, extension) + emojiURL := fmt.Sprintf("%s/%s/%s/%s/%s.%s", URLbase, instanceAccount.ID, Emoji, Original, newEmojiID, extension) + emojiPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, instanceAccount.ID, Emoji, Original, newEmojiID, extension) // serve url and storage path for the static version -- will always be png - emojiStaticURL := fmt.Sprintf("%s/%s/%s/%s/%s.png", URLbase, instanceAccount.ID, MediaEmoji, MediaStatic, newEmojiID) - emojiStaticPath := fmt.Sprintf("%s/%s/%s/%s/%s.png", mh.config.StorageConfig.BasePath, instanceAccount.ID, MediaEmoji, MediaStatic, newEmojiID) + emojiStaticURL := fmt.Sprintf("%s/%s/%s/%s/%s.png", URLbase, instanceAccount.ID, Emoji, Static, newEmojiID) + emojiStaticPath := fmt.Sprintf("%s/%s/%s/%s/%s.png", mh.config.StorageConfig.BasePath, instanceAccount.ID, Emoji, Static, newEmojiID) // store the original if err := mh.storage.StoreFileAt(emojiPath, original.image); err != nil { @@ -256,25 +263,26 @@ func (mh *mediaHandler) ProcessLocalEmoji(emojiBytes []byte, shortcode string) ( // and finally return the new emoji data to the caller -- it's up to them what to do with it e := >smodel.Emoji{ - ID: newEmojiID, - Shortcode: shortcode, - Domain: "", // empty because this is a local emoji - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - ImageRemoteURL: "", // empty because this is a local emoji - ImageStaticRemoteURL: "", // empty because this is a local emoji - ImageURL: emojiURL, - ImageStaticURL: emojiStaticURL, - ImagePath: emojiPath, - ImageStaticPath: emojiStaticPath, - ImageContentType: contentType, - ImageFileSize: len(original.image), - ImageStaticFileSize: len(static.image), - ImageUpdatedAt: time.Now(), - Disabled: false, - URI: emojiURI, - VisibleInPicker: true, - CategoryID: "", // empty because this is a new emoji -- no category yet + ID: newEmojiID, + Shortcode: shortcode, + Domain: "", // empty because this is a local emoji + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + ImageRemoteURL: "", // empty because this is a local emoji + ImageStaticRemoteURL: "", // empty because this is a local emoji + ImageURL: emojiURL, + ImageStaticURL: emojiStaticURL, + ImagePath: emojiPath, + ImageStaticPath: emojiStaticPath, + ImageContentType: contentType, + ImageStaticContentType: MIMEPng, // static version will always be a png + ImageFileSize: len(original.image), + ImageStaticFileSize: len(static.image), + ImageUpdatedAt: time.Now(), + Disabled: false, + URI: emojiURI, + VisibleInPicker: true, + CategoryID: "", // empty because this is a new emoji -- no category yet } return e, nil } @@ -294,7 +302,7 @@ func (mh *mediaHandler) processImageAttachment(data []byte, accountID string, co var small *imageAndMeta switch contentType { - case "image/jpeg", "image/png": + case MIMEJpeg, MIMEPng: if clean, err = purgeExif(data); err != nil { return nil, fmt.Errorf("error cleaning exif data: %s", err) } @@ -302,7 +310,7 @@ func (mh *mediaHandler) processImageAttachment(data []byte, accountID string, co if err != nil { return nil, fmt.Errorf("error parsing image: %s", err) } - case "image/gif": + case MIMEGif: clean = data original, err = deriveGif(clean, contentType) if err != nil { @@ -326,13 +334,13 @@ func (mh *mediaHandler) processImageAttachment(data []byte, accountID string, co smallURL := fmt.Sprintf("%s/%s/attachment/small/%s.jpeg", URLbase, accountID, newMediaID) // all thumbnails/smalls are encoded as jpeg // we store the original... - originalPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, accountID, MediaAttachment, MediaOriginal, newMediaID, extension) + originalPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, accountID, Attachment, Original, newMediaID, extension) if err := mh.storage.StoreFileAt(originalPath, original.image); err != nil { return nil, fmt.Errorf("storage error: %s", err) } // and a thumbnail... - smallPath := fmt.Sprintf("%s/%s/%s/%s/%s.jpeg", mh.config.StorageConfig.BasePath, accountID, MediaAttachment, MediaSmall, newMediaID) // all thumbnails/smalls are encoded as jpeg + smallPath := fmt.Sprintf("%s/%s/%s/%s/%s.jpeg", mh.config.StorageConfig.BasePath, accountID, Attachment, Small, newMediaID) // all thumbnails/smalls are encoded as jpeg if err := mh.storage.StoreFileAt(smallPath, small.image); err != nil { return nil, fmt.Errorf("storage error: %s", err) } @@ -372,7 +380,7 @@ func (mh *mediaHandler) processImageAttachment(data []byte, accountID string, co }, Thumbnail: gtsmodel.Thumbnail{ Path: smallPath, - ContentType: "image/jpeg", // all thumbnails/smalls are encoded as jpeg + ContentType: MIMEJpeg, // all thumbnails/smalls are encoded as jpeg FileSize: len(small.image), UpdatedAt: time.Now(), URL: smallURL, @@ -386,14 +394,14 @@ func (mh *mediaHandler) processImageAttachment(data []byte, accountID string, co } -func (mh *mediaHandler) processHeaderOrAvi(imageBytes []byte, contentType string, headerOrAvi string, accountID string) (*gtsmodel.MediaAttachment, error) { +func (mh *mediaHandler) processHeaderOrAvi(imageBytes []byte, contentType string, mediaType Type, accountID string) (*gtsmodel.MediaAttachment, error) { var isHeader bool var isAvatar bool - switch headerOrAvi { - case MediaHeader: + switch mediaType { + case Header: isHeader = true - case MediaAvatar: + case Avatar: isAvatar = true default: return nil, errors.New("header or avatar not selected") @@ -403,15 +411,15 @@ func (mh *mediaHandler) processHeaderOrAvi(imageBytes []byte, contentType string var err error switch contentType { - case "image/jpeg": + case MIMEJpeg: if clean, err = purgeExif(imageBytes); err != nil { return nil, fmt.Errorf("error cleaning exif data: %s", err) } - case "image/png": + case MIMEPng: if clean, err = purgeExif(imageBytes); err != nil { return nil, fmt.Errorf("error cleaning exif data: %s", err) } - case "image/gif": + case MIMEGif: clean = imageBytes default: return nil, errors.New("media type unrecognized") @@ -432,17 +440,17 @@ func (mh *mediaHandler) processHeaderOrAvi(imageBytes []byte, contentType string newMediaID := uuid.NewString() URLbase := fmt.Sprintf("%s://%s%s", mh.config.StorageConfig.ServeProtocol, mh.config.StorageConfig.ServeHost, mh.config.StorageConfig.ServeBasePath) - originalURL := fmt.Sprintf("%s/%s/%s/original/%s.%s", URLbase, accountID, headerOrAvi, newMediaID, extension) - smallURL := fmt.Sprintf("%s/%s/%s/small/%s.%s", URLbase, accountID, headerOrAvi, newMediaID, extension) + originalURL := fmt.Sprintf("%s/%s/%s/original/%s.%s", URLbase, accountID, mediaType, newMediaID, extension) + smallURL := fmt.Sprintf("%s/%s/%s/small/%s.%s", URLbase, accountID, mediaType, newMediaID, extension) // we store the original... - originalPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, accountID, headerOrAvi, MediaOriginal, newMediaID, extension) + originalPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, accountID, mediaType, Original, newMediaID, extension) if err := mh.storage.StoreFileAt(originalPath, original.image); err != nil { return nil, fmt.Errorf("storage error: %s", err) } // and a thumbnail... - smallPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, accountID, headerOrAvi, MediaSmall, newMediaID, extension) + smallPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, accountID, mediaType, Small, newMediaID, extension) if err := mh.storage.StoreFileAt(smallPath, small.image); err != nil { return nil, fmt.Errorf("storage error: %s", err) } diff --git a/internal/media/media_test.go b/internal/media/media_test.go index 58f2e02..8045295 100644 --- a/internal/media/media_test.go +++ b/internal/media/media_test.go @@ -29,7 +29,7 @@ import ( "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/storage" ) @@ -78,7 +78,7 @@ func (suite *MediaTestSuite) SetupSuite() { } suite.config = c // use an actual database for this, because it's just easier than mocking one out - database, err := db.New(context.Background(), c, log) + database, err := db.NewPostgresService(context.Background(), c, log) if err != nil { suite.FailNow(err.Error()) } diff --git a/internal/media/mock_MediaHandler.go b/internal/media/mock_MediaHandler.go index 1f87555..10fffbb 100644 --- a/internal/media/mock_MediaHandler.go +++ b/internal/media/mock_MediaHandler.go @@ -4,7 +4,7 @@ package media import ( mock "github.com/stretchr/testify/mock" - gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" ) // MockMediaHandler is an autogenerated mock type for the MediaHandler type diff --git a/internal/media/util.go b/internal/media/util.go index 64d1ee7..f4f2819 100644 --- a/internal/media/util.go +++ b/internal/media/util.go @@ -33,6 +33,26 @@ import ( "github.com/superseriousbusiness/exifremove/pkg/exifremove" ) +const ( + // MIMEImage is the mime type for image + MIMEImage = "image" + // MIMEJpeg is the jpeg image mime type + MIMEJpeg = "image/jpeg" + // MIMEGif is the gif image mime type + MIMEGif = "image/gif" + // MIMEPng is the png image mime type + MIMEPng = "image/png" + + // MIMEVideo is the mime type for video + MIMEVideo = "video" + // MIMEMp4 is the mp4 video mime type + MIMEMp4 = "video/mp4" + // MIMEMpeg is the mpeg video mime type + MIMEMpeg = "video/mpeg" + // MIMEWebm is the webm video mime type + MIMEWebm = "video/webm" +) + // parseContentType parses the MIME content type from a file, returning it as a string in the form (eg., "image/jpeg"). // Returns an error if the content type is not something we can process. func parseContentType(content []byte) (string, error) { @@ -54,13 +74,13 @@ func parseContentType(content []byte) (string, error) { return kind.MIME.Value, nil } -// supportedImageType checks mime type of an image against a slice of accepted types, +// SupportedImageType checks mime type of an image against a slice of accepted types, // and returns True if the mime type is accepted. -func supportedImageType(mimeType string) bool { +func SupportedImageType(mimeType string) bool { acceptedImageTypes := []string{ - "image/jpeg", - "image/gif", - "image/png", + MIMEJpeg, + MIMEGif, + MIMEPng, } for _, accepted := range acceptedImageTypes { if mimeType == accepted { @@ -70,13 +90,13 @@ func supportedImageType(mimeType string) bool { return false } -// supportedVideoType checks mime type of a video against a slice of accepted types, +// SupportedVideoType checks mime type of a video against a slice of accepted types, // and returns True if the mime type is accepted. -func supportedVideoType(mimeType string) bool { +func SupportedVideoType(mimeType string) bool { acceptedVideoTypes := []string{ - "video/mp4", - "video/mpeg", - "video/webm", + MIMEMp4, + MIMEMpeg, + MIMEWebm, } for _, accepted := range acceptedVideoTypes { if mimeType == accepted { @@ -89,8 +109,8 @@ func supportedVideoType(mimeType string) bool { // supportedEmojiType checks that the content type is image/png -- the only type supported for emoji. func supportedEmojiType(mimeType string) bool { acceptedEmojiTypes := []string{ - "image/gif", - "image/png", + MIMEGif, + MIMEPng, } for _, accepted := range acceptedEmojiTypes { if mimeType == accepted { @@ -121,7 +141,7 @@ func deriveGif(b []byte, extension string) (*imageAndMeta, error) { var g *gif.GIF var err error switch extension { - case "image/gif": + case MIMEGif: g, err = gif.DecodeAll(bytes.NewReader(b)) if err != nil { return nil, err @@ -161,12 +181,12 @@ func deriveImage(b []byte, contentType string) (*imageAndMeta, error) { var err error switch contentType { - case "image/jpeg": + case MIMEJpeg: i, err = jpeg.Decode(bytes.NewReader(b)) if err != nil { return nil, err } - case "image/png": + case MIMEPng: i, err = png.Decode(bytes.NewReader(b)) if err != nil { return nil, err @@ -210,17 +230,17 @@ func deriveThumbnail(b []byte, contentType string, x uint, y uint) (*imageAndMet var err error switch contentType { - case "image/jpeg": + case MIMEJpeg: i, err = jpeg.Decode(bytes.NewReader(b)) if err != nil { return nil, err } - case "image/png": + case MIMEPng: i, err = png.Decode(bytes.NewReader(b)) if err != nil { return nil, err } - case "image/gif": + case MIMEGif: i, err = gif.Decode(bytes.NewReader(b)) if err != nil { return nil, err @@ -254,12 +274,12 @@ func deriveStaticEmoji(b []byte, contentType string) (*imageAndMeta, error) { var err error switch contentType { - case "image/png": + case MIMEPng: i, err = png.Decode(bytes.NewReader(b)) if err != nil { return nil, err } - case "image/gif": + case MIMEGif: i, err = gif.Decode(bytes.NewReader(b)) if err != nil { return nil, err @@ -285,3 +305,31 @@ type imageAndMeta struct { aspect float64 blurhash string } + +// ParseMediaType converts s to a recognized MediaType, or returns an error if unrecognized +func ParseMediaType(s string) (Type, error) { + switch Type(s) { + case Attachment: + return Attachment, nil + case Header: + return Header, nil + case Avatar: + return Avatar, nil + case Emoji: + return Emoji, nil + } + return "", fmt.Errorf("%s not a recognized MediaType", s) +} + +// ParseMediaSize converts s to a recognized MediaSize, or returns an error if unrecognized +func ParseMediaSize(s string) (Size, error) { + switch Size(s) { + case Small: + return Small, nil + case Original: + return Original, nil + case Static: + return Static, nil + } + return "", fmt.Errorf("%s not a recognized MediaSize", s) +} diff --git a/internal/media/util_test.go b/internal/media/util_test.go index be617a2..db2cca6 100644 --- a/internal/media/util_test.go +++ b/internal/media/util_test.go @@ -135,10 +135,10 @@ func (suite *MediaUtilTestSuite) TestDeriveThumbnailFromJPEG() { } func (suite *MediaUtilTestSuite) TestSupportedImageTypes() { - ok := supportedImageType("image/jpeg") + ok := SupportedImageType("image/jpeg") assert.True(suite.T(), ok) - ok = supportedImageType("image/bmp") + ok = SupportedImageType("image/bmp") assert.False(suite.T(), ok) } diff --git a/internal/message/accountprocess.go b/internal/message/accountprocess.go new file mode 100644 index 0000000..9433140 --- /dev/null +++ b/internal/message/accountprocess.go @@ -0,0 +1,168 @@ +package message + +import ( + "errors" + "fmt" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +// 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 +} + +func (p *processor) AccountGet(authed *oauth.Auth, targetAccountID string) (*apimodel.Account, error) { + targetAccount := >smodel.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) + } + + 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 +} + +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, >smodel.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, >smodel.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, >smodel.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, >smodel.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, >smodel.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, >smodel.Account{}); err != nil { + return nil, err + } + } + + if form.Source.Sensitive != nil { + if err := p.db.UpdateOneByID(authed.Account.ID, "locked", *form.Locked, >smodel.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, >smodel.Account{}); err != nil { + return nil, err + } + } + } + + // fetch the account with all updated values set + updatedAccount := >smodel.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) + } + + 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 +} diff --git a/internal/message/adminprocess.go b/internal/message/adminprocess.go new file mode 100644 index 0000000..abf7b61 --- /dev/null +++ b/internal/message/adminprocess.go @@ -0,0 +1,48 @@ +package message + +import ( + "bytes" + "errors" + "fmt" + "io" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "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) + } + + 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/message/appprocess.go b/internal/message/appprocess.go new file mode 100644 index 0000000..bf56f08 --- /dev/null +++ b/internal/message/appprocess.go @@ -0,0 +1,59 @@ +package message + +import ( + "github.com/google/uuid" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +func (p *processor) AppCreate(authed *oauth.Auth, form *apimodel.ApplicationCreateRequest) (*apimodel.Application, error) { + // set default 'read' for scopes if it's not set, this follows the default of the mastodon api https://docs.joinmastodon.org/methods/apps/ + var scopes string + if form.Scopes == "" { + scopes = "read" + } else { + scopes = form.Scopes + } + + // generate new IDs for this application and its associated client + clientID := uuid.NewString() + clientSecret := uuid.NewString() + vapidKey := uuid.NewString() + + // generate the application to put in the database + app := >smodel.Application{ + Name: form.ClientName, + Website: form.Website, + RedirectURI: form.RedirectURIs, + ClientID: clientID, + ClientSecret: clientSecret, + Scopes: scopes, + VapidKey: vapidKey, + } + + // chuck it in the db + if err := p.db.Put(app); err != nil { + return nil, err + } + + // now we need to model an oauth client from the application that the oauth library can use + oc := &oauth.Client{ + ID: clientID, + Secret: clientSecret, + Domain: form.RedirectURIs, + UserID: "", // This client isn't yet associated with a specific user, it's just an app client right now + } + + // chuck it in the db + if err := p.db.Put(oc); err != nil { + return nil, err + } + + mastoApp, err := p.tc.AppToMastoSensitive(app) + if err != nil { + return nil, err + } + + return mastoApp, nil +} diff --git a/internal/message/error.go b/internal/message/error.go new file mode 100644 index 0000000..cbd55dc --- /dev/null +++ b/internal/message/error.go @@ -0,0 +1,106 @@ +package message + +import ( + "errors" + "net/http" + "strings" +) + +// ErrorWithCode wraps an internal error with an http code, and a 'safe' version of +// the error that can be served to clients without revealing internal business logic. +// +// A typical use of this error would be to first log the Original error, then return +// the Safe error and the StatusCode to an API caller. +type ErrorWithCode interface { + // Error returns the original internal error for debugging within the GoToSocial logs. + // This should *NEVER* be returned to a client as it may contain sensitive information. + Error() string + // Safe returns the API-safe version of the error for serialization towards a client. + // There's not much point logging this internally because it won't contain much helpful information. + Safe() string + // Code returns the status code for serving to a client. + Code() int +} + +type errorWithCode struct { + original error + safe error + code int +} + +func (e errorWithCode) Error() string { + return e.original.Error() +} + +func (e errorWithCode) Safe() string { + return e.safe.Error() +} + +func (e errorWithCode) Code() int { + return e.code +} + +// NewErrorBadRequest returns an ErrorWithCode 400 with the given original error and optional help text. +func NewErrorBadRequest(original error, helpText ...string) ErrorWithCode { + safe := "bad request" + if helpText != nil { + safe = safe + ": " + strings.Join(helpText, ": ") + } + return errorWithCode{ + original: original, + safe: errors.New(safe), + code: http.StatusBadRequest, + } +} + +// NewErrorNotAuthorized returns an ErrorWithCode 401 with the given original error and optional help text. +func NewErrorNotAuthorized(original error, helpText ...string) ErrorWithCode { + safe := "not authorized" + if helpText != nil { + safe = safe + ": " + strings.Join(helpText, ": ") + } + return errorWithCode{ + original: original, + safe: errors.New(safe), + code: http.StatusUnauthorized, + } +} + +// NewErrorForbidden returns an ErrorWithCode 403 with the given original error and optional help text. +func NewErrorForbidden(original error, helpText ...string) ErrorWithCode { + safe := "forbidden" + if helpText != nil { + safe = safe + ": " + strings.Join(helpText, ": ") + } + return errorWithCode{ + original: original, + safe: errors.New(safe), + code: http.StatusForbidden, + } +} + +// NewErrorNotFound returns an ErrorWithCode 404 with the given original error and optional help text. +func NewErrorNotFound(original error, helpText ...string) ErrorWithCode { + safe := "404 not found" + if helpText != nil { + safe = safe + ": " + strings.Join(helpText, ": ") + } + return errorWithCode{ + original: original, + safe: errors.New(safe), + code: http.StatusNotFound, + } +} + +// NewErrorInternalError returns an ErrorWithCode 500 with the given original error and optional help text. +func NewErrorInternalError(original error, helpText ...string) ErrorWithCode { + safe := "internal server error" + if helpText != nil { + safe = safe + ": " + strings.Join(helpText, ": ") + } + return errorWithCode{ + original: original, + safe: errors.New(safe), + code: http.StatusInternalServerError, + } +} diff --git a/internal/message/fediprocess.go b/internal/message/fediprocess.go new file mode 100644 index 0000000..6dc6330 --- /dev/null +++ b/internal/message/fediprocess.go @@ -0,0 +1,102 @@ +package message + +import ( + "fmt" + "net/http" + + "github.com/go-fed/activity/streams" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +// authenticateAndDereferenceFediRequest authenticates the HTTP signature of an incoming federation request, using the given +// username to perform the validation. It will *also* dereference the originator of the request and return it as a gtsmodel account +// for further processing. NOTE that this function will have the side effect of putting the dereferenced account into the database, +// and passing it into the processor through a channel for further asynchronous processing. +func (p *processor) authenticateAndDereferenceFediRequest(username string, r *http.Request) (*gtsmodel.Account, error) { + + // first authenticate + requestingAccountURI, err := p.federator.AuthenticateFederatedRequest(username, r) + if err != nil { + return nil, fmt.Errorf("couldn't authenticate request for username %s: %s", username, err) + } + + // OK now we can do the dereferencing part + // we might already have an entry for this account so check that first + requestingAccount := >smodel.Account{} + + err = p.db.GetWhere("uri", requestingAccountURI.String(), requestingAccount) + if err == nil { + // we do have it yay, return it + return requestingAccount, nil + } + + if _, ok := err.(db.ErrNoEntries); !ok { + // something has actually gone wrong so bail + return nil, fmt.Errorf("database error getting account with uri %s: %s", requestingAccountURI.String(), err) + } + + // we just don't have an entry for this account yet + // what we do now should depend on our chosen federation method + // for now though, we'll just dereference it + // TODO: slow-fed + requestingPerson, err := p.federator.DereferenceRemoteAccount(username, requestingAccountURI) + if err != nil { + return nil, fmt.Errorf("couldn't dereference %s: %s", requestingAccountURI.String(), err) + } + + // convert it to our internal account representation + requestingAccount, err = p.tc.ASRepresentationToAccount(requestingPerson) + if err != nil { + return nil, fmt.Errorf("couldn't convert dereferenced uri %s to gtsmodel account: %s", requestingAccountURI.String(), err) + } + + // shove it in the database for later + if err := p.db.Put(requestingAccount); err != nil { + return nil, fmt.Errorf("database error inserting account with uri %s: %s", requestingAccountURI.String(), err) + } + + // put it in our channel to queue it for async processing + p.FromFederator() <- FromFederator{ + APObjectType: gtsmodel.ActivityStreamsProfile, + APActivityType: gtsmodel.ActivityStreamsCreate, + Activity: requestingAccount, + } + + return requestingAccount, nil +} + +func (p *processor) GetFediUser(requestedUsername string, request *http.Request) (interface{}, ErrorWithCode) { + // get the account the request is referring to + requestedAccount := >smodel.Account{} + if err := p.db.GetLocalAccountByUsername(requestedUsername, requestedAccount); err != nil { + return nil, NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err)) + } + + // authenticate the request + requestingAccount, err := p.authenticateAndDereferenceFediRequest(requestedUsername, request) + if err != nil { + return nil, NewErrorNotAuthorized(err) + } + + blocked, err := p.db.Blocked(requestedAccount.ID, requestingAccount.ID) + if err != nil { + return nil, NewErrorInternalError(err) + } + + if blocked { + return nil, NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID)) + } + + requestedPerson, err := p.tc.AccountToAS(requestedAccount) + if err != nil { + return nil, NewErrorInternalError(err) + } + + data, err := streams.Serialize(requestedPerson) + if err != nil { + return nil, NewErrorInternalError(err) + } + + return data, nil +} diff --git a/internal/message/mediaprocess.go b/internal/message/mediaprocess.go new file mode 100644 index 0000000..77b387d --- /dev/null +++ b/internal/message/mediaprocess.go @@ -0,0 +1,188 @@ +package message + +import ( + "bytes" + "errors" + "fmt" + "io" + "strconv" + "strings" + + 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/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. + 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.ProcessLocalAttachment(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 + // TODO: tidy this up into a separate function and just return an error so all the c.JSON and return calls are obviated + var focusx, focusy float32 + if form.Focus != "" { + spl := strings.Split(form.Focus, ",") + if len(spl) != 2 { + return nil, fmt.Errorf("improperly formatted focus %s", form.Focus) + } + xStr := spl[0] + yStr := spl[1] + if xStr == "" || yStr == "" { + return nil, fmt.Errorf("improperly formatted focus %s", form.Focus) + } + fx, err := strconv.ParseFloat(xStr, 32) + if err != nil { + return nil, fmt.Errorf("improperly formatted focus %s: %s", form.Focus, err) + } + if fx > 1 || fx < -1 { + return nil, fmt.Errorf("improperly formatted focus %s", form.Focus) + } + focusx = float32(fx) + fy, err := strconv.ParseFloat(yStr, 32) + if err != nil { + return nil, fmt.Errorf("improperly formatted focus %s: %s", form.Focus, err) + } + if fy > 1 || fy < -1 { + return nil, fmt.Errorf("improperly formatted focus %s", form.Focus) + } + focusy = float32(fy) + } + 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 +} + +func (p *processor) MediaGet(authed *oauth.Auth, form *apimodel.GetContentRequestForm) (*apimodel.Content, error) { + // parse the form fields + mediaSize, err := media.ParseMediaSize(form.MediaSize) + if err != nil { + return nil, NewErrorNotFound(fmt.Errorf("media size %s not valid", form.MediaSize)) + } + + mediaType, err := media.ParseMediaType(form.MediaType) + if err != nil { + return nil, 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, 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 := >smodel.Account{} + if err := p.db.GetByID(form.AccountID, acct); err != nil { + return nil, NewErrorNotFound(fmt.Errorf("account with id %s could not be selected from the db: %s", form.AccountID, err)) + } + if !acct.SuspendedAt.IsZero() { + return nil, 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, 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, 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 := >smodel.Emoji{} + if err := p.db.GetByID(wantedMediaID, e); err != nil { + return nil, NewErrorNotFound(fmt.Errorf("emoji %s could not be taken from the db: %s", wantedMediaID, err)) + } + if e.Disabled { + return nil, 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, NewErrorNotFound(fmt.Errorf("media size %s not recognized for emoji", mediaSize)) + } + case media.Attachment, media.Header, media.Avatar: + a := >smodel.MediaAttachment{} + if err := p.db.GetByID(wantedMediaID, a); err != nil { + return nil, NewErrorNotFound(fmt.Errorf("attachment %s could not be taken from the db: %s", wantedMediaID, err)) + } + if a.AccountID != form.AccountID { + return nil, 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, NewErrorNotFound(fmt.Errorf("media size %s not recognized for attachment", mediaSize)) + } + } + + bytes, err := p.storage.RetrieveFileFrom(storagePath) + if err != nil { + return nil, NewErrorNotFound(fmt.Errorf("error retrieving from storage: %s", err)) + } + + content.ContentLength = int64(len(bytes)) + content.Content = bytes + return content, nil +} diff --git a/internal/message/processor.go b/internal/message/processor.go new file mode 100644 index 0000000..d0027c9 --- /dev/null +++ b/internal/message/processor.go @@ -0,0 +1,215 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package message + +import ( + "net/http" + + "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/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/storage" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" +) + +// Processor should be passed to api modules (see internal/apimodule/...). It is used for +// passing messages back and forth from the client API and the federating interface, via channels. +// It also contains logic for filtering which messages should end up where. +// It is designed to be used asynchronously: the client API and the federating API should just be able to +// fire messages into the processor and not wait for a reply before proceeding with other work. This allows +// for clean distribution of messages without slowing down the client API and harming the user experience. +type Processor interface { + // ToClientAPI returns a channel for putting in messages that need to go to the gts client API. + ToClientAPI() chan ToClientAPI + // FromClientAPI returns a channel for putting messages in that come from the client api going to the processor + FromClientAPI() chan FromClientAPI + // ToFederator returns a channel for putting in messages that need to go to the federator (activitypub). + ToFederator() chan ToFederator + // FromFederator returns a channel for putting messages in that come from the federator (activitypub) going into the processor + FromFederator() chan FromFederator + // Start starts the Processor, reading from its channels and passing messages back and forth. + Start() error + // Stop stops the processor cleanly, finishing handling any remaining messages before closing down. + Stop() error + + /* + CLIENT API-FACING PROCESSING FUNCTIONS + These functions are intended to be called when the API client needs an immediate (ie., synchronous) reply + to an HTTP request. As such, they will only do the bare-minimum of work necessary to give a properly + formed reply. For more intensive (and time-consuming) calls, where you don't require an immediate + response, pass work to the processor using a channel instead. + */ + + // AccountCreate processes the given form for creating a new account, returning an oauth token for that account if successful. + AccountCreate(authed *oauth.Auth, form *apimodel.AccountCreateRequest) (*apimodel.Token, error) + // AccountGet processes the given request for account information. + AccountGet(authed *oauth.Auth, targetAccountID string) (*apimodel.Account, error) + // AccountUpdate processes the update of an account with the given form + AccountUpdate(authed *oauth.Auth, form *apimodel.UpdateCredentialsRequest) (*apimodel.Account, error) + + // AppCreate processes the creation of a new API application + AppCreate(authed *oauth.Auth, form *apimodel.ApplicationCreateRequest) (*apimodel.Application, error) + + // StatusCreate processes the given form to create a new status, returning the api model representation of that status if it's OK. + StatusCreate(authed *oauth.Auth, form *apimodel.AdvancedStatusCreateForm) (*apimodel.Status, error) + // StatusDelete processes the delete of a given status, returning the deleted status if the delete goes through. + StatusDelete(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error) + // StatusFave processes the faving of a given status, returning the updated status if the fave goes through. + StatusFave(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error) + // StatusFavedBy returns a slice of accounts that have liked the given status, filtered according to privacy settings. + StatusFavedBy(authed *oauth.Auth, targetStatusID string) ([]*apimodel.Account, error) + // StatusGet gets the given status, taking account of privacy settings and blocks etc. + StatusGet(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error) + // StatusUnfave processes the unfaving of a given status, returning the updated status if the fave goes through. + StatusUnfave(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error) + + // MediaCreate handles the creation of a media attachment, using the given form. + MediaCreate(authed *oauth.Auth, form *apimodel.AttachmentRequest) (*apimodel.Attachment, error) + // MediaGet handles the fetching of a media attachment, using the given request form. + MediaGet(authed *oauth.Auth, form *apimodel.GetContentRequestForm) (*apimodel.Content, error) + // 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) + + /* + FEDERATION API-FACING PROCESSING FUNCTIONS + These functions are intended to be called when the federating client needs an immediate (ie., synchronous) reply + to an HTTP request. As such, they will only do the bare-minimum of work necessary to give a properly + formed reply. For more intensive (and time-consuming) calls, where you don't require an immediate + response, pass work to the processor using a channel instead. + */ + + // 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{}, ErrorWithCode) +} + +// processor just implements the Processor interface +type processor struct { + // federator pub.FederatingActor + toClientAPI chan ToClientAPI + fromClientAPI chan FromClientAPI + toFederator chan ToFederator + fromFederator chan FromFederator + federator federation.Federator + stop chan interface{} + log *logrus.Logger + config *config.Config + tc typeutils.TypeConverter + oauthServer oauth.Server + mediaHandler media.Handler + storage storage.Storage + db db.DB +} + +// NewProcessor returns a new Processor that uses the given federator and logger +func NewProcessor(config *config.Config, tc typeutils.TypeConverter, federator federation.Federator, oauthServer oauth.Server, mediaHandler media.Handler, storage storage.Storage, db db.DB, log *logrus.Logger) Processor { + return &processor{ + toClientAPI: make(chan ToClientAPI, 100), + fromClientAPI: make(chan FromClientAPI, 100), + toFederator: make(chan ToFederator, 100), + fromFederator: make(chan FromFederator, 100), + federator: federator, + stop: make(chan interface{}), + log: log, + config: config, + tc: tc, + oauthServer: oauthServer, + mediaHandler: mediaHandler, + storage: storage, + db: db, + } +} + +func (p *processor) ToClientAPI() chan ToClientAPI { + return p.toClientAPI +} + +func (p *processor) FromClientAPI() chan FromClientAPI { + return p.fromClientAPI +} + +func (p *processor) ToFederator() chan ToFederator { + return p.toFederator +} + +func (p *processor) FromFederator() chan FromFederator { + return p.fromFederator +} + +// Start starts the Processor, reading from its channels and passing messages back and forth. +func (p *processor) Start() error { + go func() { + DistLoop: + for { + select { + case clientMsg := <-p.toClientAPI: + p.log.Infof("received message TO client API: %+v", clientMsg) + case clientMsg := <-p.fromClientAPI: + p.log.Infof("received message FROM client API: %+v", clientMsg) + case federatorMsg := <-p.toFederator: + p.log.Infof("received message TO federator: %+v", federatorMsg) + case federatorMsg := <-p.fromFederator: + p.log.Infof("received message FROM federator: %+v", federatorMsg) + case <-p.stop: + break DistLoop + } + } + }() + return nil +} + +// Stop stops the processor cleanly, finishing handling any remaining messages before closing down. +// TODO: empty message buffer properly before stopping otherwise we'll lose federating messages. +func (p *processor) Stop() error { + close(p.stop) + return nil +} + +// ToClientAPI wraps a message that travels from the processor into the client API +type ToClientAPI struct { + APObjectType gtsmodel.ActivityStreamsObject + APActivityType gtsmodel.ActivityStreamsActivity + Activity interface{} +} + +// FromClientAPI wraps a message that travels from client API into the processor +type FromClientAPI struct { + APObjectType gtsmodel.ActivityStreamsObject + APActivityType gtsmodel.ActivityStreamsActivity + Activity interface{} +} + +// ToFederator wraps a message that travels from the processor into the federator +type ToFederator struct { + APObjectType gtsmodel.ActivityStreamsObject + APActivityType gtsmodel.ActivityStreamsActivity + Activity interface{} +} + +// FromFederator wraps a message that travels from the federator into the processor +type FromFederator struct { + APObjectType gtsmodel.ActivityStreamsObject + APActivityType gtsmodel.ActivityStreamsActivity + Activity interface{} +} diff --git a/internal/message/processorutil.go b/internal/message/processorutil.go new file mode 100644 index 0000000..c928eec --- /dev/null +++ b/internal/message/processorutil.go @@ -0,0 +1,304 @@ +package message + +import ( + "bytes" + "errors" + "fmt" + "io" + "mime/multipart" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +func (p *processor) processVisibility(form *apimodel.AdvancedStatusCreateForm, accountDefaultVis gtsmodel.Visibility, status *gtsmodel.Status) error { + // by default all flags are set to true + gtsAdvancedVis := >smodel.VisibilityAdvanced{ + Federated: true, + Boostable: true, + Replyable: true, + Likeable: true, + } + + var gtsBasicVis gtsmodel.Visibility + // Advanced takes priority if it's set. + // If it's not set, take whatever masto visibility is set. + // If *that's* not set either, then just take the account default. + // If that's also not set, take the default for the whole instance. + if form.VisibilityAdvanced != nil { + gtsBasicVis = gtsmodel.Visibility(*form.VisibilityAdvanced) + } else if form.Visibility != "" { + gtsBasicVis = p.tc.MastoVisToVis(form.Visibility) + } else if accountDefaultVis != "" { + gtsBasicVis = accountDefaultVis + } else { + gtsBasicVis = gtsmodel.VisibilityDefault + } + + switch gtsBasicVis { + case gtsmodel.VisibilityPublic: + // for public, there's no need to change any of the advanced flags from true regardless of what the user filled out + break + case gtsmodel.VisibilityUnlocked: + // for unlocked the user can set any combination of flags they like so look at them all to see if they're set and then apply them + if form.Federated != nil { + gtsAdvancedVis.Federated = *form.Federated + } + + if form.Boostable != nil { + gtsAdvancedVis.Boostable = *form.Boostable + } + + if form.Replyable != nil { + gtsAdvancedVis.Replyable = *form.Replyable + } + + if form.Likeable != nil { + gtsAdvancedVis.Likeable = *form.Likeable + } + + case gtsmodel.VisibilityFollowersOnly, gtsmodel.VisibilityMutualsOnly: + // for followers or mutuals only, boostable will *always* be false, but the other fields can be set so check and apply them + gtsAdvancedVis.Boostable = false + + if form.Federated != nil { + gtsAdvancedVis.Federated = *form.Federated + } + + if form.Replyable != nil { + gtsAdvancedVis.Replyable = *form.Replyable + } + + if form.Likeable != nil { + gtsAdvancedVis.Likeable = *form.Likeable + } + + case gtsmodel.VisibilityDirect: + // direct is pretty easy: there's only one possible setting so return it + gtsAdvancedVis.Federated = true + gtsAdvancedVis.Boostable = false + gtsAdvancedVis.Federated = true + gtsAdvancedVis.Likeable = true + } + + status.Visibility = gtsBasicVis + status.VisibilityAdvanced = gtsAdvancedVis + return nil +} + +func (p *processor) processReplyToID(form *apimodel.AdvancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) error { + if form.InReplyToID == "" { + return nil + } + + // If this status is a reply to another status, we need to do a bit of work to establish whether or not this status can be posted: + // + // 1. Does the replied status exist in the database? + // 2. Is the replied status marked as replyable? + // 3. Does a block exist between either the current account or the account that posted the status it's replying to? + // + // If this is all OK, then we fetch the repliedStatus and the repliedAccount for later processing. + repliedStatus := >smodel.Status{} + repliedAccount := >smodel.Account{} + // check replied status exists + is replyable + if err := p.db.GetByID(form.InReplyToID, repliedStatus); err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + return fmt.Errorf("status with id %s not replyable because it doesn't exist", form.InReplyToID) + } + return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err) + } + + if !repliedStatus.VisibilityAdvanced.Replyable { + return fmt.Errorf("status with id %s is marked as not replyable", form.InReplyToID) + } + + // check replied account is known to us + if err := p.db.GetByID(repliedStatus.AccountID, repliedAccount); err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + return fmt.Errorf("status with id %s not replyable because account id %s is not known", form.InReplyToID, repliedStatus.AccountID) + } + return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err) + } + // check if a block exists + if blocked, err := p.db.Blocked(thisAccountID, repliedAccount.ID); err != nil { + if _, ok := err.(db.ErrNoEntries); !ok { + return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err) + } + } else if blocked { + return fmt.Errorf("status with id %s not replyable", form.InReplyToID) + } + status.InReplyToID = repliedStatus.ID + status.InReplyToAccountID = repliedAccount.ID + + return nil +} + +func (p *processor) processMediaIDs(form *apimodel.AdvancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) error { + if form.MediaIDs == nil { + return nil + } + + gtsMediaAttachments := []*gtsmodel.MediaAttachment{} + attachments := []string{} + for _, mediaID := range form.MediaIDs { + // check these attachments exist + a := >smodel.MediaAttachment{} + if err := p.db.GetByID(mediaID, a); err != nil { + return fmt.Errorf("invalid media type or media not found for media id %s", mediaID) + } + // check they belong to the requesting account id + if a.AccountID != thisAccountID { + return fmt.Errorf("media with id %s does not belong to account %s", mediaID, thisAccountID) + } + // check they're not already used in a status + if a.StatusID != "" || a.ScheduledStatusID != "" { + return fmt.Errorf("media with id %s is already attached to a status", mediaID) + } + gtsMediaAttachments = append(gtsMediaAttachments, a) + attachments = append(attachments, a.ID) + } + status.GTSMediaAttachments = gtsMediaAttachments + status.Attachments = attachments + return nil +} + +func (p *processor) processLanguage(form *apimodel.AdvancedStatusCreateForm, accountDefaultLanguage string, status *gtsmodel.Status) error { + if form.Language != "" { + status.Language = form.Language + } else { + status.Language = accountDefaultLanguage + } + if status.Language == "" { + return errors.New("no language given either in status create form or account default") + } + return nil +} + +func (p *processor) processMentions(form *apimodel.AdvancedStatusCreateForm, accountID string, status *gtsmodel.Status) error { + menchies := []string{} + gtsMenchies, err := p.db.MentionStringsToMentions(util.DeriveMentions(form.Status), accountID, status.ID) + if err != nil { + return fmt.Errorf("error generating mentions from status: %s", err) + } + for _, menchie := range gtsMenchies { + if err := p.db.Put(menchie); err != nil { + return fmt.Errorf("error putting mentions in db: %s", err) + } + menchies = append(menchies, menchie.TargetAccountID) + } + // add full populated gts menchies to the status for passing them around conveniently + status.GTSMentions = gtsMenchies + // add just the ids of the mentioned accounts to the status for putting in the db + status.Mentions = menchies + return nil +} + +func (p *processor) processTags(form *apimodel.AdvancedStatusCreateForm, accountID string, status *gtsmodel.Status) error { + tags := []string{} + gtsTags, err := p.db.TagStringsToTags(util.DeriveHashtags(form.Status), accountID, status.ID) + if err != nil { + return fmt.Errorf("error generating hashtags from status: %s", err) + } + for _, tag := range gtsTags { + if err := p.db.Upsert(tag, "name"); err != nil { + return fmt.Errorf("error putting tags in db: %s", err) + } + tags = append(tags, tag.ID) + } + // add full populated gts tags to the status for passing them around conveniently + status.GTSTags = gtsTags + // add just the ids of the used tags to the status for putting in the db + status.Tags = tags + return nil +} + +func (p *processor) processEmojis(form *apimodel.AdvancedStatusCreateForm, accountID string, status *gtsmodel.Status) error { + emojis := []string{} + gtsEmojis, err := p.db.EmojiStringsToEmojis(util.DeriveEmojis(form.Status), accountID, status.ID) + if err != nil { + return fmt.Errorf("error generating emojis from status: %s", err) + } + for _, e := range gtsEmojis { + emojis = append(emojis, e.ID) + } + // add full populated gts emojis to the status for passing them around conveniently + status.GTSEmojis = gtsEmojis + // add just the ids of the used emojis to the status for putting in the db + status.Emojis = emojis + return nil +} + +/* + 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() +} diff --git a/internal/message/statusprocess.go b/internal/message/statusprocess.go new file mode 100644 index 0000000..b7237fe --- /dev/null +++ b/internal/message/statusprocess.go @@ -0,0 +1,350 @@ +package message + +import ( + "errors" + "fmt" + "time" + + "github.com/google/uuid" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +func (p *processor) StatusCreate(auth *oauth.Auth, form *apimodel.AdvancedStatusCreateForm) (*apimodel.Status, error) { + uris := util.GenerateURIsForAccount(auth.Account.Username, p.config.Protocol, p.config.Host) + thisStatusID := uuid.NewString() + thisStatusURI := fmt.Sprintf("%s/%s", uris.StatusesURI, thisStatusID) + thisStatusURL := fmt.Sprintf("%s/%s", uris.StatusesURL, thisStatusID) + newStatus := >smodel.Status{ + ID: thisStatusID, + URI: thisStatusURI, + URL: thisStatusURL, + Content: util.HTMLFormat(form.Status), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Local: true, + AccountID: auth.Account.ID, + ContentWarning: form.SpoilerText, + ActivityStreamsType: gtsmodel.ActivityStreamsNote, + Sensitive: form.Sensitive, + Language: form.Language, + CreatedWithApplicationID: auth.Application.ID, + Text: form.Status, + } + + // check if replyToID is ok + if err := p.processReplyToID(form, auth.Account.ID, newStatus); err != nil { + return nil, err + } + + // check if mediaIDs are ok + if err := p.processMediaIDs(form, auth.Account.ID, newStatus); err != nil { + return nil, err + } + + // check if visibility settings are ok + if err := p.processVisibility(form, auth.Account.Privacy, newStatus); err != nil { + return nil, err + } + + // handle language settings + if err := p.processLanguage(form, auth.Account.Language, newStatus); err != nil { + return nil, err + } + + // handle mentions + if err := p.processMentions(form, auth.Account.ID, newStatus); err != nil { + return nil, err + } + + if err := p.processTags(form, auth.Account.ID, newStatus); err != nil { + return nil, err + } + + if err := p.processEmojis(form, auth.Account.ID, newStatus); err != nil { + return nil, err + } + + // put the new status in the database, generating an ID for it in the process + if err := p.db.Put(newStatus); err != nil { + return nil, err + } + + // change the status ID of the media attachments to the new status + for _, a := range newStatus.GTSMediaAttachments { + a.StatusID = newStatus.ID + a.UpdatedAt = time.Now() + if err := p.db.UpdateByID(a.ID, a); err != nil { + return nil, err + } + } + + // return the frontend representation of the new status to the submitter + return p.tc.StatusToMasto(newStatus, auth.Account, auth.Account, nil, newStatus.GTSReplyToAccount, nil) +} + +func (p *processor) StatusDelete(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error) { + l := p.log.WithField("func", "StatusDelete") + l.Tracef("going to search for target status %s", targetStatusID) + targetStatus := >smodel.Status{} + if err := p.db.GetByID(targetStatusID, targetStatus); err != nil { + return nil, fmt.Errorf("error fetching status %s: %s", targetStatusID, err) + } + + if targetStatus.AccountID != authed.Account.ID { + return nil, errors.New("status doesn't belong to requesting account") + } + + l.Trace("going to get relevant accounts") + relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(targetStatus) + if err != nil { + return nil, fmt.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err) + } + + var boostOfStatus *gtsmodel.Status + if targetStatus.BoostOfID != "" { + boostOfStatus = >smodel.Status{} + if err := p.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { + return nil, fmt.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err) + } + } + + mastoStatus, err := p.tc.StatusToMasto(targetStatus, authed.Account, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) + if err != nil { + return nil, fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err) + } + + if err := p.db.DeleteByID(targetStatus.ID, targetStatus); err != nil { + return nil, fmt.Errorf("error deleting status from the database: %s", err) + } + + return mastoStatus, nil +} + +func (p *processor) StatusFave(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error) { + l := p.log.WithField("func", "StatusFave") + l.Tracef("going to search for target status %s", targetStatusID) + targetStatus := >smodel.Status{} + if err := p.db.GetByID(targetStatusID, targetStatus); err != nil { + return nil, fmt.Errorf("error fetching status %s: %s", targetStatusID, err) + } + + l.Tracef("going to search for target account %s", targetStatus.AccountID) + targetAccount := >smodel.Account{} + if err := p.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { + return nil, fmt.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err) + } + + l.Trace("going to get relevant accounts") + relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(targetStatus) + if err != nil { + return nil, fmt.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err) + } + + l.Trace("going to see if status is visible") + visible, err := p.db.StatusVisible(targetStatus, targetAccount, authed.Account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that + if err != nil { + return nil, fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err) + } + + if !visible { + return nil, errors.New("status is not visible") + } + + // is the status faveable? + if !targetStatus.VisibilityAdvanced.Likeable { + return nil, errors.New("status is not faveable") + } + + // it's visible! it's faveable! so let's fave the FUCK out of it + _, err = p.db.FaveStatus(targetStatus, authed.Account.ID) + if err != nil { + return nil, fmt.Errorf("error faveing status: %s", err) + } + + var boostOfStatus *gtsmodel.Status + if targetStatus.BoostOfID != "" { + boostOfStatus = >smodel.Status{} + if err := p.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { + return nil, fmt.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err) + } + } + + mastoStatus, err := p.tc.StatusToMasto(targetStatus, targetAccount, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) + if err != nil { + return nil, fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err) + } + + return mastoStatus, nil +} + +func (p *processor) StatusFavedBy(authed *oauth.Auth, targetStatusID string) ([]*apimodel.Account, error) { + l := p.log.WithField("func", "StatusFavedBy") + + l.Tracef("going to search for target status %s", targetStatusID) + targetStatus := >smodel.Status{} + if err := p.db.GetByID(targetStatusID, targetStatus); err != nil { + return nil, fmt.Errorf("error fetching status %s: %s", targetStatusID, err) + } + + l.Tracef("going to search for target account %s", targetStatus.AccountID) + targetAccount := >smodel.Account{} + if err := p.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { + return nil, fmt.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err) + } + + l.Trace("going to get relevant accounts") + relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(targetStatus) + if err != nil { + return nil, fmt.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err) + } + + l.Trace("going to see if status is visible") + visible, err := p.db.StatusVisible(targetStatus, targetAccount, authed.Account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that + if err != nil { + return nil, fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err) + } + + if !visible { + return nil, errors.New("status is not visible") + } + + // get ALL accounts that faved a status -- doesn't take account of blocks and mutes and stuff + favingAccounts, err := p.db.WhoFavedStatus(targetStatus) + if err != nil { + return nil, fmt.Errorf("error seeing who faved status: %s", err) + } + + // filter the list so the user doesn't see accounts they blocked or which blocked them + filteredAccounts := []*gtsmodel.Account{} + for _, acc := range favingAccounts { + blocked, err := p.db.Blocked(authed.Account.ID, acc.ID) + if err != nil { + return nil, fmt.Errorf("error checking blocks: %s", err) + } + if !blocked { + filteredAccounts = append(filteredAccounts, acc) + } + } + + // TODO: filter other things here? suspended? muted? silenced? + + // now we can return the masto representation of those accounts + mastoAccounts := []*apimodel.Account{} + for _, acc := range filteredAccounts { + mastoAccount, err := p.tc.AccountToMastoPublic(acc) + if err != nil { + return nil, fmt.Errorf("error converting account to api model: %s", err) + } + mastoAccounts = append(mastoAccounts, mastoAccount) + } + + return mastoAccounts, nil +} + +func (p *processor) StatusGet(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error) { + l := p.log.WithField("func", "StatusGet") + + l.Tracef("going to search for target status %s", targetStatusID) + targetStatus := >smodel.Status{} + if err := p.db.GetByID(targetStatusID, targetStatus); err != nil { + return nil, fmt.Errorf("error fetching status %s: %s", targetStatusID, err) + } + + l.Tracef("going to search for target account %s", targetStatus.AccountID) + targetAccount := >smodel.Account{} + if err := p.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { + return nil, fmt.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err) + } + + l.Trace("going to get relevant accounts") + relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(targetStatus) + if err != nil { + return nil, fmt.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err) + } + + l.Trace("going to see if status is visible") + visible, err := p.db.StatusVisible(targetStatus, targetAccount, authed.Account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that + if err != nil { + return nil, fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err) + } + + if !visible { + return nil, errors.New("status is not visible") + } + + var boostOfStatus *gtsmodel.Status + if targetStatus.BoostOfID != "" { + boostOfStatus = >smodel.Status{} + if err := p.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { + return nil, fmt.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err) + } + } + + mastoStatus, err := p.tc.StatusToMasto(targetStatus, targetAccount, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) + if err != nil { + return nil, fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err) + } + + return mastoStatus, nil + +} + +func (p *processor) StatusUnfave(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error) { + l := p.log.WithField("func", "StatusUnfave") + l.Tracef("going to search for target status %s", targetStatusID) + targetStatus := >smodel.Status{} + if err := p.db.GetByID(targetStatusID, targetStatus); err != nil { + return nil, fmt.Errorf("error fetching status %s: %s", targetStatusID, err) + } + + l.Tracef("going to search for target account %s", targetStatus.AccountID) + targetAccount := >smodel.Account{} + if err := p.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { + return nil, fmt.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err) + } + + l.Trace("going to get relevant accounts") + relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(targetStatus) + if err != nil { + return nil, fmt.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err) + } + + l.Trace("going to see if status is visible") + visible, err := p.db.StatusVisible(targetStatus, targetAccount, authed.Account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that + if err != nil { + return nil, fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err) + } + + if !visible { + return nil, errors.New("status is not visible") + } + + // is the status faveable? + if !targetStatus.VisibilityAdvanced.Likeable { + return nil, errors.New("status is not faveable") + } + + // it's visible! it's faveable! so let's unfave the FUCK out of it + _, err = p.db.UnfaveStatus(targetStatus, authed.Account.ID) + if err != nil { + return nil, fmt.Errorf("error unfaveing status: %s", err) + } + + var boostOfStatus *gtsmodel.Status + if targetStatus.BoostOfID != "" { + boostOfStatus = >smodel.Status{} + if err := p.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { + return nil, fmt.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err) + } + } + + mastoStatus, err := p.tc.StatusToMasto(targetStatus, targetAccount, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) + if err != nil { + return nil, fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err) + } + + return mastoStatus, nil +} diff --git a/internal/oauth/clientstore.go b/internal/oauth/clientstore.go index 4e67889..5241cf4 100644 --- a/internal/oauth/clientstore.go +++ b/internal/oauth/clientstore.go @@ -30,7 +30,8 @@ type clientStore struct { db db.DB } -func newClientStore(db db.DB) oauth2.ClientStore { +// NewClientStore returns an implementation of the oauth2 ClientStore interface, using the given db as a storage backend. +func NewClientStore(db db.DB) oauth2.ClientStore { pts := &clientStore{ db: db, } diff --git a/internal/oauth/clientstore_test.go b/internal/oauth/clientstore_test.go index a702822..b77163e 100644 --- a/internal/oauth/clientstore_test.go +++ b/internal/oauth/clientstore_test.go @@ -15,7 +15,7 @@ You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ -package oauth +package oauth_test import ( "context" @@ -25,6 +25,7 @@ import ( "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/oauth2/v4/models" ) @@ -61,7 +62,7 @@ func (suite *PgClientStoreTestSuite) SetupTest() { Database: "postgres", ApplicationName: "gotosocial", } - db, err := db.New(context.Background(), c, log) + db, err := db.NewPostgresService(context.Background(), c, log) if err != nil { logrus.Panicf("error creating database connection: %s", err) } @@ -69,7 +70,7 @@ func (suite *PgClientStoreTestSuite) SetupTest() { suite.db = db models := []interface{}{ - &Client{}, + &oauth.Client{}, } for _, m := range models { @@ -82,7 +83,7 @@ func (suite *PgClientStoreTestSuite) SetupTest() { // TearDownTest drops the oauth_clients table and closes the pg connection after each test func (suite *PgClientStoreTestSuite) TearDownTest() { models := []interface{}{ - &Client{}, + &oauth.Client{}, } for _, m := range models { if err := suite.db.DropTable(m); err != nil { @@ -97,7 +98,7 @@ func (suite *PgClientStoreTestSuite) TearDownTest() { func (suite *PgClientStoreTestSuite) TestClientStoreSetAndGet() { // set a new client in the store - cs := newClientStore(suite.db) + cs := oauth.NewClientStore(suite.db) if err := cs.Set(context.Background(), suite.testClientID, models.New(suite.testClientID, suite.testClientSecret, suite.testClientDomain, suite.testClientUserID)); err != nil { suite.FailNow(err.Error()) } @@ -115,7 +116,7 @@ func (suite *PgClientStoreTestSuite) TestClientStoreSetAndGet() { func (suite *PgClientStoreTestSuite) TestClientSetAndDelete() { // set a new client in the store - cs := newClientStore(suite.db) + cs := oauth.NewClientStore(suite.db) if err := cs.Set(context.Background(), suite.testClientID, models.New(suite.testClientID, suite.testClientSecret, suite.testClientDomain, suite.testClientUserID)); err != nil { suite.FailNow(err.Error()) } diff --git a/internal/oauth/oauth_test.go b/internal/oauth/oauth_test.go index 594b9b5..1b84496 100644 --- a/internal/oauth/oauth_test.go +++ b/internal/oauth/oauth_test.go @@ -16,6 +16,6 @@ along with this program. If not, see . */ -package oauth +package oauth_test // TODO: write tests diff --git a/internal/oauth/server.go b/internal/oauth/server.go index 1ddf18b..7877d66 100644 --- a/internal/oauth/server.go +++ b/internal/oauth/server.go @@ -23,10 +23,8 @@ import ( "fmt" "net/http" - "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" "github.com/superseriousbusiness/oauth2/v4" "github.com/superseriousbusiness/oauth2/v4/errors" "github.com/superseriousbusiness/oauth2/v4/manage" @@ -66,94 +64,53 @@ type s struct { log *logrus.Logger } -// Authed wraps an authorized token, application, user, and account. -// It is used in the functions GetAuthed and MustAuth. -// Because the user might *not* be authed, any of the fields in this struct -// might be nil, so make sure to check that when you're using this struct anywhere. -type Authed struct { - Token oauth2.TokenInfo - Application *gtsmodel.Application - User *gtsmodel.User - Account *gtsmodel.Account -} +// New returns a new oauth server that implements the Server interface +func New(database db.DB, log *logrus.Logger) Server { + ts := newTokenStore(context.Background(), database, log) + cs := NewClientStore(database) -// GetAuthed is a convenience function for returning an Authed struct from a gin context. -// In essence, it tries to extract a token, application, user, and account from the context, -// and then sets them on a struct for convenience. -// -// If any are not present in the context, they will be set to nil on the returned Authed struct. -// -// If *ALL* are not present, then nil and an error will be returned. -// -// If something goes wrong during parsing, then nil and an error will be returned (consider this not authed). -func GetAuthed(c *gin.Context) (*Authed, error) { - ctx := c.Copy() - a := &Authed{} - var i interface{} - var ok bool + manager := manage.NewDefaultManager() + manager.MapTokenStorage(ts) + manager.MapClientStorage(cs) + manager.SetAuthorizeCodeTokenCfg(manage.DefaultAuthorizeCodeTokenCfg) + sc := &server.Config{ + TokenType: "Bearer", + // Must follow the spec. + AllowGetAccessRequest: false, + // Support only the non-implicit flow. + AllowedResponseTypes: []oauth2.ResponseType{oauth2.Code}, + // Allow: + // - Authorization Code (for first & third parties) + // - Client Credentials (for applications) + AllowedGrantTypes: []oauth2.GrantType{ + oauth2.AuthorizationCode, + oauth2.ClientCredentials, + }, + AllowedCodeChallengeMethods: []oauth2.CodeChallengeMethod{oauth2.CodeChallengePlain}, + } - i, ok = ctx.Get(SessionAuthorizedToken) - if ok { - parsed, ok := i.(oauth2.TokenInfo) - if !ok { - return nil, errors.New("could not parse token from session context") + srv := server.NewServer(sc, manager) + srv.SetInternalErrorHandler(func(err error) *errors.Response { + log.Errorf("internal oauth error: %s", err) + return nil + }) + + srv.SetResponseErrorHandler(func(re *errors.Response) { + log.Errorf("internal response error: %s", re.Error) + }) + + srv.SetUserAuthorizationHandler(func(w http.ResponseWriter, r *http.Request) (string, error) { + userID := r.FormValue("userid") + if userID == "" { + return "", errors.New("userid was empty") } - a.Token = parsed + return userID, nil + }) + srv.SetClientInfoHandler(server.ClientFormHandler) + return &s{ + server: srv, + log: log, } - - i, ok = ctx.Get(SessionAuthorizedApplication) - if ok { - parsed, ok := i.(*gtsmodel.Application) - if !ok { - return nil, errors.New("could not parse application from session context") - } - a.Application = parsed - } - - i, ok = ctx.Get(SessionAuthorizedUser) - if ok { - parsed, ok := i.(*gtsmodel.User) - if !ok { - return nil, errors.New("could not parse user from session context") - } - a.User = parsed - } - - i, ok = ctx.Get(SessionAuthorizedAccount) - if ok { - parsed, ok := i.(*gtsmodel.Account) - if !ok { - return nil, errors.New("could not parse account from session context") - } - a.Account = parsed - } - - if a.Token == nil && a.Application == nil && a.User == nil && a.Account == nil { - return nil, errors.New("not authorized") - } - - return a, nil -} - -// MustAuth is like GetAuthed, but will fail if one of the requirements is not met. -func MustAuth(c *gin.Context, requireToken bool, requireApp bool, requireUser bool, requireAccount bool) (*Authed, error) { - a, err := GetAuthed(c) - if err != nil { - return nil, err - } - 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 requireAccount && a.Account == nil { - return nil, errors.New("account not supplied") - } - return a, nil } // HandleTokenRequest wraps the oauth2 library's HandleTokenRequest function @@ -211,52 +168,3 @@ func (s *s) GenerateUserAccessToken(ti oauth2.TokenInfo, clientSecret string, us s.log.Tracef("obtained user-level access token: %+v", accessToken) return accessToken, nil } - -// New returns a new oauth server that implements the Server interface -func New(database db.DB, log *logrus.Logger) Server { - ts := newTokenStore(context.Background(), database, log) - cs := newClientStore(database) - - manager := manage.NewDefaultManager() - manager.MapTokenStorage(ts) - manager.MapClientStorage(cs) - manager.SetAuthorizeCodeTokenCfg(manage.DefaultAuthorizeCodeTokenCfg) - sc := &server.Config{ - TokenType: "Bearer", - // Must follow the spec. - AllowGetAccessRequest: false, - // Support only the non-implicit flow. - AllowedResponseTypes: []oauth2.ResponseType{oauth2.Code}, - // Allow: - // - Authorization Code (for first & third parties) - // - Client Credentials (for applications) - AllowedGrantTypes: []oauth2.GrantType{ - oauth2.AuthorizationCode, - oauth2.ClientCredentials, - }, - AllowedCodeChallengeMethods: []oauth2.CodeChallengeMethod{oauth2.CodeChallengePlain}, - } - - srv := server.NewServer(sc, manager) - srv.SetInternalErrorHandler(func(err error) *errors.Response { - log.Errorf("internal oauth error: %s", err) - return nil - }) - - srv.SetResponseErrorHandler(func(re *errors.Response) { - log.Errorf("internal response error: %s", re.Error) - }) - - srv.SetUserAuthorizationHandler(func(w http.ResponseWriter, r *http.Request) (string, error) { - userID := r.FormValue("userid") - if userID == "" { - return "", errors.New("userid was empty") - } - return userID, nil - }) - srv.SetClientInfoHandler(server.ClientFormHandler) - return &s{ - server: srv, - log: log, - } -} diff --git a/internal/oauth/tokenstore_test.go b/internal/oauth/tokenstore_test.go index 594b9b5..1b84496 100644 --- a/internal/oauth/tokenstore_test.go +++ b/internal/oauth/tokenstore_test.go @@ -16,6 +16,6 @@ along with this program. If not, see . */ -package oauth +package oauth_test // TODO: write tests diff --git a/internal/oauth/util.go b/internal/oauth/util.go new file mode 100644 index 0000000..378b814 --- /dev/null +++ b/internal/oauth/util.go @@ -0,0 +1,86 @@ +package oauth + +import ( + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/oauth2/v4" + "github.com/superseriousbusiness/oauth2/v4/errors" +) + +// Auth wraps an authorized token, application, user, and account. +// It is used in the functions GetAuthed and MustAuth. +// Because the user might *not* be authed, any of the fields in this struct +// might be nil, so make sure to check that when you're using this struct anywhere. +type Auth struct { + Token oauth2.TokenInfo + Application *gtsmodel.Application + User *gtsmodel.User + Account *gtsmodel.Account +} + +// Authed is a convenience function for returning an Authed struct from a gin context. +// In essence, it tries to extract a token, application, user, and account from the context, +// and then sets them on a struct for convenience. +// +// If any are not present in the context, they will be set to nil on the returned Authed struct. +// +// If *ALL* are not present, then nil and an error will be returned. +// +// If something goes wrong during parsing, then nil and an error will be returned (consider this not authed). +// Authed is like GetAuthed, but will fail if one of the requirements is not met. +func Authed(c *gin.Context, requireToken bool, requireApp bool, requireUser bool, requireAccount bool) (*Auth, error) { + ctx := c.Copy() + a := &Auth{} + var i interface{} + var ok bool + + i, ok = ctx.Get(SessionAuthorizedToken) + if ok { + parsed, ok := i.(oauth2.TokenInfo) + if !ok { + return nil, errors.New("could not parse token from session context") + } + a.Token = parsed + } + + i, ok = ctx.Get(SessionAuthorizedApplication) + if ok { + parsed, ok := i.(*gtsmodel.Application) + if !ok { + return nil, errors.New("could not parse application from session context") + } + a.Application = parsed + } + + i, ok = ctx.Get(SessionAuthorizedUser) + if ok { + parsed, ok := i.(*gtsmodel.User) + if !ok { + return nil, errors.New("could not parse user from session context") + } + a.User = parsed + } + + i, ok = ctx.Get(SessionAuthorizedAccount) + if ok { + parsed, ok := i.(*gtsmodel.Account) + if !ok { + return nil, errors.New("could not parse account from session context") + } + a.Account = parsed + } + + 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 requireAccount && a.Account == nil { + return nil, errors.New("account not supplied") + } + return a, nil +} diff --git a/internal/storage/inmem.go b/internal/storage/inmem.go index 2d88189..a596c3d 100644 --- a/internal/storage/inmem.go +++ b/internal/storage/inmem.go @@ -35,7 +35,7 @@ func (s *inMemStorage) RetrieveFileFrom(path string) ([]byte, error) { l := s.log.WithField("func", "RetrieveFileFrom") l.Debugf("retrieving from path %s", path) d, ok := s.stored[path] - if !ok { + if !ok || len(d) == 0 { return nil, fmt.Errorf("no data found at path %s", path) } return d, nil diff --git a/internal/transport/controller.go b/internal/transport/controller.go new file mode 100644 index 0000000..5251410 --- /dev/null +++ b/internal/transport/controller.go @@ -0,0 +1,71 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package transport + +import ( + "crypto" + "fmt" + + "github.com/go-fed/activity/pub" + "github.com/go-fed/httpsig" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/config" +) + +// Controller generates transports for use in making federation requests to other servers. +type Controller interface { + NewTransport(pubKeyID string, privkey crypto.PrivateKey) (pub.Transport, error) +} + +type controller struct { + config *config.Config + clock pub.Clock + client pub.HttpClient + appAgent string +} + +// NewController returns an implementation of the Controller interface for creating new transports +func NewController(config *config.Config, clock pub.Clock, client pub.HttpClient, log *logrus.Logger) Controller { + return &controller{ + config: config, + clock: clock, + client: client, + appAgent: fmt.Sprintf("%s %s", config.ApplicationName, config.Host), + } +} + +// NewTransport returns a new http signature transport with the given public key id (a URL), and the given private key. +func (c *controller) NewTransport(pubKeyID string, privkey crypto.PrivateKey) (pub.Transport, error) { + prefs := []httpsig.Algorithm{httpsig.RSA_SHA256, httpsig.RSA_SHA512} + digestAlgo := httpsig.DigestSha256 + getHeaders := []string{"(request-target)", "date"} + postHeaders := []string{"(request-target)", "date", "digest"} + + getSigner, _, err := httpsig.NewSigner(prefs, digestAlgo, getHeaders, httpsig.Signature) + if err != nil { + return nil, fmt.Errorf("error creating get signer: %s", err) + } + + postSigner, _, err := httpsig.NewSigner(prefs, digestAlgo, postHeaders, httpsig.Signature) + if err != nil { + return nil, fmt.Errorf("error creating post signer: %s", err) + } + + return pub.NewHttpSigTransport(c.client, c.appAgent, c.clock, getSigner, postSigner, pubKeyID, privkey), nil +} diff --git a/internal/typeutils/accountable.go b/internal/typeutils/accountable.go new file mode 100644 index 0000000..ba5c4aa --- /dev/null +++ b/internal/typeutils/accountable.go @@ -0,0 +1,101 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package typeutils + +import "github.com/go-fed/activity/streams/vocab" + +// Accountable represents the minimum activitypub interface for representing an 'account'. +// This interface is fulfilled by: Person, Application, Organization, Service, and Group +type Accountable interface { + withJSONLDId + withGetTypeName + withPreferredUsername + withIcon + withDisplayName + withImage + withSummary + withDiscoverable + withURL + withPublicKey + withInbox + withOutbox + withFollowing + withFollowers + withFeatured +} + +type withJSONLDId interface { + GetJSONLDId() vocab.JSONLDIdProperty +} + +type withGetTypeName interface { + GetTypeName() string +} + +type withPreferredUsername interface { + GetActivityStreamsPreferredUsername() vocab.ActivityStreamsPreferredUsernameProperty +} + +type withIcon interface { + GetActivityStreamsIcon() vocab.ActivityStreamsIconProperty +} + +type withDisplayName interface { + GetActivityStreamsName() vocab.ActivityStreamsNameProperty +} + +type withImage interface { + GetActivityStreamsImage() vocab.ActivityStreamsImageProperty +} + +type withSummary interface { + GetActivityStreamsSummary() vocab.ActivityStreamsSummaryProperty +} + +type withDiscoverable interface { + GetTootDiscoverable() vocab.TootDiscoverableProperty +} + +type withURL interface { + GetActivityStreamsUrl() vocab.ActivityStreamsUrlProperty +} + +type withPublicKey interface { + GetW3IDSecurityV1PublicKey() vocab.W3IDSecurityV1PublicKeyProperty +} + +type withInbox interface { + GetActivityStreamsInbox() vocab.ActivityStreamsInboxProperty +} + +type withOutbox interface { + GetActivityStreamsOutbox() vocab.ActivityStreamsOutboxProperty +} + +type withFollowing interface { + GetActivityStreamsFollowing() vocab.ActivityStreamsFollowingProperty +} + +type withFollowers interface { + GetActivityStreamsFollowers() vocab.ActivityStreamsFollowersProperty +} + +type withFeatured interface { + GetTootFeatured() vocab.TootFeaturedProperty +} diff --git a/internal/typeutils/asextractionutil.go b/internal/typeutils/asextractionutil.go new file mode 100644 index 0000000..8d39be3 --- /dev/null +++ b/internal/typeutils/asextractionutil.go @@ -0,0 +1,216 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package typeutils + +import ( + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "net/url" + + "github.com/go-fed/activity/pub" +) + +func extractPreferredUsername(i withPreferredUsername) (string, error) { + u := i.GetActivityStreamsPreferredUsername() + if u == nil || !u.IsXMLSchemaString() { + return "", errors.New("preferredUsername was not a string") + } + if u.GetXMLSchemaString() == "" { + return "", errors.New("preferredUsername was empty") + } + return u.GetXMLSchemaString(), nil +} + +func extractName(i withDisplayName) (string, error) { + nameProp := i.GetActivityStreamsName() + if nameProp == nil { + return "", errors.New("activityStreamsName not found") + } + + // take the first name string we can find + for nameIter := nameProp.Begin(); nameIter != nameProp.End(); nameIter = nameIter.Next() { + if nameIter.IsXMLSchemaString() && nameIter.GetXMLSchemaString() != "" { + return nameIter.GetXMLSchemaString(), nil + } + } + + return "", errors.New("activityStreamsName not found") +} + +// extractIconURL extracts a URL to a supported image file from something like: +// "icon": { +// "mediaType": "image/jpeg", +// "type": "Image", +// "url": "http://example.org/path/to/some/file.jpeg" +// }, +func extractIconURL(i withIcon) (*url.URL, error) { + iconProp := i.GetActivityStreamsIcon() + if iconProp == nil { + return nil, errors.New("icon property was nil") + } + + // icon can potentially contain multiple entries, so we iterate through all of them + // here in order to find the first one that meets these criteria: + // 1. is an image + // 2. has a URL so we can grab it + for iconIter := iconProp.Begin(); iconIter != iconProp.End(); iconIter = iconIter.Next() { + // 1. is an image + if !iconIter.IsActivityStreamsImage() { + continue + } + imageValue := iconIter.GetActivityStreamsImage() + if imageValue == nil { + continue + } + + // 2. has a URL so we can grab it + url, err := extractURL(imageValue) + if err == nil && url != nil { + return url, nil + } + } + // if we get to this point we didn't find an icon meeting our criteria :'( + return nil, errors.New("could not extract valid image from icon") +} + +// extractImageURL extracts a URL to a supported image file from something like: +// "image": { +// "mediaType": "image/jpeg", +// "type": "Image", +// "url": "http://example.org/path/to/some/file.jpeg" +// }, +func extractImageURL(i withImage) (*url.URL, error) { + imageProp := i.GetActivityStreamsImage() + if imageProp == nil { + return nil, errors.New("icon property was nil") + } + + // icon can potentially contain multiple entries, so we iterate through all of them + // here in order to find the first one that meets these criteria: + // 1. is an image + // 2. has a URL so we can grab it + for imageIter := imageProp.Begin(); imageIter != imageProp.End(); imageIter = imageIter.Next() { + // 1. is an image + if !imageIter.IsActivityStreamsImage() { + continue + } + imageValue := imageIter.GetActivityStreamsImage() + if imageValue == nil { + continue + } + + // 2. has a URL so we can grab it + url, err := extractURL(imageValue) + if err == nil && url != nil { + return url, nil + } + } + // if we get to this point we didn't find an image meeting our criteria :'( + return nil, errors.New("could not extract valid image from image property") +} + +func extractSummary(i withSummary) (string, error) { + summaryProp := i.GetActivityStreamsSummary() + if summaryProp == nil { + return "", errors.New("summary property was nil") + } + + for summaryIter := summaryProp.Begin(); summaryIter != summaryProp.End(); summaryIter = summaryIter.Next() { + if summaryIter.IsXMLSchemaString() && summaryIter.GetXMLSchemaString() != "" { + return summaryIter.GetXMLSchemaString(), nil + } + } + + return "", errors.New("could not extract summary") +} + +func extractDiscoverable(i withDiscoverable) (bool, error) { + if i.GetTootDiscoverable() == nil { + return false, errors.New("discoverable was nil") + } + return i.GetTootDiscoverable().Get(), nil +} + +func extractURL(i withURL) (*url.URL, error) { + urlProp := i.GetActivityStreamsUrl() + if urlProp == nil { + return nil, errors.New("url property was nil") + } + + for urlIter := urlProp.Begin(); urlIter != urlProp.End(); urlIter = urlIter.Next() { + if urlIter.IsIRI() && urlIter.GetIRI() != nil { + return urlIter.GetIRI(), nil + } + } + + return nil, errors.New("could not extract url") +} + +func extractPublicKeyForOwner(i withPublicKey, forOwner *url.URL) (*rsa.PublicKey, *url.URL, error) { + publicKeyProp := i.GetW3IDSecurityV1PublicKey() + if publicKeyProp == nil { + return nil, nil, errors.New("public key property was nil") + } + + for publicKeyIter := publicKeyProp.Begin(); publicKeyIter != publicKeyProp.End(); publicKeyIter = publicKeyIter.Next() { + pkey := publicKeyIter.Get() + if pkey == nil { + continue + } + + pkeyID, err := pub.GetId(pkey) + if err != nil || pkeyID == nil { + continue + } + + if pkey.GetW3IDSecurityV1Owner() == nil || pkey.GetW3IDSecurityV1Owner().Get() == nil || pkey.GetW3IDSecurityV1Owner().Get().String() != forOwner.String() { + continue + } + + if pkey.GetW3IDSecurityV1PublicKeyPem() == nil { + continue + } + + pkeyPem := pkey.GetW3IDSecurityV1PublicKeyPem().Get() + if pkeyPem == "" { + continue + } + + block, _ := pem.Decode([]byte(pkeyPem)) + if block == nil || block.Type != "PUBLIC KEY" { + return nil, nil, errors.New("could not decode publicKeyPem to PUBLIC KEY pem block type") + } + + p, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, nil, fmt.Errorf("could not parse public key from block bytes: %s", err) + } + if p == nil { + return nil, nil, errors.New("returned public key was empty") + } + + if publicKey, ok := p.(*rsa.PublicKey); ok { + return publicKey, pkeyID, nil + } + } + return nil, nil, errors.New("couldn't find public key") +} diff --git a/internal/typeutils/astointernal.go b/internal/typeutils/astointernal.go new file mode 100644 index 0000000..5e3b6b0 --- /dev/null +++ b/internal/typeutils/astointernal.go @@ -0,0 +1,164 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package typeutils + +import ( + "errors" + "fmt" + + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +func (c *converter) ASRepresentationToAccount(accountable Accountable) (*gtsmodel.Account, error) { + // first check if we actually already know this account + uriProp := accountable.GetJSONLDId() + if uriProp == nil || !uriProp.IsIRI() { + return nil, errors.New("no id property found on person, or id was not an iri") + } + uri := uriProp.GetIRI() + + acct := >smodel.Account{} + err := c.db.GetWhere("uri", uri.String(), acct) + if err == nil { + // we already know this account so we can skip generating it + return acct, nil + } + if _, ok := err.(db.ErrNoEntries); !ok { + // we don't know the account and there's been a real error + return nil, fmt.Errorf("error getting account with uri %s from the database: %s", uri.String(), err) + } + + // we don't know the account so we need to generate it from the person -- at least we already have the URI! + acct = >smodel.Account{} + acct.URI = uri.String() + + // Username aka preferredUsername + // We need this one so bail if it's not set. + username, err := extractPreferredUsername(accountable) + if err != nil { + return nil, fmt.Errorf("couldn't extract username: %s", err) + } + acct.Username = username + + // Domain + acct.Domain = uri.Host + + // avatar aka icon + // if this one isn't extractable in a format we recognise we'll just skip it + if avatarURL, err := extractIconURL(accountable); err == nil { + acct.AvatarRemoteURL = avatarURL.String() + } + + // header aka image + // if this one isn't extractable in a format we recognise we'll just skip it + if headerURL, err := extractImageURL(accountable); err == nil { + acct.HeaderRemoteURL = headerURL.String() + } + + // display name aka name + // we default to the username, but take the more nuanced name property if it exists + acct.DisplayName = username + if displayName, err := extractName(accountable); err == nil { + acct.DisplayName = displayName + } + + // TODO: fields aka attachment array + + // note aka summary + note, err := extractSummary(accountable) + if err == nil && note != "" { + acct.Note = note + } + + // check for bot and actor type + switch gtsmodel.ActivityStreamsActor(accountable.GetTypeName()) { + case gtsmodel.ActivityStreamsPerson, gtsmodel.ActivityStreamsGroup, gtsmodel.ActivityStreamsOrganization: + // people, groups, and organizations aren't bots + acct.Bot = false + // apps and services are + case gtsmodel.ActivityStreamsApplication, gtsmodel.ActivityStreamsService: + acct.Bot = true + default: + // we don't know what this is! + return nil, fmt.Errorf("type name %s not recognised or not convertible to gtsmodel.ActivityStreamsActor", accountable.GetTypeName()) + } + acct.ActorType = gtsmodel.ActivityStreamsActor(accountable.GetTypeName()) + + // TODO: locked aka manuallyApprovesFollowers + + // discoverable + // default to false -- take custom value if it's set though + acct.Discoverable = false + discoverable, err := extractDiscoverable(accountable) + if err == nil { + acct.Discoverable = discoverable + } + + // url property + url, err := extractURL(accountable) + if err != nil { + return nil, fmt.Errorf("could not extract url for person with id %s: %s", uri.String(), err) + } + acct.URL = url.String() + + // InboxURI + if accountable.GetActivityStreamsInbox() == nil || accountable.GetActivityStreamsInbox().GetIRI() == nil { + return nil, fmt.Errorf("person with id %s had no inbox uri", uri.String()) + } + acct.InboxURI = accountable.GetActivityStreamsInbox().GetIRI().String() + + // OutboxURI + if accountable.GetActivityStreamsOutbox() == nil || accountable.GetActivityStreamsOutbox().GetIRI() == nil { + return nil, fmt.Errorf("person with id %s had no outbox uri", uri.String()) + } + acct.OutboxURI = accountable.GetActivityStreamsOutbox().GetIRI().String() + + // FollowingURI + if accountable.GetActivityStreamsFollowing() == nil || accountable.GetActivityStreamsFollowing().GetIRI() == nil { + return nil, fmt.Errorf("person with id %s had no following uri", uri.String()) + } + acct.FollowingURI = accountable.GetActivityStreamsFollowing().GetIRI().String() + + // FollowersURI + if accountable.GetActivityStreamsFollowers() == nil || accountable.GetActivityStreamsFollowers().GetIRI() == nil { + return nil, fmt.Errorf("person with id %s had no followers uri", uri.String()) + } + acct.FollowersURI = accountable.GetActivityStreamsFollowers().GetIRI().String() + + // FeaturedURI + // very much optional + if accountable.GetTootFeatured() != nil && accountable.GetTootFeatured().GetIRI() != nil { + acct.FeaturedCollectionURI = accountable.GetTootFeatured().GetIRI().String() + } + + // TODO: FeaturedTagsURI + + // TODO: alsoKnownAs + + // publicKey + pkey, pkeyURL, err := extractPublicKeyForOwner(accountable, uri) + if err != nil { + return nil, fmt.Errorf("couldn't get public key for person %s: %s", uri.String(), err) + } + acct.PublicKey = pkey + acct.PublicKeyURI = pkeyURL.String() + + return acct, nil +} diff --git a/internal/typeutils/astointernal_test.go b/internal/typeutils/astointernal_test.go new file mode 100644 index 0000000..1cd66a0 --- /dev/null +++ b/internal/typeutils/astointernal_test.go @@ -0,0 +1,206 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package typeutils_test + +import ( + "context" + "encoding/json" + "fmt" + "testing" + + "github.com/go-fed/activity/streams" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type ASToInternalTestSuite struct { + ConverterStandardTestSuite +} + +const ( + gargronAsActivityJson = `{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", + "toot": "http://joinmastodon.org/ns#", + "featured": { + "@id": "toot:featured", + "@type": "@id" + }, + "featuredTags": { + "@id": "toot:featuredTags", + "@type": "@id" + }, + "alsoKnownAs": { + "@id": "as:alsoKnownAs", + "@type": "@id" + }, + "movedTo": { + "@id": "as:movedTo", + "@type": "@id" + }, + "schema": "http://schema.org#", + "PropertyValue": "schema:PropertyValue", + "value": "schema:value", + "IdentityProof": "toot:IdentityProof", + "discoverable": "toot:discoverable", + "Device": "toot:Device", + "Ed25519Signature": "toot:Ed25519Signature", + "Ed25519Key": "toot:Ed25519Key", + "Curve25519Key": "toot:Curve25519Key", + "EncryptedMessage": "toot:EncryptedMessage", + "publicKeyBase64": "toot:publicKeyBase64", + "deviceId": "toot:deviceId", + "claim": { + "@type": "@id", + "@id": "toot:claim" + }, + "fingerprintKey": { + "@type": "@id", + "@id": "toot:fingerprintKey" + }, + "identityKey": { + "@type": "@id", + "@id": "toot:identityKey" + }, + "devices": { + "@type": "@id", + "@id": "toot:devices" + }, + "messageFranking": "toot:messageFranking", + "messageType": "toot:messageType", + "cipherText": "toot:cipherText", + "suspended": "toot:suspended", + "focalPoint": { + "@container": "@list", + "@id": "toot:focalPoint" + } + } + ], + "id": "https://mastodon.social/users/Gargron", + "type": "Person", + "following": "https://mastodon.social/users/Gargron/following", + "followers": "https://mastodon.social/users/Gargron/followers", + "inbox": "https://mastodon.social/users/Gargron/inbox", + "outbox": "https://mastodon.social/users/Gargron/outbox", + "featured": "https://mastodon.social/users/Gargron/collections/featured", + "featuredTags": "https://mastodon.social/users/Gargron/collections/tags", + "preferredUsername": "Gargron", + "name": "Eugen", + "summary": "

Developer of Mastodon and administrator of mastodon.social. I post service announcements, development updates, and personal stuff.

", + "url": "https://mastodon.social/@Gargron", + "manuallyApprovesFollowers": false, + "discoverable": true, + "devices": "https://mastodon.social/users/Gargron/collections/devices", + "alsoKnownAs": [ + "https://tooting.ai/users/Gargron" + ], + "publicKey": { + "id": "https://mastodon.social/users/Gargron#main-key", + "owner": "https://mastodon.social/users/Gargron", + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvXc4vkECU2/CeuSo1wtn\nFoim94Ne1jBMYxTZ9wm2YTdJq1oiZKif06I2fOqDzY/4q/S9uccrE9Bkajv1dnkO\nVm31QjWlhVpSKynVxEWjVBO5Ienue8gND0xvHIuXf87o61poqjEoepvsQFElA5ym\novljWGSA/jpj7ozygUZhCXtaS2W5AD5tnBQUpcO0lhItYPYTjnmzcc4y2NbJV8hz\n2s2G8qKv8fyimE23gY1XrPJg+cRF+g4PqFXujjlJ7MihD9oqtLGxbu7o1cifTn3x\nBfIdPythWu5b4cujNsB3m3awJjVmx+MHQ9SugkSIYXV0Ina77cTNS0M2PYiH1PFR\nTwIDAQAB\n-----END PUBLIC KEY-----\n" + }, + "tag": [], + "attachment": [ + { + "type": "PropertyValue", + "name": "Patreon", + "value": "https://www.patreon.com/mastodon" + }, + { + "type": "PropertyValue", + "name": "Homepage", + "value": "https://zeonfederated.com" + }, + { + "type": "IdentityProof", + "name": "gargron", + "signatureAlgorithm": "keybase", + "signatureValue": "5cfc20c7018f2beefb42a68836da59a792e55daa4d118498c9b1898de7e845690f" + } + ], + "endpoints": { + "sharedInbox": "https://mastodon.social/inbox" + }, + "icon": { + "type": "Image", + "mediaType": "image/jpeg", + "url": "https://files.mastodon.social/accounts/avatars/000/000/001/original/d96d39a0abb45b92.jpg" + }, + "image": { + "type": "Image", + "mediaType": "image/png", + "url": "https://files.mastodon.social/accounts/headers/000/000/001/original/c91b871f294ea63e.png" + } + }` +) + +func (suite *ASToInternalTestSuite) SetupSuite() { + suite.config = testrig.NewTestConfig() + suite.db = testrig.NewTestDB() + suite.log = testrig.NewTestLog() + suite.accounts = testrig.NewTestAccounts() + suite.people = testrig.NewTestFediPeople() + suite.typeconverter = typeutils.NewConverter(suite.config, suite.db) +} + +func (suite *ASToInternalTestSuite) SetupTest() { + testrig.StandardDBSetup(suite.db) +} + +func (suite *ASToInternalTestSuite) TestParsePerson() { + + testPerson := suite.people["new_person_1"] + + acct, err := suite.typeconverter.ASRepresentationToAccount(testPerson) + assert.NoError(suite.T(), err) + + fmt.Printf("%+v", acct) + // TODO: write assertions here, rn we're just eyeballing the output +} + +func (suite *ASToInternalTestSuite) TestParseGargron() { + m := make(map[string]interface{}) + err := json.Unmarshal([]byte(gargronAsActivityJson), &m) + assert.NoError(suite.T(), err) + + t, err := streams.ToType(context.Background(), m) + assert.NoError(suite.T(), err) + + rep, ok := t.(typeutils.Accountable) + assert.True(suite.T(), ok) + + acct, err := suite.typeconverter.ASRepresentationToAccount(rep) + assert.NoError(suite.T(), err) + + fmt.Printf("%+v", acct) + // TODO: write assertions here, rn we're just eyeballing the output +} + +func (suite *ASToInternalTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) +} + +func TestASToInternalTestSuite(t *testing.T) { + suite.Run(t, new(ASToInternalTestSuite)) +} diff --git a/internal/typeutils/converter.go b/internal/typeutils/converter.go new file mode 100644 index 0000000..5118386 --- /dev/null +++ b/internal/typeutils/converter.go @@ -0,0 +1,113 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package typeutils + +import ( + "github.com/go-fed/activity/streams/vocab" + "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +// TypeConverter is an interface for the common action of converting between apimodule (frontend, serializable) models, +// internal gts models used in the database, and activitypub models used in federation. +// +// It requires access to the database because many of the conversions require pulling out database entries and counting them etc. +// That said, it *absolutely should not* manipulate database entries in any way, only examine them. +type TypeConverter interface { + /* + INTERNAL (gts) MODEL TO FRONTEND (mastodon) MODEL + */ + + // AccountToMastoSensitive takes a db model account as a param, and returns a populated mastotype account, or an error + // if something goes wrong. The returned account should be ready to serialize on an API level, and may have sensitive fields, + // so serve it only to an authorized user who should have permission to see it. + AccountToMastoSensitive(account *gtsmodel.Account) (*model.Account, error) + + // AccountToMastoPublic takes a db model account as a param, and returns a populated mastotype account, or an error + // if something goes wrong. The returned account should be ready to serialize on an API level, and may NOT have sensitive fields. + // In other words, this is the public record that the server has of an account. + AccountToMastoPublic(account *gtsmodel.Account) (*model.Account, error) + + // AppToMastoSensitive takes a db model application as a param, and returns a populated mastotype application, or an error + // if something goes wrong. The returned application should be ready to serialize on an API level, and may have sensitive fields + // (such as client id and client secret), so serve it only to an authorized user who should have permission to see it. + AppToMastoSensitive(application *gtsmodel.Application) (*model.Application, error) + + // AppToMastoPublic takes a db model application as a param, and returns a populated mastotype application, or an error + // if something goes wrong. The returned application should be ready to serialize on an API level, and has sensitive + // fields sanitized so that it can be served to non-authorized accounts without revealing any private information. + AppToMastoPublic(application *gtsmodel.Application) (*model.Application, error) + + // AttachmentToMasto converts a gts model media attacahment into its mastodon representation for serialization on the API. + AttachmentToMasto(attachment *gtsmodel.MediaAttachment) (model.Attachment, error) + + // MentionToMasto converts a gts model mention into its mastodon (frontend) representation for serialization on the API. + MentionToMasto(m *gtsmodel.Mention) (model.Mention, error) + + // EmojiToMasto converts a gts model emoji into its mastodon (frontend) representation for serialization on the API. + EmojiToMasto(e *gtsmodel.Emoji) (model.Emoji, error) + + // TagToMasto converts a gts model tag into its mastodon (frontend) representation for serialization on the API. + TagToMasto(t *gtsmodel.Tag) (model.Tag, error) + + // StatusToMasto converts a gts model status into its mastodon (frontend) representation for serialization on the API. + StatusToMasto(s *gtsmodel.Status, targetAccount *gtsmodel.Account, requestingAccount *gtsmodel.Account, boostOfAccount *gtsmodel.Account, replyToAccount *gtsmodel.Account, reblogOfStatus *gtsmodel.Status) (*model.Status, error) + + // VisToMasto converts a gts visibility into its mastodon equivalent + VisToMasto(m gtsmodel.Visibility) model.Visibility + + /* + FRONTEND (mastodon) MODEL TO INTERNAL (gts) MODEL + */ + + // MastoVisToVis converts a mastodon visibility into its gts equivalent. + MastoVisToVis(m model.Visibility) gtsmodel.Visibility + + /* + ACTIVITYSTREAMS MODEL TO INTERNAL (gts) MODEL + */ + + // ASPersonToAccount converts a remote account/person/application representation into a gts model account + ASRepresentationToAccount(accountable Accountable) (*gtsmodel.Account, error) + + /* + INTERNAL (gts) MODEL TO ACTIVITYSTREAMS MODEL + */ + + // AccountToAS converts a gts model account into an activity streams person, suitable for federation + AccountToAS(a *gtsmodel.Account) (vocab.ActivityStreamsPerson, error) + + // StatusToAS converts a gts model status into an activity streams note, suitable for federation + StatusToAS(s *gtsmodel.Status) (vocab.ActivityStreamsNote, error) +} + +type converter struct { + config *config.Config + db db.DB +} + +// NewConverter returns a new Converter +func NewConverter(config *config.Config, db db.DB) TypeConverter { + return &converter{ + config: config, + db: db, + } +} diff --git a/internal/typeutils/converter_test.go b/internal/typeutils/converter_test.go new file mode 100644 index 0000000..b2272f5 --- /dev/null +++ b/internal/typeutils/converter_test.go @@ -0,0 +1,40 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package typeutils_test + +import ( + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" +) + +// nolint +type ConverterStandardTestSuite struct { + suite.Suite + config *config.Config + db db.DB + log *logrus.Logger + accounts map[string]*gtsmodel.Account + people map[string]typeutils.Accountable + + typeconverter typeutils.TypeConverter +} diff --git a/internal/typeutils/frontendtointernal.go b/internal/typeutils/frontendtointernal.go new file mode 100644 index 0000000..6bb45d6 --- /dev/null +++ b/internal/typeutils/frontendtointernal.go @@ -0,0 +1,39 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package typeutils + +import ( + "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +// MastoVisToVis converts a mastodon visibility into its gts equivalent. +func (c *converter) MastoVisToVis(m model.Visibility) gtsmodel.Visibility { + switch m { + case model.VisibilityPublic: + return gtsmodel.VisibilityPublic + case model.VisibilityUnlisted: + return gtsmodel.VisibilityUnlocked + case model.VisibilityPrivate: + return gtsmodel.VisibilityFollowersOnly + case model.VisibilityDirect: + return gtsmodel.VisibilityDirect + } + return "" +} diff --git a/internal/typeutils/internaltoas.go b/internal/typeutils/internaltoas.go new file mode 100644 index 0000000..73c1211 --- /dev/null +++ b/internal/typeutils/internaltoas.go @@ -0,0 +1,260 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package typeutils + +import ( + "crypto/x509" + "encoding/pem" + "net/url" + + "github.com/go-fed/activity/streams" + "github.com/go-fed/activity/streams/vocab" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +// Converts a gts model account into an Activity Streams person type, following +// the spec laid out for mastodon here: https://docs.joinmastodon.org/spec/activitypub/ +func (c *converter) AccountToAS(a *gtsmodel.Account) (vocab.ActivityStreamsPerson, error) { + person := streams.NewActivityStreamsPerson() + + // id should be the activitypub URI of this user + // something like https://example.org/users/example_user + profileIDURI, err := url.Parse(a.URI) + if err != nil { + return nil, err + } + idProp := streams.NewJSONLDIdProperty() + idProp.SetIRI(profileIDURI) + person.SetJSONLDId(idProp) + + // following + // The URI for retrieving a list of accounts this user is following + followingURI, err := url.Parse(a.FollowingURI) + if err != nil { + return nil, err + } + followingProp := streams.NewActivityStreamsFollowingProperty() + followingProp.SetIRI(followingURI) + person.SetActivityStreamsFollowing(followingProp) + + // followers + // The URI for retrieving a list of this user's followers + followersURI, err := url.Parse(a.FollowersURI) + if err != nil { + return nil, err + } + followersProp := streams.NewActivityStreamsFollowersProperty() + followersProp.SetIRI(followersURI) + person.SetActivityStreamsFollowers(followersProp) + + // inbox + // the activitypub inbox of this user for accepting messages + inboxURI, err := url.Parse(a.InboxURI) + if err != nil { + return nil, err + } + inboxProp := streams.NewActivityStreamsInboxProperty() + inboxProp.SetIRI(inboxURI) + person.SetActivityStreamsInbox(inboxProp) + + // outbox + // the activitypub outbox of this user for serving messages + outboxURI, err := url.Parse(a.OutboxURI) + if err != nil { + return nil, err + } + outboxProp := streams.NewActivityStreamsOutboxProperty() + outboxProp.SetIRI(outboxURI) + person.SetActivityStreamsOutbox(outboxProp) + + // featured posts + // Pinned posts. + featuredURI, err := url.Parse(a.FeaturedCollectionURI) + if err != nil { + return nil, err + } + featuredProp := streams.NewTootFeaturedProperty() + featuredProp.SetIRI(featuredURI) + person.SetTootFeatured(featuredProp) + + // featuredTags + // NOT IMPLEMENTED + + // preferredUsername + // Used for Webfinger lookup. Must be unique on the domain, and must correspond to a Webfinger acct: URI. + preferredUsernameProp := streams.NewActivityStreamsPreferredUsernameProperty() + preferredUsernameProp.SetXMLSchemaString(a.Username) + person.SetActivityStreamsPreferredUsername(preferredUsernameProp) + + // name + // Used as profile display name. + nameProp := streams.NewActivityStreamsNameProperty() + if a.Username != "" { + nameProp.AppendXMLSchemaString(a.DisplayName) + } else { + nameProp.AppendXMLSchemaString(a.Username) + } + person.SetActivityStreamsName(nameProp) + + // summary + // Used as profile bio. + if a.Note != "" { + summaryProp := streams.NewActivityStreamsSummaryProperty() + summaryProp.AppendXMLSchemaString(a.Note) + person.SetActivityStreamsSummary(summaryProp) + } + + // url + // Used as profile link. + profileURL, err := url.Parse(a.URL) + if err != nil { + return nil, err + } + urlProp := streams.NewActivityStreamsUrlProperty() + urlProp.AppendIRI(profileURL) + person.SetActivityStreamsUrl(urlProp) + + // manuallyApprovesFollowers + // Will be shown as a locked account. + // TODO: NOT IMPLEMENTED **YET** -- this needs to be added as an activitypub extension to https://github.com/go-fed/activity, see https://github.com/go-fed/activity/tree/master/astool + + // discoverable + // Will be shown in the profile directory. + discoverableProp := streams.NewTootDiscoverableProperty() + discoverableProp.Set(a.Discoverable) + person.SetTootDiscoverable(discoverableProp) + + // devices + // NOT IMPLEMENTED, probably won't implement + + // alsoKnownAs + // Required for Move activity. + // TODO: NOT IMPLEMENTED **YET** -- this needs to be added as an activitypub extension to https://github.com/go-fed/activity, see https://github.com/go-fed/activity/tree/master/astool + + // publicKey + // Required for signatures. + publicKeyProp := streams.NewW3IDSecurityV1PublicKeyProperty() + + // create the public key + publicKey := streams.NewW3IDSecurityV1PublicKey() + + // set ID for the public key + publicKeyIDProp := streams.NewJSONLDIdProperty() + publicKeyURI, err := url.Parse(a.PublicKeyURI) + if err != nil { + return nil, err + } + publicKeyIDProp.SetIRI(publicKeyURI) + publicKey.SetJSONLDId(publicKeyIDProp) + + // set owner for the public key + publicKeyOwnerProp := streams.NewW3IDSecurityV1OwnerProperty() + publicKeyOwnerProp.SetIRI(profileIDURI) + publicKey.SetW3IDSecurityV1Owner(publicKeyOwnerProp) + + // set the pem key itself + encodedPublicKey, err := x509.MarshalPKIXPublicKey(a.PublicKey) + if err != nil { + return nil, err + } + publicKeyBytes := pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: encodedPublicKey, + }) + publicKeyPEMProp := streams.NewW3IDSecurityV1PublicKeyPemProperty() + publicKeyPEMProp.Set(string(publicKeyBytes)) + publicKey.SetW3IDSecurityV1PublicKeyPem(publicKeyPEMProp) + + // append the public key to the public key property + publicKeyProp.AppendW3IDSecurityV1PublicKey(publicKey) + + // set the public key property on the Person + person.SetW3IDSecurityV1PublicKey(publicKeyProp) + + // tag + // TODO: Any tags used in the summary of this profile + + // attachment + // Used for profile fields. + // TODO: The PropertyValue type has to be added: https://schema.org/PropertyValue + + // endpoints + // NOT IMPLEMENTED -- this is for shared inbox which we don't use + + // icon + // Used as profile avatar. + if a.AvatarMediaAttachmentID != "" { + iconProperty := streams.NewActivityStreamsIconProperty() + + iconImage := streams.NewActivityStreamsImage() + + avatar := >smodel.MediaAttachment{} + if err := c.db.GetByID(a.AvatarMediaAttachmentID, avatar); err != nil { + return nil, err + } + + mediaType := streams.NewActivityStreamsMediaTypeProperty() + mediaType.Set(avatar.File.ContentType) + iconImage.SetActivityStreamsMediaType(mediaType) + + avatarURLProperty := streams.NewActivityStreamsUrlProperty() + avatarURL, err := url.Parse(avatar.URL) + if err != nil { + return nil, err + } + avatarURLProperty.AppendIRI(avatarURL) + iconImage.SetActivityStreamsUrl(avatarURLProperty) + + iconProperty.AppendActivityStreamsImage(iconImage) + person.SetActivityStreamsIcon(iconProperty) + } + + // image + // Used as profile header. + if a.HeaderMediaAttachmentID != "" { + headerProperty := streams.NewActivityStreamsImageProperty() + + headerImage := streams.NewActivityStreamsImage() + + header := >smodel.MediaAttachment{} + if err := c.db.GetByID(a.HeaderMediaAttachmentID, header); err != nil { + return nil, err + } + + mediaType := streams.NewActivityStreamsMediaTypeProperty() + mediaType.Set(header.File.ContentType) + headerImage.SetActivityStreamsMediaType(mediaType) + + headerURLProperty := streams.NewActivityStreamsUrlProperty() + headerURL, err := url.Parse(header.URL) + if err != nil { + return nil, err + } + headerURLProperty.AppendIRI(headerURL) + headerImage.SetActivityStreamsUrl(headerURLProperty) + + headerProperty.AppendActivityStreamsImage(headerImage) + } + + return person, nil +} + +func (c *converter) StatusToAS(s *gtsmodel.Status) (vocab.ActivityStreamsNote, error) { + return nil, nil +} diff --git a/internal/typeutils/internaltoas_test.go b/internal/typeutils/internaltoas_test.go new file mode 100644 index 0000000..8eb827e --- /dev/null +++ b/internal/typeutils/internaltoas_test.go @@ -0,0 +1,76 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package typeutils_test + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/go-fed/activity/streams" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type InternalToASTestSuite struct { + ConverterStandardTestSuite +} + +// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout +func (suite *InternalToASTestSuite) SetupSuite() { + // setup standard items + suite.config = testrig.NewTestConfig() + suite.db = testrig.NewTestDB() + suite.log = testrig.NewTestLog() + suite.accounts = testrig.NewTestAccounts() + suite.people = testrig.NewTestFediPeople() + suite.typeconverter = typeutils.NewConverter(suite.config, suite.db) +} + +func (suite *InternalToASTestSuite) SetupTest() { + testrig.StandardDBSetup(suite.db) +} + +// TearDownTest drops tables to make sure there's no data in the db +func (suite *InternalToASTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) +} + +func (suite *InternalToASTestSuite) TestAccountToAS() { + testAccount := suite.accounts["local_account_1"] // take zork for this test + + asPerson, err := suite.typeconverter.AccountToAS(testAccount) + assert.NoError(suite.T(), err) + + ser, err := streams.Serialize(asPerson) + assert.NoError(suite.T(), err) + + bytes, err := json.Marshal(ser) + assert.NoError(suite.T(), err) + + fmt.Println(string(bytes)) + // TODO: write assertions here, rn we're just eyeballing the output +} + +func TestInternalToASTestSuite(t *testing.T) { + suite.Run(t, new(InternalToASTestSuite)) +} diff --git a/internal/mastotypes/converter.go b/internal/typeutils/internaltofrontend.go similarity index 73% rename from internal/mastotypes/converter.go rename to internal/typeutils/internaltofrontend.go index e689b62..9456ef5 100644 --- a/internal/mastotypes/converter.go +++ b/internal/typeutils/internaltofrontend.go @@ -16,72 +16,18 @@ along with this program. If not, see . */ -package mastotypes +package typeutils import ( "fmt" "time" - "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" - "github.com/superseriousbusiness/gotosocial/internal/util" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" ) -// Converter is an interface for the common action of converting between mastotypes (frontend, serializable) models and internal gts models used in the database. -// It requires access to the database because many of the conversions require pulling out database entries and counting them etc. -type Converter interface { - // AccountToMastoSensitive takes a db model account as a param, and returns a populated mastotype account, or an error - // if something goes wrong. The returned account should be ready to serialize on an API level, and may have sensitive fields, - // so serve it only to an authorized user who should have permission to see it. - AccountToMastoSensitive(account *gtsmodel.Account) (*mastotypes.Account, error) - - // AccountToMastoPublic takes a db model account as a param, and returns a populated mastotype account, or an error - // if something goes wrong. The returned account should be ready to serialize on an API level, and may NOT have sensitive fields. - // In other words, this is the public record that the server has of an account. - AccountToMastoPublic(account *gtsmodel.Account) (*mastotypes.Account, error) - - // AppToMastoSensitive takes a db model application as a param, and returns a populated mastotype application, or an error - // if something goes wrong. The returned application should be ready to serialize on an API level, and may have sensitive fields - // (such as client id and client secret), so serve it only to an authorized user who should have permission to see it. - AppToMastoSensitive(application *gtsmodel.Application) (*mastotypes.Application, error) - - // AppToMastoPublic takes a db model application as a param, and returns a populated mastotype application, or an error - // if something goes wrong. The returned application should be ready to serialize on an API level, and has sensitive - // fields sanitized so that it can be served to non-authorized accounts without revealing any private information. - AppToMastoPublic(application *gtsmodel.Application) (*mastotypes.Application, error) - - // AttachmentToMasto converts a gts model media attacahment into its mastodon representation for serialization on the API. - AttachmentToMasto(attachment *gtsmodel.MediaAttachment) (mastotypes.Attachment, error) - - // MentionToMasto converts a gts model mention into its mastodon (frontend) representation for serialization on the API. - MentionToMasto(m *gtsmodel.Mention) (mastotypes.Mention, error) - - // EmojiToMasto converts a gts model emoji into its mastodon (frontend) representation for serialization on the API. - EmojiToMasto(e *gtsmodel.Emoji) (mastotypes.Emoji, error) - - // TagToMasto converts a gts model tag into its mastodon (frontend) representation for serialization on the API. - TagToMasto(t *gtsmodel.Tag) (mastotypes.Tag, error) - - // StatusToMasto converts a gts model status into its mastodon (frontend) representation for serialization on the API. - StatusToMasto(s *gtsmodel.Status, targetAccount *gtsmodel.Account, requestingAccount *gtsmodel.Account, boostOfAccount *gtsmodel.Account, replyToAccount *gtsmodel.Account, reblogOfStatus *gtsmodel.Status) (*mastotypes.Status, error) -} - -type converter struct { - config *config.Config - db db.DB -} - -// New returns a new Converter -func New(config *config.Config, db db.DB) Converter { - return &converter{ - config: config, - db: db, - } -} - -func (c *converter) AccountToMastoSensitive(a *gtsmodel.Account) (*mastotypes.Account, error) { +func (c *converter) AccountToMastoSensitive(a *gtsmodel.Account) (*model.Account, error) { // we can build this sensitive account easily by first getting the public account.... mastoAccount, err := c.AccountToMastoPublic(a) if err != nil { @@ -102,8 +48,8 @@ func (c *converter) AccountToMastoSensitive(a *gtsmodel.Account) (*mastotypes.Ac frc = len(fr) } - mastoAccount.Source = &mastotypes.Source{ - Privacy: util.ParseMastoVisFromGTSVis(a.Privacy), + mastoAccount.Source = &model.Source{ + Privacy: c.VisToMasto(a.Privacy), Sensitive: a.Sensitive, Language: a.Language, Note: a.Note, @@ -114,7 +60,7 @@ func (c *converter) AccountToMastoSensitive(a *gtsmodel.Account) (*mastotypes.Ac return mastoAccount, nil } -func (c *converter) AccountToMastoPublic(a *gtsmodel.Account) (*mastotypes.Account, error) { +func (c *converter) AccountToMastoPublic(a *gtsmodel.Account) (*model.Account, error) { // count followers followers := []gtsmodel.Follow{} if err := c.db.GetFollowersByAccountID(a.ID, &followers); err != nil { @@ -174,7 +120,7 @@ func (c *converter) AccountToMastoPublic(a *gtsmodel.Account) (*mastotypes.Accou aviURLStatic := avi.Thumbnail.URL header := >smodel.MediaAttachment{} - if err := c.db.GetHeaderForAccountID(avi, a.ID); err != nil { + if err := c.db.GetHeaderForAccountID(header, a.ID); err != nil { if _, ok := err.(db.ErrNoEntries); !ok { return nil, fmt.Errorf("error getting header: %s", err) } @@ -183,9 +129,9 @@ func (c *converter) AccountToMastoPublic(a *gtsmodel.Account) (*mastotypes.Accou headerURLStatic := header.Thumbnail.URL // get the fields set on this account - fields := []mastotypes.Field{} + fields := []model.Field{} for _, f := range a.Fields { - mField := mastotypes.Field{ + mField := model.Field{ Name: f.Name, Value: f.Value, } @@ -204,7 +150,7 @@ func (c *converter) AccountToMastoPublic(a *gtsmodel.Account) (*mastotypes.Accou acct = a.Username } - return &mastotypes.Account{ + return &model.Account{ ID: a.ID, Username: a.Username, Acct: acct, @@ -227,8 +173,8 @@ func (c *converter) AccountToMastoPublic(a *gtsmodel.Account) (*mastotypes.Accou }, nil } -func (c *converter) AppToMastoSensitive(a *gtsmodel.Application) (*mastotypes.Application, error) { - return &mastotypes.Application{ +func (c *converter) AppToMastoSensitive(a *gtsmodel.Application) (*model.Application, error) { + return &model.Application{ ID: a.ID, Name: a.Name, Website: a.Website, @@ -239,35 +185,35 @@ func (c *converter) AppToMastoSensitive(a *gtsmodel.Application) (*mastotypes.Ap }, nil } -func (c *converter) AppToMastoPublic(a *gtsmodel.Application) (*mastotypes.Application, error) { - return &mastotypes.Application{ +func (c *converter) AppToMastoPublic(a *gtsmodel.Application) (*model.Application, error) { + return &model.Application{ Name: a.Name, Website: a.Website, }, nil } -func (c *converter) AttachmentToMasto(a *gtsmodel.MediaAttachment) (mastotypes.Attachment, error) { - return mastotypes.Attachment{ +func (c *converter) AttachmentToMasto(a *gtsmodel.MediaAttachment) (model.Attachment, error) { + return model.Attachment{ ID: a.ID, Type: string(a.Type), URL: a.URL, PreviewURL: a.Thumbnail.URL, RemoteURL: a.RemoteURL, PreviewRemoteURL: a.Thumbnail.RemoteURL, - Meta: mastotypes.MediaMeta{ - Original: mastotypes.MediaDimensions{ + Meta: model.MediaMeta{ + Original: model.MediaDimensions{ Width: a.FileMeta.Original.Width, Height: a.FileMeta.Original.Height, Size: fmt.Sprintf("%dx%d", a.FileMeta.Original.Width, a.FileMeta.Original.Height), Aspect: float32(a.FileMeta.Original.Aspect), }, - Small: mastotypes.MediaDimensions{ + Small: model.MediaDimensions{ Width: a.FileMeta.Small.Width, Height: a.FileMeta.Small.Height, Size: fmt.Sprintf("%dx%d", a.FileMeta.Small.Width, a.FileMeta.Small.Height), Aspect: float32(a.FileMeta.Small.Aspect), }, - Focus: mastotypes.MediaFocus{ + Focus: model.MediaFocus{ X: a.FileMeta.Focus.X, Y: a.FileMeta.Focus.Y, }, @@ -277,10 +223,10 @@ func (c *converter) AttachmentToMasto(a *gtsmodel.MediaAttachment) (mastotypes.A }, nil } -func (c *converter) MentionToMasto(m *gtsmodel.Mention) (mastotypes.Mention, error) { +func (c *converter) MentionToMasto(m *gtsmodel.Mention) (model.Mention, error) { target := >smodel.Account{} if err := c.db.GetByID(m.TargetAccountID, target); err != nil { - return mastotypes.Mention{}, err + return model.Mention{}, err } var local bool @@ -295,7 +241,7 @@ func (c *converter) MentionToMasto(m *gtsmodel.Mention) (mastotypes.Mention, err acct = fmt.Sprintf("@%s@%s", target.Username, target.Domain) } - return mastotypes.Mention{ + return model.Mention{ ID: target.ID, Username: target.Username, URL: target.URL, @@ -303,8 +249,8 @@ func (c *converter) MentionToMasto(m *gtsmodel.Mention) (mastotypes.Mention, err }, nil } -func (c *converter) EmojiToMasto(e *gtsmodel.Emoji) (mastotypes.Emoji, error) { - return mastotypes.Emoji{ +func (c *converter) EmojiToMasto(e *gtsmodel.Emoji) (model.Emoji, error) { + return model.Emoji{ Shortcode: e.Shortcode, URL: e.ImageURL, StaticURL: e.ImageStaticURL, @@ -313,10 +259,10 @@ func (c *converter) EmojiToMasto(e *gtsmodel.Emoji) (mastotypes.Emoji, error) { }, nil } -func (c *converter) TagToMasto(t *gtsmodel.Tag) (mastotypes.Tag, error) { +func (c *converter) TagToMasto(t *gtsmodel.Tag) (model.Tag, error) { tagURL := fmt.Sprintf("%s://%s/tags/%s", c.config.Protocol, c.config.Host, t.Name) - return mastotypes.Tag{ + return model.Tag{ Name: t.Name, URL: tagURL, // we don't serve URLs with collections of tagged statuses (FOR NOW) so this is purely for mastodon compatibility ¯\_(ツ)_/¯ }, nil @@ -328,7 +274,7 @@ func (c *converter) StatusToMasto( requestingAccount *gtsmodel.Account, boostOfAccount *gtsmodel.Account, replyToAccount *gtsmodel.Account, - reblogOfStatus *gtsmodel.Status) (*mastotypes.Status, error) { + reblogOfStatus *gtsmodel.Status) (*model.Status, error) { repliesCount, err := c.db.GetReplyCountForStatus(s) if err != nil { @@ -380,9 +326,9 @@ func (c *converter) StatusToMasto( } } - var mastoRebloggedStatus *mastotypes.Status // TODO + var mastoRebloggedStatus *model.Status // TODO - var mastoApplication *mastotypes.Application + var mastoApplication *model.Application if s.CreatedWithApplicationID != "" { gtsApplication := >smodel.Application{} if err := c.db.GetByID(s.CreatedWithApplicationID, gtsApplication); err != nil { @@ -399,7 +345,7 @@ func (c *converter) StatusToMasto( return nil, fmt.Errorf("error parsing account of status author: %s", err) } - mastoAttachments := []mastotypes.Attachment{} + mastoAttachments := []model.Attachment{} // the status might already have some gts attachments on it if it's not been pulled directly from the database // if so, we can directly convert the gts attachments into masto ones if s.GTSMediaAttachments != nil { @@ -426,7 +372,7 @@ func (c *converter) StatusToMasto( } } - mastoMentions := []mastotypes.Mention{} + mastoMentions := []model.Mention{} // the status might already have some gts mentions on it if it's not been pulled directly from the database // if so, we can directly convert the gts mentions into masto ones if s.GTSMentions != nil { @@ -453,7 +399,7 @@ func (c *converter) StatusToMasto( } } - mastoTags := []mastotypes.Tag{} + mastoTags := []model.Tag{} // the status might already have some gts tags on it if it's not been pulled directly from the database // if so, we can directly convert the gts tags into masto ones if s.GTSTags != nil { @@ -480,7 +426,7 @@ func (c *converter) StatusToMasto( } } - mastoEmojis := []mastotypes.Emoji{} + mastoEmojis := []model.Emoji{} // the status might already have some gts emojis on it if it's not been pulled directly from the database // if so, we can directly convert the gts emojis into masto ones if s.GTSEmojis != nil { @@ -507,17 +453,17 @@ func (c *converter) StatusToMasto( } } - var mastoCard *mastotypes.Card - var mastoPoll *mastotypes.Poll + var mastoCard *model.Card + var mastoPoll *model.Poll - return &mastotypes.Status{ + return &model.Status{ ID: s.ID, CreatedAt: s.CreatedAt.Format(time.RFC3339), InReplyToID: s.InReplyToID, InReplyToAccountID: s.InReplyToAccountID, Sensitive: s.Sensitive, SpoilerText: s.ContentWarning, - Visibility: util.ParseMastoVisFromGTSVis(s.Visibility), + Visibility: c.VisToMasto(s.Visibility), Language: s.Language, URI: s.URI, URL: s.URL, @@ -542,3 +488,18 @@ func (c *converter) StatusToMasto( Text: s.Text, }, nil } + +// VisToMasto converts a gts visibility into its mastodon equivalent +func (c *converter) VisToMasto(m gtsmodel.Visibility) model.Visibility { + switch m { + case gtsmodel.VisibilityPublic: + return model.VisibilityPublic + case gtsmodel.VisibilityUnlocked: + return model.VisibilityUnlisted + case gtsmodel.VisibilityFollowersOnly, gtsmodel.VisibilityMutualsOnly: + return model.VisibilityPrivate + case gtsmodel.VisibilityDirect: + return model.VisibilityDirect + } + return "" +} diff --git a/internal/util/parse.go b/internal/util/parse.go deleted file mode 100644 index f0bcff5..0000000 --- a/internal/util/parse.go +++ /dev/null @@ -1,96 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . -*/ - -package util - -import ( - "fmt" - - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" -) - -// URIs contains a bunch of URIs and URLs for a user, host, account, etc. -type URIs struct { - HostURL string - UserURL string - StatusesURL string - - UserURI string - StatusesURI string - InboxURI string - OutboxURI string - FollowersURI string - CollectionURI string -} - -// GenerateURIs throws together a bunch of URIs for the given username, with the given protocol and host. -func GenerateURIs(username string, protocol string, host string) *URIs { - hostURL := fmt.Sprintf("%s://%s", protocol, host) - userURL := fmt.Sprintf("%s/@%s", hostURL, username) - statusesURL := fmt.Sprintf("%s/statuses", userURL) - - userURI := fmt.Sprintf("%s/users/%s", hostURL, username) - statusesURI := fmt.Sprintf("%s/statuses", userURI) - inboxURI := fmt.Sprintf("%s/inbox", userURI) - outboxURI := fmt.Sprintf("%s/outbox", userURI) - followersURI := fmt.Sprintf("%s/followers", userURI) - collectionURI := fmt.Sprintf("%s/collections/featured", userURI) - return &URIs{ - HostURL: hostURL, - UserURL: userURL, - StatusesURL: statusesURL, - - UserURI: userURI, - StatusesURI: statusesURI, - InboxURI: inboxURI, - OutboxURI: outboxURI, - FollowersURI: followersURI, - CollectionURI: collectionURI, - } -} - -// ParseGTSVisFromMastoVis converts a mastodon visibility into its gts equivalent. -func ParseGTSVisFromMastoVis(m mastotypes.Visibility) gtsmodel.Visibility { - switch m { - case mastotypes.VisibilityPublic: - return gtsmodel.VisibilityPublic - case mastotypes.VisibilityUnlisted: - return gtsmodel.VisibilityUnlocked - case mastotypes.VisibilityPrivate: - return gtsmodel.VisibilityFollowersOnly - case mastotypes.VisibilityDirect: - return gtsmodel.VisibilityDirect - } - return "" -} - -// ParseMastoVisFromGTSVis converts a gts visibility into its mastodon equivalent -func ParseMastoVisFromGTSVis(m gtsmodel.Visibility) mastotypes.Visibility { - switch m { - case gtsmodel.VisibilityPublic: - return mastotypes.VisibilityPublic - case gtsmodel.VisibilityUnlocked: - return mastotypes.VisibilityUnlisted - case gtsmodel.VisibilityFollowersOnly, gtsmodel.VisibilityMutualsOnly: - return mastotypes.VisibilityPrivate - case gtsmodel.VisibilityDirect: - return mastotypes.VisibilityDirect - } - return "" -} diff --git a/internal/util/regexes.go b/internal/util/regexes.go index 60b397d..a59bd67 100644 --- a/internal/util/regexes.go +++ b/internal/util/regexes.go @@ -18,19 +18,78 @@ package util -import "regexp" +import ( + "fmt" + "regexp" +) + +const ( + minimumPasswordEntropy = 60 // dictates password strength. See https://github.com/wagslane/go-password-validator + minimumReasonLength = 40 + maximumReasonLength = 500 + maximumEmailLength = 256 + maximumUsernameLength = 64 + maximumPasswordLength = 64 + maximumEmojiShortcodeLength = 30 + maximumHashtagLength = 30 +) var ( // mention regex can be played around with here: https://regex101.com/r/qwM9D3/1 - mentionRegexString = `(?: |^|\W)(@[a-zA-Z0-9_]+(?:@[a-zA-Z0-9_\-\.]+)?)(?: |\n)` - mentionRegex = regexp.MustCompile(mentionRegexString) + mentionFinderRegexString = `(?: |^|\W)(@[a-zA-Z0-9_]+(?:@[a-zA-Z0-9_\-\.]+)?)(?: |\n)` + mentionFinderRegex = regexp.MustCompile(mentionFinderRegexString) + // hashtag regex can be played with here: https://regex101.com/r/Vhy8pg/1 - hashtagRegexString = `(?: |^|\W)?#([a-zA-Z0-9]{1,30})(?:\b|\r)` - hashtagRegex = regexp.MustCompile(hashtagRegexString) - // emoji regex can be played with here: https://regex101.com/r/478XGM/1 - emojiRegexString = `(?: |^|\W)?:([a-zA-Z0-9_]{2,30}):(?:\b|\r)?` - emojiRegex = regexp.MustCompile(emojiRegexString) + hashtagFinderRegexString = fmt.Sprintf(`(?: |^|\W)?#([a-zA-Z0-9]{1,%d})(?:\b|\r)`, maximumHashtagLength) + hashtagFinderRegex = regexp.MustCompile(hashtagFinderRegexString) + // emoji shortcode regex can be played with here: https://regex101.com/r/zMDRaG/1 - emojiShortcodeString = `^[a-z0-9_]{2,30}$` - emojiShortcodeRegex = regexp.MustCompile(emojiShortcodeString) + emojiShortcodeRegexString = fmt.Sprintf(`[a-z0-9_]{2,%d}`, maximumEmojiShortcodeLength) + emojiShortcodeValidationRegex = regexp.MustCompile(fmt.Sprintf("^%s$", emojiShortcodeRegexString)) + + // emoji regex can be played with here: https://regex101.com/r/478XGM/1 + emojiFinderRegexString = fmt.Sprintf(`(?: |^|\W)?:(%s):(?:\b|\r)?`, emojiShortcodeRegexString) + emojiFinderRegex = regexp.MustCompile(emojiFinderRegexString) + + // usernameRegexString defines an acceptable username on this instance + usernameRegexString = fmt.Sprintf(`[a-z0-9_]{2,%d}`, maximumUsernameLength) + // usernameValidationRegex can be used to validate usernames of new signups + usernameValidationRegex = regexp.MustCompile(fmt.Sprintf(`^%s$`, usernameRegexString)) + + userPathRegexString = fmt.Sprintf(`^?/%s/(%s)$`, UsersPath, usernameRegexString) + // userPathRegex parses a path that validates and captures the username part from eg /users/example_username + userPathRegex = regexp.MustCompile(userPathRegexString) + + inboxPathRegexString = fmt.Sprintf(`^/?%s/(%s)/%s$`, UsersPath, usernameRegexString, InboxPath) + // inboxPathRegex parses a path that validates and captures the username part from eg /users/example_username/inbox + inboxPathRegex = regexp.MustCompile(inboxPathRegexString) + + outboxPathRegexString = fmt.Sprintf(`^/?%s/(%s)/%s$`, UsersPath, usernameRegexString, OutboxPath) + // outboxPathRegex parses a path that validates and captures the username part from eg /users/example_username/outbox + outboxPathRegex = regexp.MustCompile(outboxPathRegexString) + + actorPathRegexString = fmt.Sprintf(`^?/%s/(%s)$`, ActorsPath, usernameRegexString) + // actorPathRegex parses a path that validates and captures the username part from eg /actors/example_username + actorPathRegex = regexp.MustCompile(actorPathRegexString) + + followersPathRegexString = fmt.Sprintf(`^/?%s/(%s)/%s$`, UsersPath, usernameRegexString, FollowersPath) + // followersPathRegex parses a path that validates and captures the username part from eg /users/example_username/followers + followersPathRegex = regexp.MustCompile(followersPathRegexString) + + followingPathRegexString = fmt.Sprintf(`^/?%s/(%s)/%s$`, UsersPath, usernameRegexString, FollowingPath) + // followingPathRegex parses a path that validates and captures the username part from eg /users/example_username/following + followingPathRegex = regexp.MustCompile(followingPathRegexString) + + likedPathRegexString = fmt.Sprintf(`^/?%s/%s/%s$`, UsersPath, usernameRegexString, LikedPath) + // followingPathRegex parses a path that validates and captures the username part from eg /users/example_username/liked + likedPathRegex = regexp.MustCompile(likedPathRegexString) + + // see https://ihateregex.io/expr/uuid/ + uuidRegexString = `[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}` + + statusesPathRegexString = fmt.Sprintf(`^/?%s/(%s)/%s/(%s)$`, UsersPath, usernameRegexString, StatusesPath, uuidRegexString) + // statusesPathRegex parses a path that validates and captures the username part and the uuid part + // from eg /users/example_username/statuses/123e4567-e89b-12d3-a456-426655440000. + // The regex can be played with here: https://regex101.com/r/G9zuxQ/1 + statusesPathRegex = regexp.MustCompile(statusesPathRegexString) ) diff --git a/internal/util/status.go b/internal/util/statustools.go similarity index 84% rename from internal/util/status.go rename to internal/util/statustools.go index e4b3ec6..5591f18 100644 --- a/internal/util/status.go +++ b/internal/util/statustools.go @@ -31,10 +31,10 @@ import ( // The case of the returned mentions will be lowered, for consistency. func DeriveMentions(status string) []string { mentionedAccounts := []string{} - for _, m := range mentionRegex.FindAllStringSubmatch(status, -1) { + for _, m := range mentionFinderRegex.FindAllStringSubmatch(status, -1) { mentionedAccounts = append(mentionedAccounts, m[1]) } - return Lower(Unique(mentionedAccounts)) + return lower(unique(mentionedAccounts)) } // DeriveHashtags takes a plaintext (ie., not html-formatted) status, @@ -43,10 +43,10 @@ func DeriveMentions(status string) []string { // tags will be lowered, for consistency. func DeriveHashtags(status string) []string { tags := []string{} - for _, m := range hashtagRegex.FindAllStringSubmatch(status, -1) { + for _, m := range hashtagFinderRegex.FindAllStringSubmatch(status, -1) { tags = append(tags, m[1]) } - return Lower(Unique(tags)) + return lower(unique(tags)) } // DeriveEmojis takes a plaintext (ie., not html-formatted) status, @@ -55,14 +55,14 @@ func DeriveHashtags(status string) []string { // emojis will be lowered, for consistency. func DeriveEmojis(status string) []string { emojis := []string{} - for _, m := range emojiRegex.FindAllStringSubmatch(status, -1) { + for _, m := range emojiFinderRegex.FindAllStringSubmatch(status, -1) { emojis = append(emojis, m[1]) } - return Lower(Unique(emojis)) + return lower(unique(emojis)) } -// Unique returns a deduplicated version of a given string slice. -func Unique(s []string) []string { +// unique returns a deduplicated version of a given string slice. +func unique(s []string) []string { keys := make(map[string]bool) list := []string{} for _, entry := range s { @@ -74,8 +74,8 @@ func Unique(s []string) []string { return list } -// Lower lowercases all strings in a given string slice -func Lower(s []string) []string { +// lower lowercases all strings in a given string slice +func lower(s []string) []string { new := []string{} for _, i := range s { new = append(new, strings.ToLower(i)) diff --git a/internal/util/status_test.go b/internal/util/statustools_test.go similarity index 91% rename from internal/util/status_test.go rename to internal/util/statustools_test.go index 72bd3e8..7c9af2c 100644 --- a/internal/util/status_test.go +++ b/internal/util/statustools_test.go @@ -16,13 +16,14 @@ along with this program. If not, see . */ -package util +package util_test import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/util" ) type StatusTestSuite struct { @@ -41,7 +42,7 @@ func (suite *StatusTestSuite) TestDeriveMentionsOK() { here is a duplicate mention: @hello@test.lgbt ` - menchies := DeriveMentions(statusText) + menchies := util.DeriveMentions(statusText) assert.Len(suite.T(), menchies, 4) assert.Equal(suite.T(), "@dumpsterqueer@example.org", menchies[0]) assert.Equal(suite.T(), "@someone_else@testing.best-horse.com", menchies[1]) @@ -51,7 +52,7 @@ func (suite *StatusTestSuite) TestDeriveMentionsOK() { func (suite *StatusTestSuite) TestDeriveMentionsEmpty() { statusText := `` - menchies := DeriveMentions(statusText) + menchies := util.DeriveMentions(statusText) assert.Len(suite.T(), menchies, 0) } @@ -66,7 +67,7 @@ func (suite *StatusTestSuite) TestDeriveHashtagsOK() { #111111 thisalsoshouldn'twork#### ##` - tags := DeriveHashtags(statusText) + tags := util.DeriveHashtags(statusText) assert.Len(suite.T(), tags, 5) assert.Equal(suite.T(), "testing123", tags[0]) assert.Equal(suite.T(), "also", tags[1]) @@ -89,7 +90,7 @@ Here's some normal text with an :emoji: at the end :underscores_ok_too: ` - tags := DeriveEmojis(statusText) + tags := util.DeriveEmojis(statusText) assert.Len(suite.T(), tags, 7) assert.Equal(suite.T(), "test", tags[0]) assert.Equal(suite.T(), "another", tags[1]) diff --git a/internal/util/uri.go b/internal/util/uri.go new file mode 100644 index 0000000..9b96edc --- /dev/null +++ b/internal/util/uri.go @@ -0,0 +1,218 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package util + +import ( + "fmt" + "net/url" + "strings" +) + +const ( + // UsersPath is for serving users info + UsersPath = "users" + // ActorsPath is for serving actors info + ActorsPath = "actors" + // StatusesPath is for serving statuses + StatusesPath = "statuses" + // InboxPath represents the webfinger inbox location + InboxPath = "inbox" + // OutboxPath represents the webfinger outbox location + OutboxPath = "outbox" + // FollowersPath represents the webfinger followers location + FollowersPath = "followers" + // FollowingPath represents the webfinger following location + FollowingPath = "following" + // LikedPath represents the webfinger liked location + LikedPath = "liked" + // CollectionsPath represents the webfinger collections location + CollectionsPath = "collections" + // FeaturedPath represents the webfinger featured location + FeaturedPath = "featured" + // PublicKeyPath is for serving an account's public key + PublicKeyPath = "publickey" +) + +// APContextKey is a type used specifically for settings values on contexts within go-fed AP request chains +type APContextKey string + +const ( + // APActivity can be used to set and retrieve the actual go-fed pub.Activity within a context. + APActivity APContextKey = "activity" + // APAccount can be used the set and retrieve the account being interacted with + APAccount APContextKey = "account" + // APRequestingAccount can be used to set and retrieve the account of an incoming federation request. + APRequestingAccount APContextKey = "requestingAccount" + // APRequestingPublicKeyID can be used to set and retrieve the public key ID of an incoming federation request. + APRequestingPublicKeyID APContextKey = "requestingPublicKeyID" +) + +type ginContextKey struct{} + +// GinContextKey is used solely for setting and retrieving the gin context from a context.Context +var GinContextKey = &ginContextKey{} + +// UserURIs contains a bunch of UserURIs and URLs for a user, host, account, etc. +type UserURIs struct { + // The web URL of the instance host, eg https://example.org + HostURL string + // The web URL of the user, eg., https://example.org/@example_user + UserURL string + // The web URL for statuses of this user, eg., https://example.org/@example_user/statuses + StatusesURL string + + // The webfinger URI of this user, eg., https://example.org/users/example_user + UserURI string + // The webfinger URI for this user's statuses, eg., https://example.org/users/example_user/statuses + StatusesURI string + // The webfinger URI for this user's activitypub inbox, eg., https://example.org/users/example_user/inbox + InboxURI string + // The webfinger URI for this user's activitypub outbox, eg., https://example.org/users/example_user/outbox + OutboxURI string + // The webfinger URI for this user's followers, eg., https://example.org/users/example_user/followers + FollowersURI string + // The webfinger URI for this user's following, eg., https://example.org/users/example_user/following + FollowingURI string + // The webfinger URI for this user's liked posts eg., https://example.org/users/example_user/liked + LikedURI string + // The webfinger URI for this user's featured collections, eg., https://example.org/users/example_user/collections/featured + CollectionURI string + // The URI for this user's public key, eg., https://example.org/users/example_user/publickey + PublicKeyURI string +} + +// GenerateURIsForAccount throws together a bunch of URIs for the given username, with the given protocol and host. +func GenerateURIsForAccount(username string, protocol string, host string) *UserURIs { + // The below URLs are used for serving web requests + hostURL := fmt.Sprintf("%s://%s", protocol, host) + userURL := fmt.Sprintf("%s/@%s", hostURL, username) + statusesURL := fmt.Sprintf("%s/%s", userURL, StatusesPath) + + // the below URIs are used in ActivityPub and Webfinger + userURI := fmt.Sprintf("%s/%s/%s", hostURL, UsersPath, username) + statusesURI := fmt.Sprintf("%s/%s", userURI, StatusesPath) + inboxURI := fmt.Sprintf("%s/%s", userURI, InboxPath) + outboxURI := fmt.Sprintf("%s/%s", userURI, OutboxPath) + followersURI := fmt.Sprintf("%s/%s", userURI, FollowersPath) + followingURI := fmt.Sprintf("%s/%s", userURI, FollowingPath) + likedURI := fmt.Sprintf("%s/%s", userURI, LikedPath) + collectionURI := fmt.Sprintf("%s/%s/%s", userURI, CollectionsPath, FeaturedPath) + publicKeyURI := fmt.Sprintf("%s/%s", userURI, PublicKeyPath) + + return &UserURIs{ + HostURL: hostURL, + UserURL: userURL, + StatusesURL: statusesURL, + + UserURI: userURI, + StatusesURI: statusesURI, + InboxURI: inboxURI, + OutboxURI: outboxURI, + FollowersURI: followersURI, + FollowingURI: followingURI, + LikedURI: likedURI, + CollectionURI: collectionURI, + PublicKeyURI: publicKeyURI, + } +} + +// IsUserPath returns true if the given URL path corresponds to eg /users/example_username +func IsUserPath(id *url.URL) bool { + return userPathRegex.MatchString(strings.ToLower(id.Path)) +} + +// IsInboxPath returns true if the given URL path corresponds to eg /users/example_username/inbox +func IsInboxPath(id *url.URL) bool { + return inboxPathRegex.MatchString(strings.ToLower(id.Path)) +} + +// IsOutboxPath returns true if the given URL path corresponds to eg /users/example_username/outbox +func IsOutboxPath(id *url.URL) bool { + return outboxPathRegex.MatchString(strings.ToLower(id.Path)) +} + +// IsInstanceActorPath returns true if the given URL path corresponds to eg /actors/example_username +func IsInstanceActorPath(id *url.URL) bool { + return actorPathRegex.MatchString(strings.ToLower(id.Path)) +} + +// IsFollowersPath returns true if the given URL path corresponds to eg /users/example_username/followers +func IsFollowersPath(id *url.URL) bool { + return followersPathRegex.MatchString(strings.ToLower(id.Path)) +} + +// IsFollowingPath returns true if the given URL path corresponds to eg /users/example_username/following +func IsFollowingPath(id *url.URL) bool { + return followingPathRegex.MatchString(strings.ToLower(id.Path)) +} + +// IsLikedPath returns true if the given URL path corresponds to eg /users/example_username/liked +func IsLikedPath(id *url.URL) bool { + return likedPathRegex.MatchString(strings.ToLower(id.Path)) +} + +// IsStatusesPath returns true if the given URL path corresponds to eg /users/example_username/statuses/SOME_UUID_OF_A_STATUS +func IsStatusesPath(id *url.URL) bool { + return statusesPathRegex.MatchString(strings.ToLower(id.Path)) +} + +// ParseStatusesPath returns the username and uuid from a path such as /users/example_username/statuses/SOME_UUID_OF_A_STATUS +func ParseStatusesPath(id *url.URL) (username string, uuid string, err error) { + matches := statusesPathRegex.FindStringSubmatch(id.Path) + if len(matches) != 3 { + err = fmt.Errorf("expected 3 matches but matches length was %d", len(matches)) + return + } + username = matches[1] + uuid = matches[2] + return +} + +// ParseUserPath returns the username from a path such as /users/example_username +func ParseUserPath(id *url.URL) (username string, err error) { + matches := userPathRegex.FindStringSubmatch(id.Path) + if len(matches) != 2 { + err = fmt.Errorf("expected 2 matches but matches length was %d", len(matches)) + return + } + username = matches[1] + return +} + +// ParseInboxPath returns the username from a path such as /users/example_username/inbox +func ParseInboxPath(id *url.URL) (username string, err error) { + matches := inboxPathRegex.FindStringSubmatch(id.Path) + if len(matches) != 2 { + err = fmt.Errorf("expected 2 matches but matches length was %d", len(matches)) + return + } + username = matches[1] + return +} + +// ParseOutboxPath returns the username from a path such as /users/example_username/outbox +func ParseOutboxPath(id *url.URL) (username string, err error) { + matches := outboxPathRegex.FindStringSubmatch(id.Path) + if len(matches) != 2 { + err = fmt.Errorf("expected 2 matches but matches length was %d", len(matches)) + return + } + username = matches[1] + return +} diff --git a/internal/util/validation.go b/internal/util/validation.go index acf0e68..d392231 100644 --- a/internal/util/validation.go +++ b/internal/util/validation.go @@ -22,45 +22,22 @@ import ( "errors" "fmt" "net/mail" - "regexp" pwv "github.com/wagslane/go-password-validator" "golang.org/x/text/language" ) -const ( - // MinimumPasswordEntropy dictates password strength. See https://github.com/wagslane/go-password-validator - MinimumPasswordEntropy = 60 - // MinimumReasonLength is the length of chars we expect as a bare minimum effort - MinimumReasonLength = 40 - // MaximumReasonLength is the maximum amount of chars we're happy to accept - MaximumReasonLength = 500 - // MaximumEmailLength is the maximum length of an email address we're happy to accept - MaximumEmailLength = 256 - // MaximumUsernameLength is the maximum length of a username we're happy to accept - MaximumUsernameLength = 64 - // MaximumPasswordLength is the maximum length of a password we're happy to accept - MaximumPasswordLength = 64 - // NewUsernameRegexString is string representation of the regular expression for validating usernames - NewUsernameRegexString = `^[a-z0-9_]+$` -) - -var ( - // NewUsernameRegex is the compiled regex for validating new usernames - NewUsernameRegex = regexp.MustCompile(NewUsernameRegexString) -) - // ValidateNewPassword returns an error if the given password is not sufficiently strong, or nil if it's ok. func ValidateNewPassword(password string) error { if password == "" { return errors.New("no password provided") } - if len(password) > MaximumPasswordLength { - return fmt.Errorf("password should be no more than %d chars", MaximumPasswordLength) + if len(password) > maximumPasswordLength { + return fmt.Errorf("password should be no more than %d chars", maximumPasswordLength) } - return pwv.Validate(password, MinimumPasswordEntropy) + return pwv.Validate(password, minimumPasswordEntropy) } // ValidateUsername makes sure that a given username is valid (ie., letters, numbers, underscores, check length). @@ -70,11 +47,11 @@ func ValidateUsername(username string) error { return errors.New("no username provided") } - if len(username) > MaximumUsernameLength { - return fmt.Errorf("username should be no more than %d chars but '%s' was %d", MaximumUsernameLength, username, len(username)) + if len(username) > maximumUsernameLength { + return fmt.Errorf("username should be no more than %d chars but '%s' was %d", maximumUsernameLength, username, len(username)) } - if !NewUsernameRegex.MatchString(username) { + if !usernameValidationRegex.MatchString(username) { return fmt.Errorf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores", username) } @@ -88,8 +65,8 @@ func ValidateEmail(email string) error { return errors.New("no email provided") } - if len(email) > MaximumEmailLength { - return fmt.Errorf("email address should be no more than %d chars but '%s' was %d", MaximumEmailLength, email, len(email)) + if len(email) > maximumEmailLength { + return fmt.Errorf("email address should be no more than %d chars but '%s' was %d", maximumEmailLength, email, len(email)) } _, err := mail.ParseAddress(email) @@ -118,12 +95,12 @@ func ValidateSignUpReason(reason string, reasonRequired bool) error { return errors.New("no reason provided") } - if len(reason) < MinimumReasonLength { - return fmt.Errorf("reason should be at least %d chars but '%s' was %d", MinimumReasonLength, reason, len(reason)) + if len(reason) < minimumReasonLength { + return fmt.Errorf("reason should be at least %d chars but '%s' was %d", minimumReasonLength, reason, len(reason)) } - if len(reason) > MaximumReasonLength { - return fmt.Errorf("reason should be no more than %d chars but given reason was %d", MaximumReasonLength, len(reason)) + if len(reason) > maximumReasonLength { + return fmt.Errorf("reason should be no more than %d chars but given reason was %d", maximumReasonLength, len(reason)) } return nil } @@ -150,7 +127,7 @@ func ValidatePrivacy(privacy string) error { // for emoji shortcodes, to figure out whether it's a valid shortcode, ie., 2-30 characters, // lowercase a-z, numbers, and underscores. func ValidateEmojiShortcode(shortcode string) error { - if !emojiShortcodeRegex.MatchString(shortcode) { + if !emojiShortcodeValidationRegex.MatchString(shortcode) { return fmt.Errorf("shortcode %s did not pass validation, must be between 2 and 30 characters, lowercase letters, numbers, and underscores only", shortcode) } return nil diff --git a/internal/util/validation_test.go b/internal/util/validation_test.go index dbac5e2..73f5cb9 100644 --- a/internal/util/validation_test.go +++ b/internal/util/validation_test.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package util +package util_test import ( "errors" @@ -25,6 +25,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/util" ) type ValidationTestSuite struct { @@ -42,42 +43,42 @@ func (suite *ValidationTestSuite) TestCheckPasswordStrength() { strongPassword := "3dX5@Zc%mV*W2MBNEy$@" var err error - err = ValidateNewPassword(empty) + err = util.ValidateNewPassword(empty) if assert.Error(suite.T(), err) { assert.Equal(suite.T(), errors.New("no password provided"), err) } - err = ValidateNewPassword(terriblePassword) + err = util.ValidateNewPassword(terriblePassword) if assert.Error(suite.T(), err) { assert.Equal(suite.T(), errors.New("insecure password, try including more special characters, using uppercase letters, using numbers or using a longer password"), err) } - err = ValidateNewPassword(weakPassword) + err = util.ValidateNewPassword(weakPassword) if assert.Error(suite.T(), err) { assert.Equal(suite.T(), errors.New("insecure password, try including more special characters, using numbers or using a longer password"), err) } - err = ValidateNewPassword(shortPassword) + err = util.ValidateNewPassword(shortPassword) if assert.Error(suite.T(), err) { assert.Equal(suite.T(), errors.New("insecure password, try including more special characters or using a longer password"), err) } - err = ValidateNewPassword(specialPassword) + err = util.ValidateNewPassword(specialPassword) if assert.Error(suite.T(), err) { assert.Equal(suite.T(), errors.New("insecure password, try including more special characters or using a longer password"), err) } - err = ValidateNewPassword(longPassword) + err = util.ValidateNewPassword(longPassword) if assert.NoError(suite.T(), err) { assert.Equal(suite.T(), nil, err) } - err = ValidateNewPassword(tooLong) + err = util.ValidateNewPassword(tooLong) if assert.Error(suite.T(), err) { assert.Equal(suite.T(), errors.New("password should be no more than 64 chars"), err) } - err = ValidateNewPassword(strongPassword) + err = util.ValidateNewPassword(strongPassword) if assert.NoError(suite.T(), err) { assert.Equal(suite.T(), nil, err) } @@ -94,42 +95,42 @@ func (suite *ValidationTestSuite) TestValidateUsername() { goodUsername := "this_is_a_good_username" var err error - err = ValidateUsername(empty) + err = util.ValidateUsername(empty) if assert.Error(suite.T(), err) { assert.Equal(suite.T(), errors.New("no username provided"), err) } - err = ValidateUsername(tooLong) + err = util.ValidateUsername(tooLong) if assert.Error(suite.T(), err) { assert.Equal(suite.T(), fmt.Errorf("username should be no more than 64 chars but '%s' was 66", tooLong), err) } - err = ValidateUsername(withSpaces) + err = util.ValidateUsername(withSpaces) if assert.Error(suite.T(), err) { assert.Equal(suite.T(), fmt.Errorf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores", withSpaces), err) } - err = ValidateUsername(weirdChars) + err = util.ValidateUsername(weirdChars) if assert.Error(suite.T(), err) { assert.Equal(suite.T(), fmt.Errorf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores", weirdChars), err) } - err = ValidateUsername(leadingSpace) + err = util.ValidateUsername(leadingSpace) if assert.Error(suite.T(), err) { assert.Equal(suite.T(), fmt.Errorf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores", leadingSpace), err) } - err = ValidateUsername(trailingSpace) + err = util.ValidateUsername(trailingSpace) if assert.Error(suite.T(), err) { assert.Equal(suite.T(), fmt.Errorf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores", trailingSpace), err) } - err = ValidateUsername(newlines) + err = util.ValidateUsername(newlines) if assert.Error(suite.T(), err) { assert.Equal(suite.T(), fmt.Errorf("given username %s was invalid: must contain only lowercase letters, numbers, and underscores", newlines), err) } - err = ValidateUsername(goodUsername) + err = util.ValidateUsername(goodUsername) if assert.NoError(suite.T(), err) { assert.Equal(suite.T(), nil, err) } @@ -144,32 +145,32 @@ func (suite *ValidationTestSuite) TestValidateEmail() { emailAddress := "thisis.actually@anemail.address" var err error - err = ValidateEmail(empty) + err = util.ValidateEmail(empty) if assert.Error(suite.T(), err) { assert.Equal(suite.T(), errors.New("no email provided"), err) } - err = ValidateEmail(notAnEmailAddress) + err = util.ValidateEmail(notAnEmailAddress) if assert.Error(suite.T(), err) { assert.Equal(suite.T(), errors.New("mail: missing '@' or angle-addr"), err) } - err = ValidateEmail(almostAnEmailAddress) + err = util.ValidateEmail(almostAnEmailAddress) if assert.Error(suite.T(), err) { assert.Equal(suite.T(), errors.New("mail: no angle-addr"), err) } - err = ValidateEmail(aWebsite) + err = util.ValidateEmail(aWebsite) if assert.Error(suite.T(), err) { assert.Equal(suite.T(), errors.New("mail: missing '@' or angle-addr"), err) } - err = ValidateEmail(tooLong) + err = util.ValidateEmail(tooLong) if assert.Error(suite.T(), err) { assert.Equal(suite.T(), fmt.Errorf("email address should be no more than 256 chars but '%s' was 286", tooLong), err) } - err = ValidateEmail(emailAddress) + err = util.ValidateEmail(emailAddress) if assert.NoError(suite.T(), err) { assert.Equal(suite.T(), nil, err) } @@ -187,47 +188,47 @@ func (suite *ValidationTestSuite) TestValidateLanguage() { german := "de" var err error - err = ValidateLanguage(empty) + err = util.ValidateLanguage(empty) if assert.Error(suite.T(), err) { assert.Equal(suite.T(), errors.New("no language provided"), err) } - err = ValidateLanguage(notALanguage) + err = util.ValidateLanguage(notALanguage) if assert.Error(suite.T(), err) { assert.Equal(suite.T(), errors.New("language: tag is not well-formed"), err) } - err = ValidateLanguage(english) + err = util.ValidateLanguage(english) if assert.NoError(suite.T(), err) { assert.Equal(suite.T(), nil, err) } - err = ValidateLanguage(capitalEnglish) + err = util.ValidateLanguage(capitalEnglish) if assert.NoError(suite.T(), err) { assert.Equal(suite.T(), nil, err) } - err = ValidateLanguage(arabic3Letters) + err = util.ValidateLanguage(arabic3Letters) if assert.NoError(suite.T(), err) { assert.Equal(suite.T(), nil, err) } - err = ValidateLanguage(mixedCapsEnglish) + err = util.ValidateLanguage(mixedCapsEnglish) if assert.NoError(suite.T(), err) { assert.Equal(suite.T(), nil, err) } - err = ValidateLanguage(englishUS) + err = util.ValidateLanguage(englishUS) if assert.Error(suite.T(), err) { assert.Equal(suite.T(), errors.New("language: tag is not well-formed"), err) } - err = ValidateLanguage(dutch) + err = util.ValidateLanguage(dutch) if assert.NoError(suite.T(), err) { assert.Equal(suite.T(), nil, err) } - err = ValidateLanguage(german) + err = util.ValidateLanguage(german) if assert.NoError(suite.T(), err) { assert.Equal(suite.T(), nil, err) } @@ -241,43 +242,43 @@ func (suite *ValidationTestSuite) TestValidateReason() { var err error // check with no reason required - err = ValidateSignUpReason(empty, false) + err = util.ValidateSignUpReason(empty, false) if assert.NoError(suite.T(), err) { assert.Equal(suite.T(), nil, err) } - err = ValidateSignUpReason(badReason, false) + err = util.ValidateSignUpReason(badReason, false) if assert.NoError(suite.T(), err) { assert.Equal(suite.T(), nil, err) } - err = ValidateSignUpReason(tooLong, false) + err = util.ValidateSignUpReason(tooLong, false) if assert.NoError(suite.T(), err) { assert.Equal(suite.T(), nil, err) } - err = ValidateSignUpReason(goodReason, false) + err = util.ValidateSignUpReason(goodReason, false) if assert.NoError(suite.T(), err) { assert.Equal(suite.T(), nil, err) } // check with reason required - err = ValidateSignUpReason(empty, true) + err = util.ValidateSignUpReason(empty, true) if assert.Error(suite.T(), err) { assert.Equal(suite.T(), errors.New("no reason provided"), err) } - err = ValidateSignUpReason(badReason, true) + err = util.ValidateSignUpReason(badReason, true) if assert.Error(suite.T(), err) { assert.Equal(suite.T(), errors.New("reason should be at least 40 chars but 'because' was 7"), err) } - err = ValidateSignUpReason(tooLong, true) + err = util.ValidateSignUpReason(tooLong, true) if assert.Error(suite.T(), err) { assert.Equal(suite.T(), errors.New("reason should be no more than 500 chars but given reason was 600"), err) } - err = ValidateSignUpReason(goodReason, true) + err = util.ValidateSignUpReason(goodReason, true) if assert.NoError(suite.T(), err) { assert.Equal(suite.T(), nil, err) } diff --git a/testrig/actions.go b/testrig/actions.go index 1caa185..7ed75b1 100644 --- a/testrig/actions.go +++ b/testrig/actions.go @@ -19,24 +19,26 @@ package testrig import ( + "bytes" "context" "fmt" + "io/ioutil" + "net/http" "os" "os/signal" "syscall" "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/action" - "github.com/superseriousbusiness/gotosocial/internal/apimodule" - "github.com/superseriousbusiness/gotosocial/internal/apimodule/account" - "github.com/superseriousbusiness/gotosocial/internal/apimodule/admin" - "github.com/superseriousbusiness/gotosocial/internal/apimodule/app" - "github.com/superseriousbusiness/gotosocial/internal/apimodule/auth" - "github.com/superseriousbusiness/gotosocial/internal/apimodule/fileserver" - mediaModule "github.com/superseriousbusiness/gotosocial/internal/apimodule/media" - "github.com/superseriousbusiness/gotosocial/internal/apimodule/security" - "github.com/superseriousbusiness/gotosocial/internal/apimodule/status" - "github.com/superseriousbusiness/gotosocial/internal/cache" + "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/api/client/account" + "github.com/superseriousbusiness/gotosocial/internal/api/client/admin" + "github.com/superseriousbusiness/gotosocial/internal/api/client/app" + "github.com/superseriousbusiness/gotosocial/internal/api/client/auth" + "github.com/superseriousbusiness/gotosocial/internal/api/client/fileserver" + mediaModule "github.com/superseriousbusiness/gotosocial/internal/api/client/media" + "github.com/superseriousbusiness/gotosocial/internal/api/client/status" + "github.com/superseriousbusiness/gotosocial/internal/api/security" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/federation" "github.com/superseriousbusiness/gotosocial/internal/gotosocial" @@ -44,33 +46,39 @@ import ( // Run creates and starts a gotosocial testrig server var Run action.GTSAction = func(ctx context.Context, _ *config.Config, log *logrus.Logger) error { + c := NewTestConfig() dbService := NewTestDB() router := NewTestRouter() storageBackend := NewTestStorage() - mediaHandler := NewTestMediaHandler(dbService, storageBackend) - oauthServer := NewTestOauthServer(dbService) - distributor := NewTestDistributor() - if err := distributor.Start(); err != nil { - return fmt.Errorf("error starting distributor: %s", err) - } - mastoConverter := NewTestMastoConverter(dbService) - c := NewTestConfig() + typeConverter := NewTestTypeConverter(dbService) + transportController := NewTestTransportController(NewMockHTTPClient(func(req *http.Request) (*http.Response, error) { + r := ioutil.NopCloser(bytes.NewReader([]byte{})) + return &http.Response{ + StatusCode: 200, + Body: r, + }, nil + })) + federator := federation.NewFederator(dbService, transportController, c, log, typeConverter) + processor := NewTestProcessor(dbService, storageBackend, federator) + if err := processor.Start(); err != nil { + return fmt.Errorf("error starting processor: %s", err) + } StandardDBSetup(dbService) StandardStorageSetup(storageBackend, "./testrig/media") // build client api modules - authModule := auth.New(oauthServer, dbService, log) - accountModule := account.New(c, dbService, oauthServer, mediaHandler, mastoConverter, log) - appsModule := app.New(oauthServer, dbService, mastoConverter, log) - mm := mediaModule.New(dbService, mediaHandler, mastoConverter, c, log) - fileServerModule := fileserver.New(c, dbService, storageBackend, log) - adminModule := admin.New(c, dbService, mediaHandler, mastoConverter, log) - statusModule := status.New(c, dbService, mediaHandler, mastoConverter, distributor, log) + authModule := auth.New(c, dbService, NewTestOauthServer(dbService), log) + accountModule := account.New(c, processor, log) + appsModule := app.New(c, processor, log) + mm := mediaModule.New(c, processor, log) + fileServerModule := fileserver.New(c, processor, log) + adminModule := admin.New(c, processor, log) + statusModule := status.New(c, processor, log) securityModule := security.New(c, log) - apiModules := []apimodule.ClientAPIModule{ + apis := []api.ClientModule{ // modules with middleware go first securityModule, authModule, @@ -84,20 +92,13 @@ var Run action.GTSAction = func(ctx context.Context, _ *config.Config, log *logr statusModule, } - for _, m := range apiModules { + for _, m := range apis { if err := m.Route(router); err != nil { return fmt.Errorf("routing error: %s", err) } - if err := m.CreateTables(dbService); err != nil { - return fmt.Errorf("table creation error: %s", err) - } } - // if err := dbService.CreateInstanceAccount(); err != nil { - // return fmt.Errorf("error creating instance account: %s", err) - // } - - gts, err := gotosocial.New(dbService, &cache.MockCache{}, router, federation.New(dbService, log), c) + gts, err := gotosocial.New(dbService, router, federator, c) if err != nil { return fmt.Errorf("error creating gotosocial service: %s", err) } diff --git a/testrig/db.go b/testrig/db.go index 5974eae..4d22ab3 100644 --- a/testrig/db.go +++ b/testrig/db.go @@ -23,7 +23,7 @@ import ( "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -54,7 +54,7 @@ func NewTestDB() db.DB { config := NewTestConfig() l := logrus.New() l.SetLevel(logrus.TraceLevel) - testDB, err := db.New(context.Background(), config, l) + testDB, err := db.NewPostgresService(context.Background(), config, l) if err != nil { panic(err) } diff --git a/testrig/federator.go b/testrig/federator.go new file mode 100644 index 0000000..63ad520 --- /dev/null +++ b/testrig/federator.go @@ -0,0 +1,29 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package testrig + +import ( + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/transport" +) + +func NewTestFederator(db db.DB, tc transport.Controller) federation.Federator { + return federation.NewFederator(db, tc, NewTestConfig(), NewTestLog(), NewTestTypeConverter(db)) +} diff --git a/testrig/media/test-jpeg.jpg b/testrig/media/test-jpeg.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a9ab154d4226809d1a9b276da80dcd86d41f0c5c GIT binary patch literal 269739 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

kJk6ww*=|MHl>#1uV?lt~E37+Miw^!|EgDqo~{ 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

$coDv?IFT?rjP!`P^w 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 literal 0 HcmV?d00001 diff --git a/testrig/processor.go b/testrig/processor.go new file mode 100644 index 0000000..9aa8e25 --- /dev/null +++ b/testrig/processor.go @@ -0,0 +1,31 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package testrig + +import ( + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/message" + "github.com/superseriousbusiness/gotosocial/internal/storage" +) + +// NewTestProcessor returns a Processor suitable for testing purposes +func NewTestProcessor(db db.DB, storage storage.Storage, federator federation.Federator) message.Processor { + return message.NewProcessor(NewTestConfig(), NewTestTypeConverter(db), federator, NewTestOauthServer(db), NewTestMediaHandler(db, storage), storage, db, NewTestLog()) +} diff --git a/testrig/testmodels.go b/testrig/testmodels.go index 0d95ef2..e550c66 100644 --- a/testrig/testmodels.go +++ b/testrig/testmodels.go @@ -19,13 +19,26 @@ package testrig import ( + "bytes" + "context" + "crypto" "crypto/rand" "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "io/ioutil" "net" + "net/http" + "net/url" "time" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + "github.com/go-fed/activity/pub" + "github.com/go-fed/activity/streams" + "github.com/go-fed/activity/streams/vocab" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" ) // NewTestTokens returns a map of tokens keyed according to which account the token belongs to. @@ -274,15 +287,16 @@ func NewTestAccounts() map[string]*gtsmodel.Account { URI: "http://localhost:8080/users/weed_lord420", URL: "http://localhost:8080/@weed_lord420", LastWebfingeredAt: time.Time{}, - InboxURL: "http://localhost:8080/users/weed_lord420/inbox", - OutboxURL: "http://localhost:8080/users/weed_lord420/outbox", - SharedInboxURL: "", - FollowersURL: "http://localhost:8080/users/weed_lord420/followers", - FeaturedCollectionURL: "http://localhost:8080/users/weed_lord420/collections/featured", + InboxURI: "http://localhost:8080/users/weed_lord420/inbox", + OutboxURI: "http://localhost:8080/users/weed_lord420/outbox", + FollowersURI: "http://localhost:8080/users/weed_lord420/followers", + FollowingURI: "http://localhost:8080/users/weed_lord420/following", + FeaturedCollectionURI: "http://localhost:8080/users/weed_lord420/collections/featured", ActorType: gtsmodel.ActivityStreamsPerson, AlsoKnownAs: "", PrivateKey: &rsa.PrivateKey{}, PublicKey: &rsa.PublicKey{}, + PublicKeyURI: "http://localhost:8080/users/weed_lord420#main-key", SensitizedAt: time.Time{}, SilencedAt: time.Time{}, SuspendedAt: time.Time{}, @@ -310,12 +324,13 @@ func NewTestAccounts() map[string]*gtsmodel.Account { Language: "en", URI: "http://localhost:8080/users/admin", URL: "http://localhost:8080/@admin", + PublicKeyURI: "http://localhost:8080/users/admin#main-key", LastWebfingeredAt: time.Time{}, - InboxURL: "http://localhost:8080/users/admin/inbox", - OutboxURL: "http://localhost:8080/users/admin/outbox", - SharedInboxURL: "", - FollowersURL: "http://localhost:8080/users/admin/followers", - FeaturedCollectionURL: "http://localhost:8080/users/admin/collections/featured", + InboxURI: "http://localhost:8080/users/admin/inbox", + OutboxURI: "http://localhost:8080/users/admin/outbox", + FollowersURI: "http://localhost:8080/users/admin/followers", + FollowingURI: "http://localhost:8080/users/admin/following", + FeaturedCollectionURI: "http://localhost:8080/users/admin/collections/featured", ActorType: gtsmodel.ActivityStreamsPerson, AlsoKnownAs: "", PrivateKey: &rsa.PrivateKey{}, @@ -348,15 +363,16 @@ func NewTestAccounts() map[string]*gtsmodel.Account { URI: "http://localhost:8080/users/the_mighty_zork", URL: "http://localhost:8080/@the_mighty_zork", LastWebfingeredAt: time.Time{}, - InboxURL: "http://localhost:8080/users/the_mighty_zork/inbox", - OutboxURL: "http://localhost:8080/users/the_mighty_zork/outbox", - SharedInboxURL: "", - FollowersURL: "http://localhost:8080/users/the_mighty_zork/followers", - FeaturedCollectionURL: "http://localhost:8080/users/the_mighty_zork/collections/featured", + InboxURI: "http://localhost:8080/users/the_mighty_zork/inbox", + OutboxURI: "http://localhost:8080/users/the_mighty_zork/outbox", + FollowersURI: "http://localhost:8080/users/the_mighty_zork/followers", + FollowingURI: "http://localhost:8080/users/the_mighty_zork/following", + FeaturedCollectionURI: "http://localhost:8080/users/the_mighty_zork/collections/featured", ActorType: gtsmodel.ActivityStreamsPerson, AlsoKnownAs: "", PrivateKey: &rsa.PrivateKey{}, PublicKey: &rsa.PublicKey{}, + PublicKeyURI: "http://localhost:8080/users/the_mighty_zork#main-key", SensitizedAt: time.Time{}, SilencedAt: time.Time{}, SuspendedAt: time.Time{}, @@ -385,15 +401,16 @@ func NewTestAccounts() map[string]*gtsmodel.Account { URI: "http://localhost:8080/users/1happyturtle", URL: "http://localhost:8080/@1happyturtle", LastWebfingeredAt: time.Time{}, - InboxURL: "http://localhost:8080/users/1happyturtle/inbox", - OutboxURL: "http://localhost:8080/users/1happyturtle/outbox", - SharedInboxURL: "", - FollowersURL: "http://localhost:8080/users/1happyturtle/followers", - FeaturedCollectionURL: "http://localhost:8080/users/1happyturtle/collections/featured", + InboxURI: "http://localhost:8080/users/1happyturtle/inbox", + OutboxURI: "http://localhost:8080/users/1happyturtle/outbox", + FollowersURI: "http://localhost:8080/users/1happyturtle/followers", + FollowingURI: "http://localhost:8080/users/1happyturtle/following", + FeaturedCollectionURI: "http://localhost:8080/users/1happyturtle/collections/featured", ActorType: gtsmodel.ActivityStreamsPerson, AlsoKnownAs: "", PrivateKey: &rsa.PrivateKey{}, PublicKey: &rsa.PublicKey{}, + PublicKeyURI: "http://localhost:8080/users/1happyturtle#main-key", SensitizedAt: time.Time{}, SilencedAt: time.Time{}, SuspendedAt: time.Time{}, @@ -426,18 +443,19 @@ func NewTestAccounts() map[string]*gtsmodel.Account { Discoverable: true, Sensitive: false, Language: "en", - URI: "https://fossbros-anonymous.io/users/foss_satan", - URL: "https://fossbros-anonymous.io/@foss_satan", + URI: "http://fossbros-anonymous.io/users/foss_satan", + URL: "http://fossbros-anonymous.io/@foss_satan", LastWebfingeredAt: time.Time{}, - InboxURL: "https://fossbros-anonymous.io/users/foss_satan/inbox", - OutboxURL: "https://fossbros-anonymous.io/users/foss_satan/outbox", - SharedInboxURL: "", - FollowersURL: "https://fossbros-anonymous.io/users/foss_satan/followers", - FeaturedCollectionURL: "https://fossbros-anonymous.io/users/foss_satan/collections/featured", + InboxURI: "http://fossbros-anonymous.io/users/foss_satan/inbox", + OutboxURI: "http://fossbros-anonymous.io/users/foss_satan/outbox", + FollowersURI: "http://fossbros-anonymous.io/users/foss_satan/followers", + FollowingURI: "http://fossbros-anonymous.io/users/foss_satan/following", + FeaturedCollectionURI: "http://fossbros-anonymous.io/users/foss_satan/collections/featured", ActorType: gtsmodel.ActivityStreamsPerson, AlsoKnownAs: "", - PrivateKey: &rsa.PrivateKey{}, - PublicKey: nil, + PrivateKey: nil, + PublicKey: &rsa.PublicKey{}, + PublicKeyURI: "http://fossbros-anonymous.io/users/foss_satan#main-key", SensitizedAt: time.Time{}, SilencedAt: time.Time{}, SuspendedAt: time.Time{}, @@ -468,10 +486,10 @@ func NewTestAccounts() map[string]*gtsmodel.Account { } pub := &priv.PublicKey - // only local accounts get a private key - if v.Domain == "" { - v.PrivateKey = priv - } + // normally only local accounts get a private key (obviously) + // but for testing purposes and signing requests, we'll give + // remote accounts a private key as well + v.PrivateKey = priv v.PublicKey = pub } return accounts @@ -676,25 +694,26 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { func NewTestEmojis() map[string]*gtsmodel.Emoji { return map[string]*gtsmodel.Emoji{ "rainbow": { - ID: "a96ec4f3-1cae-47e4-a508-f9d66a6b221b", - Shortcode: "rainbow", - Domain: "", - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - ImageRemoteURL: "", - ImageStaticRemoteURL: "", - ImageURL: "http://localhost:8080/fileserver/39b745a3-774d-4b65-8bb2-b63d9e20a343/emoji/original/a96ec4f3-1cae-47e4-a508-f9d66a6b221b.png", - ImagePath: "/tmp/gotosocial/39b745a3-774d-4b65-8bb2-b63d9e20a343/emoji/original/a96ec4f3-1cae-47e4-a508-f9d66a6b221b.png", - ImageStaticURL: "http://localhost:8080/fileserver/39b745a3-774d-4b65-8bb2-b63d9e20a343/emoji/static/a96ec4f3-1cae-47e4-a508-f9d66a6b221b.png", - ImageStaticPath: "/tmp/gotosocial/39b745a3-774d-4b65-8bb2-b63d9e20a343/emoji/static/a96ec4f3-1cae-47e4-a508-f9d66a6b221b.png", - ImageContentType: "image/png", - ImageFileSize: 36702, - ImageStaticFileSize: 10413, - ImageUpdatedAt: time.Now(), - Disabled: false, - URI: "http://localhost:8080/emoji/a96ec4f3-1cae-47e4-a508-f9d66a6b221b", - VisibleInPicker: true, - CategoryID: "", + ID: "a96ec4f3-1cae-47e4-a508-f9d66a6b221b", + Shortcode: "rainbow", + Domain: "", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + ImageRemoteURL: "", + ImageStaticRemoteURL: "", + ImageURL: "http://localhost:8080/fileserver/39b745a3-774d-4b65-8bb2-b63d9e20a343/emoji/original/a96ec4f3-1cae-47e4-a508-f9d66a6b221b.png", + ImagePath: "/tmp/gotosocial/39b745a3-774d-4b65-8bb2-b63d9e20a343/emoji/original/a96ec4f3-1cae-47e4-a508-f9d66a6b221b.png", + ImageStaticURL: "http://localhost:8080/fileserver/39b745a3-774d-4b65-8bb2-b63d9e20a343/emoji/static/a96ec4f3-1cae-47e4-a508-f9d66a6b221b.png", + ImageStaticPath: "/tmp/gotosocial/39b745a3-774d-4b65-8bb2-b63d9e20a343/emoji/static/a96ec4f3-1cae-47e4-a508-f9d66a6b221b.png", + ImageContentType: "image/png", + ImageStaticContentType: "image/png", + ImageFileSize: 36702, + ImageStaticFileSize: 10413, + ImageUpdatedAt: time.Now(), + Disabled: false, + URI: "http://localhost:8080/emoji/a96ec4f3-1cae-47e4-a508-f9d66a6b221b", + VisibleInPicker: true, + CategoryID: "", }, } } @@ -993,3 +1012,436 @@ func NewTestFaves() map[string]*gtsmodel.StatusFave { }, } } + +type ActivityWithSignature struct { + Activity pub.Activity + SignatureHeader string + DigestHeader string + DateHeader string +} + +// NewTestActivities returns a bunch of pub.Activity types for use in testing the federation protocols. +// A struct of accounts needs to be passed in because the activities will also be bundled along with +// their requesting signatures. +func NewTestActivities(accounts map[string]*gtsmodel.Account) map[string]ActivityWithSignature { + dmForZork := newNote( + URLMustParse("https://fossbros-anonymous.io/users/foss_satan/statuses/5424b153-4553-4f30-9358-7b92f7cd42f6"), + URLMustParse("https://fossbros-anonymous.io/@foss_satan/5424b153-4553-4f30-9358-7b92f7cd42f6"), + "hey zork here's a new private note for you", + "new note for zork", + URLMustParse("https://fossbros-anonymous.io/users/foss_satan"), + []*url.URL{URLMustParse("http://localhost:8080/users/the_mighty_zork")}, + nil, + true) + createDmForZork := wrapNoteInCreate( + URLMustParse("https://fossbros-anonymous.io/users/foss_satan/statuses/5424b153-4553-4f30-9358-7b92f7cd42f6/activity"), + URLMustParse("https://fossbros-anonymous.io/users/foss_satan"), + time.Now(), + dmForZork) + sig, digest, date := getSignatureForActivity(createDmForZork, accounts["remote_account_1"].PublicKeyURI, accounts["remote_account_1"].PrivateKey, URLMustParse(accounts["local_account_1"].InboxURI)) + + return map[string]ActivityWithSignature{ + "dm_for_zork": { + Activity: createDmForZork, + SignatureHeader: sig, + DigestHeader: digest, + DateHeader: date, + }, + } +} + +// NewTestFediPeople returns a bunch of activity pub Person representations for testing converters and so on. +func NewTestFediPeople() map[string]typeutils.Accountable { + new_person_1priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + panic(err) + } + new_person_1pub := &new_person_1priv.PublicKey + + return map[string]typeutils.Accountable{ + "new_person_1": newPerson( + URLMustParse("https://unknown-instance.com/users/brand_new_person"), + URLMustParse("https://unknown-instance.com/users/brand_new_person/following"), + URLMustParse("https://unknown-instance.com/users/brand_new_person/followers"), + URLMustParse("https://unknown-instance.com/users/brand_new_person/inbox"), + URLMustParse("https://unknown-instance.com/users/brand_new_person/outbox"), + URLMustParse("https://unknown-instance.com/users/brand_new_person/collections/featured"), + "brand_new_person", + "Geoff Brando New Personson", + "hey I'm a new person, your instance hasn't seen me yet uwu", + URLMustParse("https://unknown-instance.com/@brand_new_person"), + true, + URLMustParse("https://unknown-instance.com/users/brand_new_person#main-key"), + new_person_1pub, + URLMustParse("https://unknown-instance.com/media/some_avatar_filename.jpeg"), + "image/jpeg", + URLMustParse("https://unknown-instance.com/media/some_header_filename.jpeg"), + "image/png", + ), + } +} + +func NewTestDereferenceRequests(accounts map[string]*gtsmodel.Account) map[string]ActivityWithSignature { + sig, digest, date := getSignatureForDereference(accounts["remote_account_1"].PublicKeyURI, accounts["remote_account_1"].PrivateKey, URLMustParse(accounts["local_account_1"].URI)) + return map[string]ActivityWithSignature{ + "foss_satan_dereference_zork": { + SignatureHeader: sig, + DigestHeader: digest, + DateHeader: date, + }, + } +} + +// getSignatureForActivity does some sneaky sneaky work with a mock http client and a test transport controller, in order to derive +// the HTTP Signature for the given activity, public key ID, private key, and destination. +func getSignatureForActivity(activity pub.Activity, pubKeyID string, privkey crypto.PrivateKey, destination *url.URL) (signatureHeader string, digestHeader string, dateHeader string) { + // create a client that basically just pulls the signature out of the request and sets it + client := &mockHTTPClient{ + do: func(req *http.Request) (*http.Response, error) { + signatureHeader = req.Header.Get("Signature") + digestHeader = req.Header.Get("Digest") + dateHeader = req.Header.Get("Date") + r := ioutil.NopCloser(bytes.NewReader([]byte{})) // we only need this so the 'close' func doesn't nil out + return &http.Response{ + StatusCode: 200, + Body: r, + }, nil + }, + } + + // use the client to create a new transport + c := NewTestTransportController(client) + tp, err := c.NewTransport(pubKeyID, privkey) + if err != nil { + panic(err) + } + + // convert the activity into json bytes + m, err := activity.Serialize() + if err != nil { + panic(err) + } + bytes, err := json.Marshal(m) + if err != nil { + panic(err) + } + + // trigger the delivery function, which will trigger the 'do' function of the recorder above + if err := tp.Deliver(context.Background(), bytes, destination); err != nil { + panic(err) + } + + // headers should now be populated + return +} + +// getSignatureForDereference does some sneaky sneaky work with a mock http client and a test transport controller, in order to derive +// the HTTP Signature for the given derefence GET request using public key ID, private key, and destination. +func getSignatureForDereference(pubKeyID string, privkey crypto.PrivateKey, destination *url.URL) (signatureHeader string, digestHeader string, dateHeader string) { + // create a client that basically just pulls the signature out of the request and sets it + client := &mockHTTPClient{ + do: func(req *http.Request) (*http.Response, error) { + signatureHeader = req.Header.Get("Signature") + digestHeader = req.Header.Get("Digest") + dateHeader = req.Header.Get("Date") + r := ioutil.NopCloser(bytes.NewReader([]byte{})) // we only need this so the 'close' func doesn't nil out + return &http.Response{ + StatusCode: 200, + Body: r, + }, nil + }, + } + + // use the client to create a new transport + c := NewTestTransportController(client) + tp, err := c.NewTransport(pubKeyID, privkey) + if err != nil { + panic(err) + } + + // trigger the delivery function, which will trigger the 'do' function of the recorder above + if _, err := tp.Dereference(context.Background(), destination); err != nil { + panic(err) + } + + // headers should now be populated + return +} + +func newPerson( + profileIDURI *url.URL, + followingURI *url.URL, + followersURI *url.URL, + inboxURI *url.URL, + outboxURI *url.URL, + featuredURI *url.URL, + username string, + displayName string, + note string, + profileURL *url.URL, + discoverable bool, + publicKeyURI *url.URL, + pkey *rsa.PublicKey, + avatarURL *url.URL, + avatarContentType string, + headerURL *url.URL, + headerContentType string) typeutils.Accountable { + person := streams.NewActivityStreamsPerson() + + // id should be the activitypub URI of this user + // something like https://example.org/users/example_user + idProp := streams.NewJSONLDIdProperty() + idProp.SetIRI(profileIDURI) + person.SetJSONLDId(idProp) + + // following + // The URI for retrieving a list of accounts this user is following + followingProp := streams.NewActivityStreamsFollowingProperty() + followingProp.SetIRI(followingURI) + person.SetActivityStreamsFollowing(followingProp) + + // followers + // The URI for retrieving a list of this user's followers + followersProp := streams.NewActivityStreamsFollowersProperty() + followersProp.SetIRI(followersURI) + person.SetActivityStreamsFollowers(followersProp) + + // inbox + // the activitypub inbox of this user for accepting messages + inboxProp := streams.NewActivityStreamsInboxProperty() + inboxProp.SetIRI(inboxURI) + person.SetActivityStreamsInbox(inboxProp) + + // outbox + // the activitypub outbox of this user for serving messages + outboxProp := streams.NewActivityStreamsOutboxProperty() + outboxProp.SetIRI(outboxURI) + person.SetActivityStreamsOutbox(outboxProp) + + // featured posts + // Pinned posts. + featuredProp := streams.NewTootFeaturedProperty() + featuredProp.SetIRI(featuredURI) + person.SetTootFeatured(featuredProp) + + // featuredTags + // NOT IMPLEMENTED + + // preferredUsername + // Used for Webfinger lookup. Must be unique on the domain, and must correspond to a Webfinger acct: URI. + preferredUsernameProp := streams.NewActivityStreamsPreferredUsernameProperty() + preferredUsernameProp.SetXMLSchemaString(username) + person.SetActivityStreamsPreferredUsername(preferredUsernameProp) + + // name + // Used as profile display name. + nameProp := streams.NewActivityStreamsNameProperty() + if displayName != "" { + nameProp.AppendXMLSchemaString(displayName) + } else { + nameProp.AppendXMLSchemaString(username) + } + person.SetActivityStreamsName(nameProp) + + // summary + // Used as profile bio. + if note != "" { + summaryProp := streams.NewActivityStreamsSummaryProperty() + summaryProp.AppendXMLSchemaString(note) + person.SetActivityStreamsSummary(summaryProp) + } + + // url + // Used as profile link. + urlProp := streams.NewActivityStreamsUrlProperty() + urlProp.AppendIRI(profileURL) + person.SetActivityStreamsUrl(urlProp) + + // manuallyApprovesFollowers + // Will be shown as a locked account. + // TODO: NOT IMPLEMENTED **YET** -- this needs to be added as an activitypub extension to https://github.com/go-fed/activity, see https://github.com/go-fed/activity/tree/master/astool + + // discoverable + // Will be shown in the profile directory. + discoverableProp := streams.NewTootDiscoverableProperty() + discoverableProp.Set(discoverable) + person.SetTootDiscoverable(discoverableProp) + + // devices + // NOT IMPLEMENTED, probably won't implement + + // alsoKnownAs + // Required for Move activity. + // TODO: NOT IMPLEMENTED **YET** -- this needs to be added as an activitypub extension to https://github.com/go-fed/activity, see https://github.com/go-fed/activity/tree/master/astool + + // publicKey + // Required for signatures. + publicKeyProp := streams.NewW3IDSecurityV1PublicKeyProperty() + + // create the public key + publicKey := streams.NewW3IDSecurityV1PublicKey() + + // set ID for the public key + publicKeyIDProp := streams.NewJSONLDIdProperty() + publicKeyIDProp.SetIRI(publicKeyURI) + publicKey.SetJSONLDId(publicKeyIDProp) + + // set owner for the public key + publicKeyOwnerProp := streams.NewW3IDSecurityV1OwnerProperty() + publicKeyOwnerProp.SetIRI(profileIDURI) + publicKey.SetW3IDSecurityV1Owner(publicKeyOwnerProp) + + // set the pem key itself + encodedPublicKey, err := x509.MarshalPKIXPublicKey(pkey) + if err != nil { + panic(err) + } + publicKeyBytes := pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: encodedPublicKey, + }) + publicKeyPEMProp := streams.NewW3IDSecurityV1PublicKeyPemProperty() + publicKeyPEMProp.Set(string(publicKeyBytes)) + publicKey.SetW3IDSecurityV1PublicKeyPem(publicKeyPEMProp) + + // append the public key to the public key property + publicKeyProp.AppendW3IDSecurityV1PublicKey(publicKey) + + // set the public key property on the Person + person.SetW3IDSecurityV1PublicKey(publicKeyProp) + + // tag + // TODO: Any tags used in the summary of this profile + + // attachment + // Used for profile fields. + // TODO: The PropertyValue type has to be added: https://schema.org/PropertyValue + + // endpoints + // NOT IMPLEMENTED -- this is for shared inbox which we don't use + + // icon + // Used as profile avatar. + iconProperty := streams.NewActivityStreamsIconProperty() + iconImage := streams.NewActivityStreamsImage() + mediaType := streams.NewActivityStreamsMediaTypeProperty() + mediaType.Set(avatarContentType) + iconImage.SetActivityStreamsMediaType(mediaType) + avatarURLProperty := streams.NewActivityStreamsUrlProperty() + avatarURLProperty.AppendIRI(avatarURL) + iconImage.SetActivityStreamsUrl(avatarURLProperty) + iconProperty.AppendActivityStreamsImage(iconImage) + person.SetActivityStreamsIcon(iconProperty) + + // image + // Used as profile header. + headerProperty := streams.NewActivityStreamsImageProperty() + headerImage := streams.NewActivityStreamsImage() + headerMediaType := streams.NewActivityStreamsMediaTypeProperty() + mediaType.Set(headerContentType) + headerImage.SetActivityStreamsMediaType(headerMediaType) + headerURLProperty := streams.NewActivityStreamsUrlProperty() + headerURLProperty.AppendIRI(headerURL) + headerImage.SetActivityStreamsUrl(headerURLProperty) + headerProperty.AppendActivityStreamsImage(headerImage) + + return person +} + +// newNote returns a new activity streams note for the given parameters +func newNote( + noteID *url.URL, + noteURL *url.URL, + noteContent string, + noteSummary string, + noteAttributedTo *url.URL, + noteTo []*url.URL, + noteCC []*url.URL, + noteSensitive bool) vocab.ActivityStreamsNote { + + // create the note itself + note := streams.NewActivityStreamsNote() + + // set id + if noteID != nil { + id := streams.NewJSONLDIdProperty() + id.Set(noteID) + note.SetJSONLDId(id) + } + + // set noteURL + if noteURL != nil { + url := streams.NewActivityStreamsUrlProperty() + url.AppendIRI(noteURL) + note.SetActivityStreamsUrl(url) + } + + // set noteContent + if noteContent != "" { + content := streams.NewActivityStreamsContentProperty() + content.AppendXMLSchemaString(noteContent) + note.SetActivityStreamsContent(content) + } + + // set noteSummary (aka content warning) + if noteSummary != "" { + summary := streams.NewActivityStreamsSummaryProperty() + summary.AppendXMLSchemaString(noteSummary) + note.SetActivityStreamsSummary(summary) + } + + // set noteAttributedTo (the url of the author of the note) + if noteAttributedTo != nil { + attributedTo := streams.NewActivityStreamsAttributedToProperty() + attributedTo.AppendIRI(noteAttributedTo) + note.SetActivityStreamsAttributedTo(attributedTo) + } + + return note +} + +// wrapNoteInCreate wraps the given activity streams note in a Create activity streams action +func wrapNoteInCreate(createID *url.URL, createActor *url.URL, createPublished time.Time, createNote vocab.ActivityStreamsNote) vocab.ActivityStreamsCreate { + // create the.... create + create := streams.NewActivityStreamsCreate() + + // set createID + if createID != nil { + id := streams.NewJSONLDIdProperty() + id.Set(createID) + create.SetJSONLDId(id) + } + + // set createActor + if createActor != nil { + actor := streams.NewActivityStreamsActorProperty() + actor.AppendIRI(createActor) + create.SetActivityStreamsActor(actor) + } + + // set createPublished (time) + if !createPublished.IsZero() { + published := streams.NewActivityStreamsPublishedProperty() + published.Set(createPublished) + create.SetActivityStreamsPublished(published) + } + + // setCreateTo + if createNote.GetActivityStreamsTo() != nil { + create.SetActivityStreamsTo(createNote.GetActivityStreamsTo()) + } + + // setCreateCC + if createNote.GetActivityStreamsCc() != nil { + create.SetActivityStreamsCc(createNote.GetActivityStreamsCc()) + } + + // set createNote + if createNote != nil { + note := streams.NewActivityStreamsObjectProperty() + note.AppendActivityStreamsNote(createNote) + create.SetActivityStreamsObject(note) + } + + return create +} diff --git a/testrig/transportcontroller.go b/testrig/transportcontroller.go new file mode 100644 index 0000000..f2b5b93 --- /dev/null +++ b/testrig/transportcontroller.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 . +*/ + +package testrig + +import ( + "bytes" + "io/ioutil" + "net/http" + + "github.com/go-fed/activity/pub" + "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/transport" +) + +// NewTestTransportController returns a test transport controller with the given http client. +// +// Obviously for testing purposes you should not be making actual http calls to other servers. +// To obviate this, use the function NewMockHTTPClient in this package to return a mock http +// client that doesn't make any remote calls but just returns whatever you tell it to. +// +// Unlike the other test interfaces provided in this package, you'll probably want to call this function +// PER TEST rather than per suite, so that the do function can be set on a test by test (or even more granular) +// basis. +func NewTestTransportController(client pub.HttpClient) transport.Controller { + return transport.NewController(NewTestConfig(), &federation.Clock{}, client, NewTestLog()) +} + +// NewMockHTTPClient returns a client that conforms to the pub.HttpClient interface, +// but will always just execute the given `do` function, allowing responses to be mocked. +// +// If 'do' is nil, then a no-op function will be used instead, that just returns status 200. +// +// Note that you should never ever make ACTUAL http calls with this thing. +func NewMockHTTPClient(do func(req *http.Request) (*http.Response, error)) pub.HttpClient { + if do == nil { + return &mockHTTPClient{ + do: func(req *http.Request) (*http.Response, error) { + r := ioutil.NopCloser(bytes.NewReader([]byte{})) + return &http.Response{ + StatusCode: 200, + Body: r, + }, nil + }, + } + } + return &mockHTTPClient{ + do: do, + } +} + +type mockHTTPClient struct { + do func(req *http.Request) (*http.Response, error) +} + +func (m *mockHTTPClient) Do(req *http.Request) (*http.Response, error) { + return m.do(req) +} diff --git a/testrig/mastoconverter.go b/testrig/typeconverter.go similarity index 75% rename from testrig/mastoconverter.go rename to testrig/typeconverter.go index 10bdbdc..9d49e6c 100644 --- a/testrig/mastoconverter.go +++ b/testrig/typeconverter.go @@ -20,10 +20,10 @@ package testrig import ( "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/mastotypes" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" ) -// NewTestMastoConverter returned a mastotypes converter with the given db and the default test config -func NewTestMastoConverter(db db.DB) mastotypes.Converter { - return mastotypes.New(NewTestConfig(), db) +// NewTestTypeConverter returned a type converter with the given db and the default test config +func NewTestTypeConverter(db db.DB) typeutils.TypeConverter { + return typeutils.NewConverter(NewTestConfig(), db) } diff --git a/testrig/util.go b/testrig/util.go index 96a9793..0fb8aa8 100644 --- a/testrig/util.go +++ b/testrig/util.go @@ -22,6 +22,7 @@ import ( "bytes" "io" "mime/multipart" + "net/url" "os" ) @@ -62,3 +63,13 @@ func CreateMultipartFormData(fieldName string, fileName string, extraFields map[ } return b, w, nil } + +// URLMustParse tries to parse the given URL and panics if it can't. +// Should only be used in tests. +func URLMustParse(stringURL string) *url.URL { + u, err := url.Parse(stringURL) + if err != nil { + panic(err) + } + return u +}