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 07edd0a97..d1cefcf78 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 6d7dbdb83..d0bcc612a 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 a836afcdb..dce810202 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 000000000..d0560bcb6 --- /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 fb21925b8..b53d8c412 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 000000000..da86ee940 --- /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 5003be139..5ca17a167 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 000000000..406769fe7 --- /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 000000000..ba7faa794 --- /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 9edf1e73a..4c62ff705 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 223a0c145..85b0dce50 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 2ebe9c7a7..7ce5311eb 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 49e5492dd..0e60db65f 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 518192758..d1e732a8c 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 d45b04e74..42760a2db 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 000000000..fd42482d4 --- /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 341805b40..793c19f4e 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 2c272e985..7ec788a0e 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 4bc1991ac..d5f8ee214 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 1d9a85993..c42ba77fc 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 44de0891c..79d9b300e 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 7651c8cc1..63d323a01 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 000000000..9823eb387 --- /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 516e3528c..09fd8ea43 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 8fb9f16ec..2826783d6 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 000000000..db57e2052 --- /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 30bbb117a..e86c66021 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 73a1b5847..ba9295623 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 000000000..0f77820a1 --- /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 000000000..02080b042 --- /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 d143ac9a7..fb9b48f8a 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 000000000..e55416522 --- /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 000000000..888589a8a --- /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 9ccf58948..2f779baed 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 000000000..799acb7d2 --- /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 169543a81..7b72df7bc 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 000000000..c6239cb36 --- /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 ce817d247..b31acebca 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 000000000..94fd662de --- /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 5f5277921..44b1dd3a6 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 bbcf9c90f..efb69d6fd 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 b8dbf2c1b..c1736a8d6 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 71c2bb309..036218f77 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 882d6bb9b..eeb4b8720 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 444c57e2c..81118fef0 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 6140a0127..a796c88ea 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 bda79a8ee..d90247f83 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 d1147e04b..ffa6d53e5 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 000000000..4f004f13c --- /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 397522dc7..d0979319b 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 ed95c124c..b0568c17e 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 c50ca6343..c2834718f 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 394085724..f145d69f2 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 0e0bbe802..3df3fe4c9 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 29b5a1803..2e7662b2b 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 86d9795a3..519922ba3 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 235761378..d8b4d6b4f 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 7265d46e3..400835fca 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 10e626a8e..857a8acc5 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 5b704367b..220cde59e 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 790322313..1e39f1516 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 81a593d99..a7985af24 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 26d361b43..c8d080e2a 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 d93ea079f..250d2218f 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 bedaebec2..b00e7680a 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 c28f5d5ab..9e410091e 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 4d7535100..f34c63374 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 1e0bbab46..6e71023e2 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 3fa7c7abb..1b2625a0d 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 ff45eaade..deafd22aa 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 0445a1ffb..441af71de 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 f5cc07a06..faf88ae84 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 82e6e6618..f009b4cef 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 c9ac1f177..611ab214c 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 000000000..693fac7c3 --- /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 000000000..84e35ab68 --- /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 000000000..8df137f44 --- /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 000000000..b45b01b63 --- /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 8f805bc93..c80b568b3 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 7709697bf..000000000 --- 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 81eab467a..000000000 --- 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 1c6f528a1..000000000 --- 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 99b79d470..000000000 --- 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 0421c5095..000000000 --- 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 ee713a471..000000000 --- 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 2d4293d0e..000000000 --- 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 97354e767..000000000 --- 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 01dfe81df..000000000 --- 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 9ce68af09..000000000 --- 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 58236edc2..000000000 --- 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 76918c782..000000000 --- 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 9c06eaf92..000000000 --- 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 d8d18d68a..000000000 --- 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 95057d1d3..000000000 --- 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 69ad7b822..3e085e180 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 16e3262ae..ab66b19de 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 df2e41907..000000000 --- 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 24a57d8a5..647285032 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 f9bd21c48..a54784022 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 151c1b522..000000000 --- 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 42248c3f2..000000000 --- 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 a7206e5ea..f0d6f5e84 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 000000000..9274e78b4 --- /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 000000000..f105d9125 --- /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 a2aba3fcf..1764eb791 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 000000000..4fe0369b9 --- /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 000000000..2eab09507 --- /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 000000000..ab854db7c --- /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 2f90858b4..8d3142f84 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 d8f46f873..f20e1161d 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 66f776e5c..000000000 --- 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 4bf5a9d33..181b061df 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 c11e2e6b0..c175a1c57 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 751956252..e98602842 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 38f9e89c4..000000000 --- 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 732d933ae..000000000 --- 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 df8c01e48..c6403fc81 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 58f2e029e..8045295d2 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 1f875557a..10fffbba4 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 64d1ee770..f4f2819af 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 be617a256..db2cca690 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 000000000..9433140d7 --- /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 000000000..abf7b61c7 --- /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 000000000..bf56f0874 --- /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 000000000..cbd55dc78 --- /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 000000000..6dc6330cf --- /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 000000000..77b387df3 --- /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 000000000..d0027c915 --- /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 000000000..c928eec1a --- /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 000000000..b7237fecf --- /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 4e678891a..5241cf412 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 a7028228d..b77163e48 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 594b9b5a9..1b8449619 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 1ddf18b03..7877d667e 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 594b9b5a9..1b8449619 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 000000000..378b81450 --- /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 2d88189db..a596c3d97 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 000000000..525141025 --- /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 000000000..ba5c4aa2a --- /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 000000000..8d39be3ec --- /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 000000000..5e3b6b052 --- /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 000000000..1cd66a0ab --- /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 000000000..5118386a9 --- /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 000000000..b2272f50c --- /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 000000000..6bb45d61b --- /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 000000000..73c121155 --- /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 000000000..8eb827e35 --- /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 e689b62da..9456ef531 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 f0bcff5dc..000000000 --- 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 60b397d86..a59bd678a 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 e4b3ec6a5..5591f185a 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 72bd3e885..7c9af2cbd 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 000000000..9b96edc61 --- /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 acf0e68cd..d392231bb 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 dbac5e248..73f5cb977 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 1caa18581..7ed75b18f 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 5974eae69..4d22ab3c8 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 000000000..63ad520db --- /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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%0D(dbcuF@_7i$_%_bc%TJ-x5H> z5swi#qeW#Mm(9gv=IHMbn>%xUL3j1iO0-xo7PBV(WMIAaXUPjhak^6pv}_oH-jC@l zNGl3XKblcq=I;>BZvQM=6=-SDMxuP*GgdM$iFR)&nrb@0KbI)k zjOvUZP+B9VT1Xf5j=%)Z$bCeD<$Y*h$-YyOp7y^>qH+_7(!*ut%zM0)aV4DX`Pcod zG5S+xVxSB$Hu5%vv*I6Tr#_PIxcXW9tGgIexUG+!4gv!}!}AOBZTG_?(Qs7f|G-Ps`%ulL@Wbc1?Ql&1w6TztS}0J1;_f#!pE2 zU9RG{E&T}v#H}dEj^wb#Mnz>WTZ&25QAk}`2FAQO-6yb=0e+Gep7hS6*)N7Cdgsp` zUZ>@?_6~C+yI5zzP7zK9DsFQ!xJUmQ>H#q~0%r{pBb_2p9Ml5fE!UZuwSNfzu~m_E znE&TL%^cXM;Mk&#jN)?L?7t{9`cZ6T3UuRNbf=y>=)==eMKOEvD@+zF&O20xo4}gN z)vBNDCLp1y!DJ+sM9rF`JnB%5i=1Cb3yTZ9<5>r)Lugu{)P>5+bBDYMiJA!#2DFtX{g&&-Bg*2VSo*nF~k2hUSW zcg-=U^eIa-Q&R@8vdV`grFzk&^Do?5a8;o_f6xybs8VPJgf0K0Cv0J-7nCY!X3$2u z<&Ma++STA)Li&eA`Op1FI}u0qB=xOnxtZDUO9L~X{QSmF z7hxc6>Ee&Ukwm+m_BDWV!#k|`amq5xNA~ai(+``_3!fkXIT^)y)w$hQk}rzeN%4FMQAET!C65{$JASjgo78*_NFMUgEngt0 z;amT(d2`%hW2eKb_D}DE`J$Y;WaNl9&O~fN8+RYAa4)dKN^A%B(I3~LNHi-YCqbJ2 zZ?a!0_!K7|?K4Gc?~teySc>{^5PHgeFmdMu>Ixo@$2MzQ%uJEe7g|4{Ij~XK;IjJm zl2W~>Qarz#WY^C#TH#JTcSMGnr5#(bLG95xBF`3O+Vm2y z0f9%@PQ6BMqf}X2>bi;Yl&R^v+`Bvi$ok~`^Sa;D^BoUj@B_rgOyZVHW$n*;zkov^ ztxt}D(&}hl)sk4e(YTlJf>|7r;MhWNeg3B9Z8CUE1YwF3peuQ{B~k-eqGTW2Kj6_( z{DO@0mDzQ!J$gQxzCz2bT1`R3qrce2`4xjtS5png{G1&X5f3<%3>4LNhi=8X3B7;P9}N1& z#s|lnCD*Wfn+=J;5Cy5IB8J6R75#*!bMDkf&{9xKBWxA*bZM-2g)_j>US8(C%$Zu> zh;G!7H*do=pzrtO!E;{Hzz=UMwMcx*P)W|p#oatFbTQpzrX{zc!3AlrVoT=0iHBBs z_GlN$=KaPJy}F9}$>5zy9QoiP#Tnq}_-!wFb-LO?#}-asQskF6BvM;#`u^g|{^haJ z@fk+Vg^fcTUh4==Tq%THVuwA9oyXoVRI zir%A}?9huTEqg;etoD(KO;GJweQ~A~+)`Mv0rjVD5H*^~v{u&ZA5BYKuI~h)i=Unu ziJ7PU2O_a@?Bn*3h+b@}sqpyC%dlcPy!~KS=^(Tagq@znoHXO5)zkUbImm~B47iT! z6!ZGSf1~xvgQTs}w-1l1aJ9Aa{YTu7Ejiv=x)y8WJH*znqBk66Q3vR-)Ngp@cadz% zh~o!8^vI_iDtd?()>1gUqSMQ@J`e@#{oP!QE$j6O>^U&YyiOtu56m8$vmwxsm zFp)dB#^GpX@mzr$HCRU%cbii<@44@I8aBQc+jWA-uz}yg2nM7>2FxQ-2>oZ^h-`!((K<<`2wPOo46fL*y4y*K#~J4 zav-3Je}0d6^AcUMh0^n-PX(LiZeDHMc}GrC!SDL_2IuU*AuuRn)`4s6`e##{XRP_M z!p(iZGpCNYA6xV_k8Z%wzBEf0GTu?d-NR1E%M+j7DjrJwG1rDgfaQ)j8|Rnw*o@w- zG6uQzJ16TPqcu=3+SmfkZh(N`H#nFEqkXRd;@PSE34aeAVm^F~s{AEA`vD=&<1SgM zHc%oyFQfj4x^bsF1-!jgrZK07P#8L^8WB-F{IHKUKTVYZRr4U>zf-TA^E)ItW6k4( z(f+)($I{ei%=z?=V#ecy5b~pK%DHib#v4|{T?s&UIS6NMqeJ8 zc$ucSQwV37!s?i5M~T&9H=bB6rj>OXzj1tJ^w5634Ga5nCc$h_Tz zgYK0D|AF=SLcbRAjXNjnI1j{5gxNkwhWqs2hYwR-;^wNA`1!sylVt)#U;1*()u8Ee zD|PGHu6fcKA~w|l+Q+p7m+}JoQoOE0lIR%LmW|lGUBx=lPBD&d8TpO*GX6yK zgEYGREk6resJLIy=48wQmBY|LT!JFH)jwjNV7-89F<1ze7AfdbEC@hh$qv+ zOM3A4CBnImFf2a!8!`}(>0KAy$KRP$C_L)8UdW6+_*w~84^e2m{yW#=MMXtzO|)hWT5ahlBhG-jFMl8v z#72K}=!8AjReAZpPF-RA{3U*I$Xxw{`cz+u7^bb2z(75u3<9CWV$Ei@?UyuQ*FMSK zpkm|XEFp6P6w`V|79ZM&lIC0xsu=MCx{I@21IS%xW-_fNeChXD%P(C0Up+d)NHTmQ z<*4FiS*H0b>O)t5XIf!9?M@t#vsR~wUDTR#{t0v_&(r#a)xyZs3|A~^diltk6t(K2 zdhS>YZ&9nhko+#P-a&}|ZQxas_PVzJ3d{Ww%*S4y6rKjdt1fI1>_Zp}AT(b{=1H-p}5D@~@~xmo$sz+dQvIf;L$l>0S=s})A& z|KLB31!JS-@i^HfHlHBzljm8@509?_4$=0Hj%ax#8DbY!uR<{zH=$y>Pe{&fAyqaa#x()?!De)kN^J?1z`xCZ>bv3TemxBF~+C}{3Xw&?f| z%r^xBJrL=3G&qxPwzP#Q+bsI;v9o*oWVg@bPAku--roO-T34>IKRKh8tH5`hD#OJg zA;T@Vp`^~4`GHN3hy{ADEA2v9gKTeqQuo3n0mQyc+Nk_}4UoXQUPyjN)zpnotGN zi~nxIHY@siCQuvp5%W12``!WyC^*Gwh(F&InVF;cke{)ib3&iUe+fd5yx+K*n`P|N zf~S_*m1XTog!zBU7>@=tsKzHpYkc?;+?&%lLja2ph9Bnl{U&+&HXuy#tEtz3fM(W& zv=A51@+)S{DYJzWv)Q+bku)CJ9}=L8D@Rp`&i~kni=UjCIj4;6@4=9m)vMx3Gt3_t z+V`0=Q_=hc?vyei*G}_)sr^2s#I}5$$+bK{$8Ey!zMKp92T$=I_URL49WoZBZ$kLy z`9%d}QhZVS;GxU{iF2VncJV7+ zh`EAmDy~N)y1Rud<#qkV{#nNU#q@)=iDQ@#XNKc93#=Z(w0HbKtW&v|GgZdg}^Uj}u_$iu37s9%nryCZIh?04t&CJ<{e;ba}>a`a1? zng}vl_F2X~^lrWe@L;jn)E?{6-2;jCpEYj`uFS6iLDRTX%Gh><35A;L1*!71cID7c zzu>KT)ZY5PD_#tBOdUDiR!1Qib9)7phYFNclE)@{?l*qPIN6Y~!1>ug*J0s^`r z>8UWIWo{AkjC}!>ey5gGhP}wkmgt$BnNuoj?ZMTKocXD105|M%$R~8qd&;Vr+Y8~} z{C z_%YZ~Ec8IEUZtu*c%6;;P(YHd)|J+LB+9n`8o>4QCmWjU@PcdU&Oh1SZIZS2HDJx& zV1TFZh-;CpJS``pYe3K+gQJW@OY;o*NfI^j7R75q)7k z$`$T=4G;;y>m$S*0!TbkPa}sdE&mJTyY=EM#2x3cU;H3(=^AhauiXdZ{l&$(`sN#f ztkL@dZ$?S>J+`ugTTfl|{M$>e0be*KPS#JZxE!M9WEP&c^g+x81HO%Zka~txTtvd0QrZ0cxqb&qYW~OehLj`870X1T8F>ovzXn8YJNhr5T&c?jW34(uNp@qR z*8tGZt570W*B0uEBBr)wkFHtMe|65$KV@e1U&YXi4UnJKQ{3$n0lVXN$g9FsJK45S z`HeeD@dxQj9q%eSSgstmV2BV9E#}qadT8 zx_R>!HO&o5iW?MU0CEbZ8@C@&3Ol=0{$yl+D6fqQXk&R{<{B7N)qbB_;r08h>ft@A zM?xZsNORGbIu>q0-?KZGSmj3cl`La(I>mHHA8UQdJ&0Sro(9|`BLh&$0+wVMDp3S> zayv(gqDBT65c8?R5rc~Znu~|}J^wT3*_>^4g~VW{b8f7)ynFs%jslxSOVs>DoR@c; zZzIx8W_9?;duV%_k%W4XCvO_7StzmJpOO=tc*yU|)fh0%>;Q>8#=LJ*t$XfKHorq& zW#!bREp~&sFrW=YS`Hs=m7)9uP!6RrJBJ-U8O_tNTEevBB&c}(z8CB*?d_jJh^GKE z1!v>P*@YWUEDA6!jSZ79Czqh+wdeO>!f%3RQUgK!jVLFTZOMDnGTy=S7Nk(r@v5dl{dR@Nd_E zzG_=|4KTvveniLO_gswdE*wN+Oe$?t94hnV50qu7G#jEGV^qf|Y$HZOq`K&75O;D4 zyknhB^8(;3OkJ$Nx*hF*`L^;?T3!A>FURl5KJq9*-ETy?#)&$K811Oz_Lc5BjLrJV z?`Xr-zK;gxr)4qVgiH#XoSglnOkn;j8YI+{VZf{!M!{NxY3$+~=gaL|)!fxK8aCKv zLK)|Ch3%b1-kQ8xM_0Wh?j%@kjvK6P>MeD)Fe-8C4vy2ymlYzK=G)H~L=hu?;# zk)wQQiu}Hjz!2fZqH@`FTXmD=;wdes^(9s!tqMW6sLMS|aZRb|EcTO-OrCer)N)$U zCmQo~eqrPE_n~UEEVCjg4A~klCm{Sy2|X)<7d*a55m4Q)%dE7bW>v%j8NZDXQw{~X zj>)hos^v?OSm2AbwO6_g3@*~evf}srq{3|8_yFh0k!@!SUe5qDNbA;{3=g9XZor3i z&ZXD6+XaUAsDfJOZY49a?IT7SX+SLTOLjb`4m%nwY^QdNLrvj*GJabBVXYD8nSbi) zV4Lp`+B+XJCI!rgiDuzbrqOW=0_ZXR>BX-hw~`lJtXGnhJ`K72%A~Rnt#@AQDzm4T z?WAr>V{t=KnQtoY_cM-UdkkCyEEaGtwsda}!$)6kF7;l-y7F*61irKFNVQR@#)e_C zi{$PdI)}gKyf8aWVT=vL1(Q z7DY%Z&y_W+Da{l2{Gg7vL8ea3RbwnbiEmjZVfR`4y_pc>0-RK??;CAW26Z6h#R&g4 zMb?*++6rqv#Wlz@(e!}5dyUMZPpZ#!lDnAtk)ltcLso@}rp?TIG!8-60RH*8EJ;!&OxO*b%qA9`szosyBmSlkAE--ScVvIP!okF(lB0mJQDG77~5t{-j{CNqv? z=UJPD~3_-J}M%cVrnzpOi7&&*(&;UiSsok9!OD%@=8ER z^-epTDt=j}ju$c7F{mG07n0X;0^U{=y#^$JDT0&ECLH{3&(G6|+}9f$r z1>PIh;Z8jx)1}NDq5&|6JAc|TU;rkV0%A(%tSlmg=Z7A>dV-;u6!#sZS)I}AK59Tg#0xihC64G z&NFQ`RSJVu3X_WYOz3=)vnQ#rt*gnULflsP!esi9;`OH_fF~`rDMd3N#eZ3PSFJwt;7)m*taUA`;K(Zh*VL@{h_?Ca4wL6X;wov#E2TEZH%`HQH8fuWtM-_VpDY>st;vt>NwolO(=!%4Xx5QC<;h|= zqmyCPf5B@WP3x#vH|*l5-=}423ZA8=&^6S(`#S9Dc$XDnspCCM{(bj%;l(NqC?Lh_ zn;RN;lPC0VQ_<|_2_zThpUS+1$|ciH-;FEAV^l`IzI6p3gV)||MtbW$H$5nk>(?+j zM%L&MZt9+-AtRcG_>iuQh4M)*8cz7I#ZJ)Sm9%YIF({Q8MQFQA{>EudlCf&c^{60t zIu!ldh9kXtnhvn^*nO`)k7lXwKYJQY7oWRi{Mln4447uFj%UldG16?TVD-h1+Qy_P z895~^G3e!RgCdbx=hl*)Rgu-tV;;Sn8z#m!wtp4jR6c9kb75ROtsokQAWZ{}p`@G_ z9eJ{9$C>$COO7yUgB^A2ZOHjQ6O#Z?ep7_l6vb3XaFV2oo?m67!q?<@?Sg)^GZp@C zZllnOrmqet$UcUI%FoFynDuj>4mhCH@{Hx*jZY!*rHSomdl_kM4!TtJf_=<`OqA1o z)=}2eEdJUEIkoqmg$ZAYtf#;zF{m-6~L>Jq1 zIHXv#|CR9R$N&~IA)dOtcN4O106KoRYDy^Z=XFZ-1SPI`&`_i?b-%rfc;LyzOLe9@ zXgD2lbxGD2U#85=dqUeyXR7-`6 zij4tznsRSISqcRQk_xt&L`)3X{Rd;Ea0PU3M1aPvF&5P)HmF zoOdF8(Ykr(m_4ANaOad>eSAlK5?%@RQxol=s1cf6J-MM7FTv^dmLJ5l$k&i3#DsWN z8{r_;cVEe=zY%#<*2xf~p=eAs#-u@1&S>;-iJGkU@fAE1Bskd*QCK_KV)l*wj;Zy;>m}h{b}-%NbR356`F25VoVAzyu%oC5 zu2*&rSvz{R>C-i(?wl1NvDLiS0C1Z`q_dk4>$--|N5K#8>N`<>yeTu4$V5O=8?fBV z9W$GfrCPKdP5{x68IJY@nNCB<7m)NaxnhlV99b%aZ=gREw#KeyBRSiP4xu~^@p(XB z8d!vOCbwyV6FvBz`3O30d@34#`3*o2(~*^|m(>I;-sERQJ^jr>WGGc0eGAIj)TLen zLzD%8;B~UsJ`wSm0^dLJY2A@xeM8{&?|E0LbNU9g_dfAfzFRM^6A%z3WSr_mR0K|E zz2plcW*3gOs}53=yQQGVtz`Igfe+`&MzP|eG4l=)wh?@x+{sccb-aKCgE z4MMCQoIqSOOQ|B|sBZ_b0!ta-6S@O_@j7UPj#hQ^b1NKB*{pYB<` ztfg4fg`S!sET*~Q3Jn*F7|0YC#na!OQQPR(5AcTqp=EVjO7)v2DK!QK0-ibbA_}q; z)lZMQs6|(zXndX48aZ*iiNk8vtmU$|ZUqSWa<0ylK0y~^gy2>0eKmVEaf#d7n^6I> zYL=dTFr@u(y8ZM`>n4~cj7(-U=Zc0pX<#Ooz9fJaOtI`2?T+Zy9LI@8v z8J6I+c$<$ySDiD7K^#sS%lqd;6FbJeW9tZVArMh)G7OiJp|;I0qzqZvGzm`zr{0^v zcaRJ8C(={SBS1_O9)R)1Y#35Y>J*dkx~5|j(Tt#fT(e=C;L=h1nUiWmp5{oflQ-3K zua(ys(&X{feub0br3i`9BnYJo(0r_m1C<+obkcxWEs80oSub^&?J;b$vn=)}^@lvk zpu`q`cX|`w+gWsYXrn8&xgqtt5>(sk0Z_KEHqebEQe`6MXQS&V4qqKnH@*t zFK|BNXj4BYJdjsRtAVRo6}9fw>J)+mdQqVhDOqv|JC3g-JFi`JA?8LMa2SYXnri+I z#?2DW>7cvCIGD)2%8T3&bQGt-@=e3N!j7aHbzDWe=6Vx~j1*JUsyZi4nsCd$kEt@} zUEB+%Q$}_FYMURR#|E^D2b4HlF8PJmcC?NTe&t6mrLQq3KR5T7^xn}J*;yUL2`L(= zGJf2F<~7K&n%qkZpIsGwV*BQZ5}56dnN2Fn8*}e`rNN8$SJGQ70Cu;$%qpKPO3SHt zt_{Y0yTDfOAnwgK+2m~R{7+Tg$+syPlxe>Lg4mvl5g%|^e_RALv2+(~Xeic5Ujsxh ze@j+$y|?e%f%mO4F5c87)13}*7qiN0>nG{(EUH-X{j>uRA%axBzW;GbCrucm&(4m=VfAOrnWra$Xy zbb)Pq@>o2gzo!-0IJ#is8F0O~YSwp@q}BhV?4zGC9oVB~$}BmYrOH-kPQQji%3)Q| z4ys>rP7~UUGIH}S?p!=ri44voXCnVIRE>z%2h$ohF3GF7`GNl?Bdb(#qOa<|ZWSZC zm(h43w&yJ zOwyKwfT16hcKZRPqCCyn(cbBEl#&KBpQwr?Y~=1`uDa&)gHJI^iFN6|x7MZt+sF9% zqWrScyfJw;^6yjfL=W%pSilu^ZBm#0Y|CL^l82<2j|D|m7u-hzVR;Onbq$?}<%nCy z-bGRclbC=n5nq0{NPLFS^EGHu2T72A^Nl2GO0)s8i=5R(x8XETg(=cpW*4O*;t84` z5#Zl1{8(2RNRi7D;<;VpgJc^`NdXowk@D$8()9tl1C_SU8O!d0r;*N&r--_`v~fiY zRQz%@;HeU+f-HOl^`iLxCA|-3Y3%TQdEFRW+PtKZ+GSf&(@CAM zdh!&x?l~-or&Vb$lI+K>m`yYD^WS`!R@9FdE&(!h{RxfCEWAvdckwIt0?n3h%1Sb-5^W2PsC>3wiQKZMc;aT5#Jt_;D^;Mnn$<97%{5E{Tdu&oED{eNMq5pa- zBk`5?kPCFxE?(1_Z7 zs{+&NVOP*mR>{9l}-S)*|mUpqFJ9DRQ_MyjJv4n`_e{Dw59 zujY$FVuh+17x_ExV}j1+>K{Nv%momwTkF`K<25 zM$UE&&>d`Fb*?5Zd{S}uE9z7C*YOJOw}PG9x03zVD4f=`%%?D{=0S;4+M_xFt;iZIz&#-SX!HpAvh;CgO6xm4|M=Oo zJovplqRJ5C#_L}mqUn6qbxsdYE+_f(bVZ9AY5-t*Az~NYFlDG(V&yq9Ax90wK3lP& zsM)|Q=KX{E#uffp)B#vhYseQ!Hmu4^hbrs-i;?sRrjp^2GKNsk5r=dq7ir0uS@`2l{c&!?@Db9VD*KS2sqO=Sv%Sc`Qe8GfZ->8ge` zwO=@M>6aAPp-iW-6d9FjGh2Ynis4P03%h6?P_g4I%+XeG< z+kQ36qou?r`#H>gA4d;7V+#%E-2`%T8P~qCq<^mL$db0!O_6)d*E@z+HFVIr^GY`Hsp%+P*U++ zE7cg@?==NO(Ar{=2PwsVG|<7$DR?>=E%vewBdUMnFnT#W2b85&2PXB!@wHO)P8Z}^ z_hn9(Y`ZyAg6;l(aew&LrC_ z|8(#^*w*y5Fhqpfv|unvtoEKNw>Sqyy_6(5bE-YAeEG zi2$tv3?Gdvi|d{hL7tQ1<@~rp#m1?;dmL3`4rXa{@|3TdyciI z>rEu(gyxQ&U&N()fv#w5Z6%g@r8?IED$lzS@^@zdcyyu%b+Saz-S6enbEK zd5VE9fsL`whe?MAFuXhi#yx zt^u!ESzq6!5)CtY6_SHnvMIY&darJdMISs7(Ym02k8x;z)y8wwpX<>RyQqj7q9!iY zBU|w^{j(?7Tjj1+3WkITSe)2RNLTmQ(C-@1vG*lsrs5yglxHRjq#V-!=P~HnTQ3gm z<}*|cTr*kxxuGqG>{0P6r?t-EjE(9OUVfN+RsrhY6DWF3A`)7BkVA6kdoBZK$K8zp z_Y<_&2}6wCO=WJ3f=*ccXqMTCJD(eSHE+*=j&pawOF>9VV0QR^4^wXg`L7jNsN>TTNPE@MiLvmLh1eU;_-`_`!S4T&Palm#dK1EbO_4m^5-e?$~%C%#j0;UVMrF|viL-64O~hZ zyazA}5ql%TLjO0>>J};6$5>L=ZlW6f!9I`@&y(;ciSDjAJ^#rgy0ak?io10Ozq%x6 ztg?fMDW_`cJ&_~dYkj`|QMVOhC5Ee{p`oq+W@;KYP2=)y5iIz|tabP7VbUsJ&0`>- z5$UasDu7Wd)QNzZUrn{sqw2vRA%d7jmoYG#UT_V`_0S#FYN+b$)atsN6I}Z6uQLYL zDMbTbf(6`t?~K=bHfXv!5tiildAzI;Nv>$?Uc)O^P8Gk#s^~P2w@159zV(y{-V%AA z{fsJji-O_d2P9nIrC6)35A-&lyC*1F_y&^+FY-9#3mMvnkq#ZuS{D#4d@%{4GehUG zdoyVG0yf}B1ZjI=lE>T z=H)uIb!Il|&x&|l8+CntekjY3t(KZCYawlX25Us48b&)IT0v^+M zj?xAy4;qEBw3&t6y!}bd(XI%eGPB3GcB^1CZ$?)n0+r9B75zQ8RLbGw%^R{DyfoJN zUr4H}wK8#Y(tl-FSeSuclJmDnQ;oJ-UvYqG{Yryls@AmAvj<+kGAlOJ9rxKa-G%3+i??vWP-aruqaNaztJbjGtIxTmg9B){)z>3$R>7~F&E+^{zm?-+-X$k>Q=4QK z+U8Niq%;nsII{BLrV@3-vmvsNvA=)o%*m{)kFzte5H&|6N55ER<@Gn+d`yjR-BbKb zSwrO13J)>6wcPXQv)yy6E{S+`#J;vr9qg~IcH+T8l1z&P*81$N*WgHYcT@8*hs?Vv zbYM}pDS2%eYvyVpf-369d3z97S`FC|gNudJa8+Gng0Zb<(|q;6{p|OgN_(4=REGtB zPg@_PQ1jYnq--jxBFztsYLuZJu?>R3?02lJZ?b%C=!qDZ3f9na zgxHDQrW0zIKID)6GnH17VmR4XiB(6H3F&s_eht0Lb@zoEp+00I&+x0E%v+%Y7P-Ql z%EfzE{FU%{MeTt!GOxh+PHXi~nFamO*aFYO2-}>mP2sC!dOK2oMh%t(8+%)YF*$u@ z)pfOzc%!S0EL=(wD7D{BS|$CJ=bk*PD;?|RqMQcArNCBw<;F?n3V6!D;Zc)hoXFEo zqBfBN6bWO7K5{|*6C7V&8Q#=rrPHJM>;z_=_lslfNRq6&l5?t3bD#t=oI0Fyn;@O4%;E?00?t#tZBuAen=ag7cpqmQfhV z9|%fDK6kozmx~qDN8r@X(Xb8JP-+{dswsu;i;>-F{&cfZ%W#i{KBoT}ifvF%Di|a# z>@!LcmM3#GVuDX3yz-IKPtFn>D-JQAE>iN8^)f%j+zA;v)CaB-TOmII2SoldXTjF@ zNJY+(k(CIuhuJ}#ar}Vk;#9&LU8#w5U2i&4&)zG~+!PFWb|F%$Ow?akJ}cxzfPG%a zHNQk;SOOCmmNgyFeWCz!*4S1Iy5ag|nlf}`x*iU)R%gv;)f6rN`tt9eU5_XES1_a! z>RWCrjaVa6a|Ht5@7EeiHZlyCCSVSiFY}b5MRe}U(?4V3xc3}4U|L!rIEJrmROpai zs>lY9j6Bz2LRj+Rj)}I&rq9NstPHuKIStH>|9=dC+B}M2q(UVL_2I;t>;*&J&zQCr z-Q$7LM^hNKiW43B8{evPo# zpoHq5GmbmD9GGg1?tJMuQttFrU2a;z8q_OC$K=pIFtkbpBB#2?YX-e{Uf)W-XW&+L zxA@gK_xr#@mAfVio?R#J6aT$l2{uA}-PIi1MWEG8?SmbX(JqY$&Kd^UZjQpx^u;MO z826$tYiTm=4$DVBRYe2EzJ7s&#`c_hL{MsA7;AjEb1R{Z^RwoQ@U^b&vMEOMx5zX`=QTZSu#_Y% ze&?l*u6{ChJ?CvIvw(5m9R|**NABE%y&Coc$f2};iZ|JS47TRYcjeofURLO}b*AH| zg+V9}p^*7!gczKgs zDpBsI(d@lW72UW7xH4*PT?1kr;zY7q@9?@~j9G^QT5b8Zo1RPTBAqRmjG^_(Mg3Fe zN*-XcfvI|Kri{PvNU7{)_op&70v&}Zb}RrwK1?TIhb@(o-Z|UpAby(JHu-On7jwh& z?`kmv{UbBo_1`3}Ag1l%ouH-pfG6dsE@1)|Uy>Dx`uf<`jmk`C8OJCXGaP19mkPN+S`v+x@ zYw{c?5((Llj~|qZg2%Zj1s!9Xs!);B%!6X8x~z}LUW$Bw^&_yHZiAR|**Q`mtC z9OnA%^f;&D{v?R`!A*LLZCU0(t{8F56MnT>l>%pRNfB2`G{fI+!y{u8aLG<3) zQGA-G_V9cOE~U35_b<2%x^2l_N`wW$s(&4yD^o5-`3cwM3un&RfXmaqR_<(RGxFMP zc{yv`AMHcY>4d};qsMJg3!g$HK0VNrP*J=wlt{F)f6hTBu3~8s^@9qh2O8QBnCn3j zYU{mJF~4`d3}{z!@YZjk3=agqixB&NTHsL}OScaUXSW*`p=xlH^L`-TAiOa0XQwHy zdxYKCQLMXe{d|C2nOGIm~y~qCcM-{1Yc+-g;ZvQ*bbe zB2TG&KBt2{=l$Tw&p1u0DkZmS83oB9U-;uW8uo#Fsye=)&l0JVI&{jw<0tGn;m@2< zWtUG5&*L-`!dkjzGGBy>iSULwxh;9e4rnnF)&!n)k5BHgowYv=FMkAWr6L{(yy6_0 ztjT0lzCXr$R3b8WkMd^tcd5kg`^>tcsjqf=U(@#k`I51{TaJAA2s!F(r0LWwS!DIE zkjx;i`KY|1o_j>~9ifWRE|*3{y?y}`puTNiy_y%l{Gf+KHs)J>f>Nf`(MT3-wX$*t zRb;x$9~J2h9x_p&mv1X9y%ToRDeJ)$-JdbD{{s_1?7y={{!Ey#*W{~D9mYD&*xE>G zrEzXl|EaTca}Z+TA5pm@3AnT1rA;v9f{bZ_~%4k!j0$9nQ^YB6+G3TO<`>YKPSqi4ix2%WDb2ETveTGh`5oeHPlX>M%m57Q5TFhhhM zb=&tQjViUFc0G!qj5<`8p2}zX!j!&9+DoWLZeW0WnNv?7L^YS+kBN>SiXz$oE6S#N zC)_-m)BPOfU47Hn`# zA*7l+0neE}q1AKE)65gb8QV~EyFz3|q(nSnL0mKUr@f6Kl)xPM(_F&QdEhy`{xY-N zbH81M(t1y=G4GAyQXC7pjR5YVafPmHIe|@$&2yUS3Nu(KDythD*1e_J=qZvmp~a2D z_mBdpdJun0LU%q1gPQl;sB>pU?;t(2x&ACuT=qTA81qu?4hha{XxRxg=u+Gix*K@( zCibSUMRp@xaR>v33YF(M^sZro zW!F`-r4zxzrMc9k0xIr(M9n2Px4knZSw~aV?Lx_al!wEiVK#;0KngB24UCEUvnGDDV*zHu!X{B0Alz1F^4?zgm^k#&6$` zjCI;n-F4hKu&Mx7wL8UFs~Kq#3X`nw$7+5!82i)Wk**GFn}HNKf_u>I?@$2tx}+VL zAkh2N6t7A;6s4e$){^QB$UZYdBqHVN;ZsD%n8nvrfq3#!F++4=#u}0w?0sDLAki?4 za)^Y1Q_E-L(M+p^;Jw_o936vmQ8KL{eBQvMozoVE2RLDYAXbYf%p{}TXmBIufLKB8 zsr4sH`&%J&f-}fP{=j62yhDu}>rK(zRMB87cw^3;dV!qkR+r)$FzpIw2A6WNKBNF?k)sR_bVFQY zFa4D`0%zM^sF8%9{Yq4k;DS*w%sT#_y1mZF5S6zdh>rKAw0~=A( zqlu#(%t0WLv^Xt=sOgezjBXHcul+V zBtKCK!si;&cQ<$MQdgK~;C4Lf4F}mVN*?M9t~K-!V~qfG=gVF!k+3I^q^x~cym(z- z#vxgraiOpIihZHZJZFVDpjYJGuGHBnd>&2P*HrHV0vTG5FZ(7;=7xsdZhG^%K*q3N zX?62l_~C~#laO2+6P$d53{&~}M{2YZ);8>I9EQ@ou!T_a6xVq<)9L*dOya!CAYAOj z4xUg=)o*h2lQjFGGPv@4leLqo*xCcD$Ys@#4R1>69eIJ&4CS;6H=3#-Ps+fRYI$&L zWR@oic^L4cbFd}H5@<7(WUI@^6fq{X<-I(Q-S?-yN4f7VXeGv{oCMySR670YKAVGQ z1TL{e_qFjj+FaU@-dPy>Ax7wJ5*dx5%`J9ofVd2M8kl31v<;}QIi^2dQ{xV+97Bon ziv8t56^_&`F@_(EBcAfJwo6EXrb#(K&^G~3$CXERVL|go?P1Uj&*7@ryv@3g%*@)LT(KDRL!-u%q@+)Q*O|lIma@djf@&pzO@*T zY53?&6d1GJPrUHa#Yu_g^Nwz1XfBD8zl&|^njGz>KBXyjorLkU4lXqq z1lBe-P!ovHnZJ6gT;W{!phNsnXyAUd(%VT7Edb_kmsxVHttXOwbVh~GV5J;TX<=!mdifLZtqD%AM|wP(@>|d33Lz6~P%19wz$n22g7yGr z#3@|nwYOdeCA1WxyIcv!xmjItttR$v#bq_eiX7iXX9?^za|LORZxusWLuo5YW2s7$ zhpBcZ4=Tjd{>bS>7Gh07?JBNt1ZD@FFjb>6lxEG|hJ*hAB~oaHC}>3vaT2ePLEOol{CS zy!hazYa2k^GuF80Lh+ze_#{qysNO*3VSPE0weY#LT`8g9D0$*d2-#8=c)r4zf1H;Y?Lb8dJRswu+ZLRe<6- zS=ES`_|J`balM#c#L)|$CC33xP@c(Sk**n!y_NOsCHKkP??Pbc6t5By)D6&QlFJ z`Lm~opAa6@{wa$}oo35G-b2!u1oE+B*o^YB&Ndppdfu$4Je2Jh8YMrNo_$H;qePuL^ z_nZ~iFq6Q82~mv;r;?5#VdOxx97i`rQLob46Po8#hUYp0hz@Y5 z>gR4MS62(zbNq!JK5STW`9D z=Nrwha+eBn-@H^rnhrv2yahKPu!%Eg1#K) z`Fup%K)Pe&u3~YmDyJfp4uJHCx=P^ps#@{{Z-e%KYBs;a+t7 zdB=LljAIx5zws=ag^htEv3rz{{YmYo@d5mHkU9pq*nD^ zUd#%GkEL!0;A?|`aQce0vy*X>HLn2UUR5dN&Y*WNy}Y3Z_mB5eKC##kr_NRf#_+<~ zns2)h-cZFmS<2JZw`ftSeF3#j_#$QIGCRT2j4I!)7dBDLE^v&q5HBB<&X_au4(U=x z-0p@@Dt?2LnDEw~SK2e+ZzGTFy~O=bbgB5R zMJHx+&&jXXQn>h9Y~{C%vL03X;dl<;ugIZc&I_~Zv3V)p69F8e*W>7`ON)6D|8=RBIG^q?tDNk-wPBxlf!l>-74n#DHsmvH&D=2fY$E1Sro zEMaSJNr65_to6Y?#8upQ#=k_T<61tS(%7@v{GY1k1{$r^rJ=`Sl4Ir54l3?rK0GrHL0Lcy}%Ya_da}8qU-L9;CQv>N8>-p ztkGW*JC$~#;LMjl2gL{*kwFZ;&N!j{g!c!`9p5)wWhK1$915gM1stkVtT^X@Y zbZsrYTiDj6NZ6w-E@2G?>PcwJhJ(i)AxOz+-hN4_=0&f>)aJ(oIFU^J)#nQtZrpoK zF4mNDwkt_xXa5WbD8i*I3kr@DJb99>LAcPpQnIxyE6?!4dTyL912F0+;& zJ-btjX+fT+@zAA~CX10uYA<3lCh|cpBJCaYO3o{GL!Y2kI3A9lC{J_RUt9$~NvWcR z(n=U)5;d(Y$2}By;#lH;!V{%7kt?a=@po|0n#o=cY74(h@#{~H^+TPA^(aRbbh<0m zy;|X&76#Sqz~j(A*;F^=^rOlH^Kn31^i#vJt48vT*})|nqzkx|=ZDY?=9*D?agwd2 zX?LYMN>n$Vxq_=IGi_I%DB@~)m1A%@&Lu|M)a27Ql{WV(m8~=c?kRR`61{|`4hGvR$+(Nvv>T-r2 zOvq4R0Zi=gK$A{L$F)19I`%kdp}`X&BMJ;Ehc}K%>E#4o*Ios==}P8N;EjHBLVS@m z%wwEr3HmB6Vwl#`#U~!r_mD1z5^?4MOK^(OxMsqrUB@1MAw1HV@a%GL0!LthMWuQ!O8z{<12fv=Y0etWfw_L@0bjWFY)y@!$XQdc$`6}O52lVX%R$T>AC z`|)*ZYtpT_W@z7H!8V0)ZZsUp!sQ;@9H5cU$RSyp^G2R@-B#1;T>dhR?9X3xCRZE= z#Oj`O@m_GU9IT%7(n{_&Kbc30deldn@d`ls4S3l3KRc5Adppp>JDaPH)w>Xi?)#Q6E9OQ|7U@Qx6vydAb~PCRy6zOI%zTPHLaMGf?i8Eeo#~R1}$|GiH7~p2O)1UQjR%XsAaq-5^O*tSsiyz#B{zV`kFz%H; zYIsv{Ck)|PaT8u+xd-HqP8fbLOeECac5Oy3&hVpnX&^E|T`m6rWoUOSNLtIzBweLi zrfV1rIP=RI0%WgyM3=6XbBa(`>K?S*Q=ADmuNssDk%xDU>L!5G3onEFD zBC76c9LGvMDZz~=lshw~#MrMA+BoJ>xvZ`iTsg)+ctjR_j1x?Tg!3oyb>o3;f5cUl ztR{?^uV*LaQI*&fZ=C#hgx$?dD24D#hy}D5W)(+_4ZK=LJlGnch&9Ip;^|lrX@y5V z1xrsTPOn8iu&;5~T|j7h14b%83bgS|9w~P|0Ye3=nbSs5W9}c_$3S(7N zRCMNdaZ!$ARN5TlnR<}?bCpV0mEI5s1B#)jt7cs#`Zc7~meIKWIuqF2WN&MpYQQ;87rBqtgtj-)=JQQO>?#$}N3)F>^o!0`r_+?` zIF5`^kkpg^v*abX0*q}IqtnibdpKVp4YH8kw|OURJvW>usfCzx>(m$;0#^L%$X&*mx&}tPDo*|a%0*s;@&XwG$%X3gkMAwqAZ7^zCaycxk}c@ zc$0_Jp>VUgfw2~o(}sVnCZF0I;bB{}s*2+anAn;xc_^sXP=!3RCKG*nekvg5F*>_Oxkf~4cpqYfZZmBlcUZ(=Y$ zK%K?7mmJ`!FTJd*X^%RQ<@J7UFPvf#qo{8T4{E;InBq+?jN?8lI+iXdR7UZBrbw>r?$M8I=#3B3rl$g_#Z7)gXtZ-4P&KfJYmC}a*irH zk~PG=#M7I|fDn!>4C6L*$2GL51PticF%-?wnE-DZ(>H49a9x z^Z3OyNm_DgPMW53V9n5-s_lhJK8Ws7ux12p3fCYsWChh6{Qm%QvPQxH@V92qjnUks zbFVTxgt67YyQ6{HRhy$9WW^hr+s+a;g1PYuR)9kVu4aHUfy|%J$|}Xxd(J@lI39F! z`BZ?BTkYrOq?Op7z&TzV=U327nxl^Ju-_Y+=RzlwfrwVP;@5L5s9Nv5%?HICJj zL2xvIw4nMrC>+||E_FY`#7%I`Us7i&fZdZ98hn*(sK<(r zd8*OMXr%o50qkX*@k??m>;mxV_sNE#Y2JUinrhIEIk#>ONpZ%3aBs5m`WgYPM z5;Fj;5D4zt8FXgOZyx1kYi~D;nXhLjGpNe$g%hTA=Xg;7(w!%~sM^_B(RZIzCe(sl z$7kokR9paKK>XCtwKSYfJBpHnoTt~L-U!g(<~K0%1!v9Tw_{b563RCIV~E5nNDck4 z$FV%d92{=90fjD=WHK-_#;4S*++)QAT$YZDg)|d^aN4C~yirs2G^;a3;_f}P!9?zo z?&a8{x+_N~8=&%rbU|ZC=&oc~8;=<3p3|r-8mBW$!r=b4Fp{4q5( z!%E`qExYkDI+d?6Q(pu(m*VprY54M}+Qv9q1cQxv%}d6e%Zd`j)|lK)_t0}IOCDQs zvk`}CgfYC5{7Ka+CILowULw_PC&2XerO2pw}E~XnF@V6U!@6O1n2YzW3GT!n!V|wcrXDR8>UgP!1%K>r5_i zCzXijz$f(7EDn09n*3XLH>A||fraMh&{Od$zl_C7(p+xEApZa&8tM@2+^vO4b|7lv z1F_DB7#+pemO#uo)Qol%H6ySn1w}86+EkjyZv%yS7u`)aij3C-D}bTUxZyM>50dJi zQf1izya6;BuulZ%Xs>2uDVl=6S{1|*i0xg9Z0F*M(C%dEU^ID8>Oj&T$^6eaaE^k8+Px`^tPUY)&-_&`CHon?h*v zo#35cE+a#by6U!>+N|!l*EHblbMl5Cj+G^%NZev<&j-pc95%0s@p%r%m{nxz95|t{ zAOWUrz^Ywbd&1Ar-j0&2zZA~y7di3FwOOSX~X*2-YK!eo&O=87>-2W1SlGkKjK2|>2Fdr= zu}KZa{BbrNI-xT4P zrZ_V81Q2^v2c}FtYB5S1qhhrqkByqU{I-%B5 zyWE`iNNCoN)|^fp$hEAJlsuxOtEtO7Q*IJRHSG$t6uje8pP4{j#46`+QK8!aAwZzZYCr^>1=B95+Otgf{3x%`RlYZ~_#v;g5=l^?~z+PCEl z26YP`afdnS&W%HXbsLFM2RZE_r^P;1Yv^DselY>6x6)Qt( zJ~n=I&@P+IZhZqXjogeF<91BhwkothYlp?=LGEd|0R;JIRKfMVUP%Wsl7_fob7sLfctC07{H!x#tW5=`?f(E5u~{2= zg~AMO=kXIq(i>EuxKsyr;MHYxuNCjc{jZeDJ=NX+( zSy0uWU~_ZkSehHBK7}zqM>lF<=iihhyVA6>1m5sMg{%qOG16 zE8~D%yjkM`#yh@>#T-*I<6{mI4;jvOE)G2ObwcL-M3@0jlI1-!-hX=I8rHj(+$e+i znCJtlDTY7xQt?c1H^GoU=M#-QiW#jj$kw^v7fjCNnx@HejxBSpja*aO0HZ-QBOL9+ z>iX51)Z-zAl5&XV<@2CK)EsM$I#fZVI0nm!=fEcpHWv$*T46XGYs&imq;EDb`r+z7 z?4H*(%(P(0!$&3Ayl+|hlx5Miw<9bZSkFf1K2+;Gv4HE;aRZdO0q&s8@rOFIT;oY> zOyhbQDb4k##wMQm@oyeU73cD($$bUmQC$cU;s;GsaNl_c$2IYBuO$~T?{jY5t#Gb5 zCg%;QtA{c*bIoa6+ExXv<&D(~+Y?@IKa%l*L_Q$bIs`X6xO(PieESJymBXy~|8@LrK&Ni(4!@XCY@lb1nok+$9U_-F&Q=5$C z#_(|QaIPMt^M~@h-jPfWdzjh^5NK3-BHU_!HznJpO{hK8Lt%r{n5NmdS| z?>RG~o%|AX@^+~yOl-4mIY{1@JGn8H!ti!&Dgf$otnlXO3~fH8ZiI%?XGdP1OTXP) z1EZkg?A7uMf}XX2&l`dIm5xHgQ0U!S;hZZ|E|41MI=wY?IDKDQ&r{_?Pu`^gu4_b& zpeCm}nxem%$(XAXUL0F^*6GJFLs7c|^pqx_E8Pi)=#7cP>3!>og1lQ!7l$hl4l2OP zGK`tDx+$H_Q0a2X6__|U%0nsQ@{ra$Rz-6pyj=rH6wS$L+E1xGAKe|Q*BfQyLth^( z3RjX;Wx~2tGuWE;Hb>r)Hwp&0oWsdP$464jgPOF!EOP)tu}T_fOpK{H$a7D;6_(E( zmQFOuf5)gTEhJ%}sc*F3VY=%1Ft605bJ|*5=MoP#IhB?JORi(9hCVR=0HiAgyh7mV zrjf0CGa5FSe}wvxWQER-Zcu@&GAEc zD8)_$qs{R{YKAE?gKW{qjCb2o`n9*V1tp>>T4XS>oL9|g4Fad8fF=R^v$|A`aC6vT znxUqI{9C!x+}5}|i4`T-AJox8_}O@YHP+YoZ&oJ8DR9EaSDb+SSN9&`e^hr0whnIy z9mtEM-gD?YipzGOUVeQ@)-A@J;W30&rIH*ELl54kkT~(7v4Sgyj1;mr7Kuo&WjoU( z64qBt`_1U(f7t}~HP?eL08c#>q`v5D8_m+x_?*V^W;fNtRGq3?pO$v3u6cY_o-`?zNXQk|xy^Z%SWBJG2RA~o7K4raYo7qDrLAyzv*JszJcP=eFO8~4vU*~92NO%Jsr+=P z+h~=M7Q7Y!Ja=<9wLl(d`_8L+Gk!ZzERZ4uo`T>6NLVqHuVDB5BWB+y&} zntkXr;LO$RT!&u5xb}U@gcWk_(d8<&z%>#ma5N*aG`k=BsY_{rYIHGpt_7nX zT7(L6orvhfFB_pu*D`>(n%A0%jlD`g-Ex7qs0k`C*Iog>H%=?mqrV_#Zf~Sed*4HT zmwH7;C_X+&#IzGt9CZvWp}2FNPI~k!OPbir+#PwKW@#9eJ@FfO)-v`S+>?i>D{QZG z48`CEhUXlFOmkkf-gO}ZJ=Y&}RX2=n!1re&xxenOin)T0sQI;ZT&*t#q0MWrbmECt z=NHyV1AZ)X0V}cCptD<}A+~TtZjJXzyh$}qy9?PchF(?vP1~-*oho+AD2cbE<&j!o zQM&wZw+__UpkRB;T;te3-CE{-lr%QbSu44k>gHBhE->+K^bsy3Pl3~y}MW~=`I?xG?0s@D=m zYTa!|txb(+zNSaBM$UU@5KtVwATjBkD!S9 zO3Y3;yJ1o_?r)4n0Zd(a#59)C)TSKjXoe-OudFxjR(sOX&3GZS&&pGEj@p3oso7TJ zd{UU=;-VLYc*9k8`*Uf5IKQsq_vU6i~TTXon z_9x=W8nw=v>_HC#p0vXHhC7TRr|ALQR8qzq!9#ZOsjXAV0*<{`b|}jQOQ$)s1USaf zIqxmvM^Vr7!Q;whZwj9Da_k;F9r0F)*4z#4O?>e~LR4?(Hq z^R?ThPe=Tg;m^#RZ4PrNTXvlIg=>wmIs9aFBs!OHD#-BC;@eYPMRW3kvxmha?n_Ze zP-i>`O0)W2$%+2}j)i3p%e{^@;nXBG-AEmogf}nnDDXH=`%1rsn zW{|A7gZaEe4OXJiXiS4T%AwC=j~8X<`Xe%iL*mHCmj3{iS*;-=FA!hmL0R=7}!nSm7NYJ*FbOpY-p z9Kk0WT;4Vm>_b@XSrvhoX=Y)m=j1egG^r{3leJprdm6_%@$yC=B&hU#>I!`S0J$}- zdAjCMgl`Ip6<}cfSago_fOzn}g%x;UfTY=5~FRY+k>&`ccapyOVDx)>xp{GxHR_4kW>04-$ z{WDhnD%;f!;G@!r{u6enA({q0Knba?V%DeGIGEb@S>to0p}cC=k`I#|iadX2cS;bn z(7q{J2sHhhml`W^@s^6`sNz2Gi(F5|@$JLN$EXZ6;`rFE(>*BqUh<>O@gM1@yP44) z!mXElUkSp!7$EyopTsrtVIF#8)7Y$)IMw@+sVAtq$2F$8>hJ#m#I8BRl)~nUbQVAU zkfo94KL>bc7fkoB4F3S;`jqUN{xUl4-Y@Gcgpd((`u!8`9K$UN(Lm7I!%30Howd!eIy#H4#+wvzTTI+zT4}|RN@rH9m-yd{zf*^dXsrCk{CllX zyAS2RdM49;jbkp{KSU=Q_@bHlh$y;%j*@ZAg$|EVQV*n5x;@~UYc3JAJ2%ZAdgEJuc!w2f7IxkSc8`qdr;$4665Ex%O5=_mrD7JM)8pK^=-0Vf zuTZy$!~6vXP~UUf8acD6V!%DOuTwQHH9_LL8BRwKmv0&U{--7 zfU&N4&?r-AaK1JQp8eh|RLFV%02ER^?~$d|Wm~FLg@xs}8X8W2lC5@!u)apTfn3z&A#92Aw$fhV zdbobDjfstw<=0+mhJ?`Ap=+APW(OAXfd@_sR=hA)&kKNNY;oYUMG0m1U2R-*d~5#z z=_#e{f$e;8$SYg}n|N(cF1w5qZn;`D~dlxZc`r9229&#>6%C|uZ4Ra*#;wo_d*6IHpa)&en5!v^ zjBEIo2Zv_t`p{$C<5tr@l|=Dc0!}@NjceK};x-{>zTo))Bn-!tPQn)~xCXYTqcKpm zr14T04I~fuOqL0_x5m+R-1+H(o_j$rd77o&Je3TPJTg${o6XOU4N6L53 zH^y}Vb1F+6T4wQDr7Z`>Z+bVz6gjjh+Zb*8pDQ4t9gh#!De zy4Kf@E+$A_dp{|(3qQ+NU~$FU3VCZW;~kHcJQ`4kSEX7FOQgd2BMicnR%q^@2;(_b z%}Ss4Q&S&|cGS=@<#(wW9S9CJzCMy*b{dzk2BB8PG0tmE`89 zl{N92)r8U9;`iK(i;3u}mgNX!53TJr!`7S0XC>`xshplxDiGoeT-jVJ)_>hX*17k& zMAtPjL9UU_j`6(FOnOrTTv}T&aKog3vIhHz4tN*2xer!q{_yuE@lK`ah&n3eYci5w z#k|+0TfpXyJ@A`Kc*l4tbRE|q8%E<=^Qp^P){!+0Ir(aO(4H~-#X~FZwXeH#lns4L zBs!VkZUa;%)Eq;10t1~6oIBTyJYuZTUcfjSA15hE6E1=XcPQ!m)KZsIjpG{Q=)3-v zApW{{%5N6%*HB_lmlYWDMYz=MSMHTJRz1~eGsYfUa_vdMTb}1UIw)!`oDU@D^(!oW zHinG<07xifYlyuqbM%wI?JCdddnPBVD3IM@wepWZs6ni)T^ry2cLijO=W8C)G(E~a zUh}~wvv#Xkd`@elvAxBlVk?Jwox!Arf=L55Eltv_l>~f5S@l|gXe9Bx+6v0dF;qCg zR}tclWn+=y!IDP%mGo418)+}_M(6Q-Tvk#X!ZGb5;BGwXXY|P9hLvj{J159L&edge zTv~68K~2Rx;Zp_exT|>%bDMeB3$u|@ zqINZV3!HqoD+4T8#l#ciUdH)WCkYRcLhg4LJGzSl&(5Kz zPk2_EHu=9^obLm@Tbe->i=SDlJWVQyU~`?t3;?Ol6`m@^gKtt_C?9f_@;T2w4#88N zfHWq)NF-=$2qn#>4bS&YW87HN9HGtzl=!!Er@7Ks;y|TyexvV9me+ahaGlRQ=i;8M zWx{dsnrb~tV;lbfXh9tGrYV8VahTd4dDO$k{{XUS&7D5b$FyE^ z=+1ByU`ZPUP>KPZ)x7JC5!wuw9evc~X6Ect>z$=NjO8`2AQR4^v^CCjvKqSatqE;P zv%2e=FU%uK4Fi19F+(uU&S7s@=U%5JoBpPR8#=&ju0L&|GS{(i*rUybHc;^Qy4vbRWa z-+MZSISuOW+)PS}xw>U0$5XTs|};Q0im# zqvVj2>rsjt-WyT8LwWHyfTCoybFS9+K6fr$gBI0ZVdRkPYmdqM)zN#kK1Q-aNEp9L zEDUMA7#X~X!;M(i-qTk16(8fBTKO(KreNIs>R8Jh*9Kt6XXaHEyw)`NDOZ28vjgKb z^l|CTqp7I@na7YTP?fS7IO#D5Hu!l^~7fJwjqcfF2vH; zuvN6>AEZ%j_}fV<%Rfn~ZaBhA4~r)?YE}8z@Z#FLjTEWF5#&!}n zAXJf|TKppLvtK3GM=HFIe^M4#-VCdbY}k^llgBG20qB(I5=q7Q6w zx|DFeKu1P7l(H8`dG>gnb~A$oXdC3E7ecf;HwIFew9((Ka*#Sp9akF@ zqLu#uWeXT2H=oK8=XWL4K-n2DgO)si{9j9gr2T4Zt#t!A^Z3nHXyOtw&Sz*fIMVhi zc0n|akB@aKaiDpkWRAY)IkTGd@dX$l=AG7Oc#CskVu27I#n%OI^#%24y9lD1I;z@s#CeD$Ll#gvT2}0CEGq zkCO}$B)m8>xa%u9efyWq>T~8-lVq3udSpzgE39WNknt&L2@yUzRt5;_gYP8c};63(1YfjWsPasEL3fN56@+EmTH(TO4C;p1ja0EYtdYh5TlpB|3%86k0NZieRaraL=24@LCn2DO0mHMHhT<(9m& z8XjxelN|Y&UL{}#xE~s-CpKm@4mE=L#B#y>X;7b&%Gy&{{xC~;-3`s!ghjxVC3Kr81ISl; zwsAw+7Z4n7M=821Kn+g=w5vCz?3kbU=vFzN(pep>JdF4P6&TX;*kle7=N}sE>I0gh zOgcU>>MVXwdEju*%4XG;oY9G}7MhWm+zTj&wa#gW8@(K4Xa+QZ3d!%d=GMmu$DWM> zqtvX$?Q@#>-r5W^GQI08S@^e+Cxe3}D%M@;)6B9H8frL)pIBj0ILB*g@ETq zb1bLTk7*juL^Egm(}P|f1C1Qsd~>M{>V9JshPQgj zQZ_j*iz`WS6&&Zk>r(Y6YP3Z5-Eey<4uYa}FuV7tMLtmOPl?5(u$Ge3hhjY#t1~0w z&G}|)@6*MkS-!Pl5IP!DoSwKT)j?x58jJ>R_)`pgXJhUHDu|4p` z6f(sf(k|X{Xf!$Nnb`I#75lZ~-Qw#NWaM&0vSUqEldrlWTV-gZbmWY4`BR)6%r9pk zb0{11>4uP}0~$I!M_(GtBH>^K+=HFGv4UmVXGQR~B77c>;|oV7@XdIERb}|V=J9kC zJg#hi9#v?G_J+phS(|z8Yp6V4NdQz4nE63gxMGH58=f4~EUhkSxZL8kIgiw(4Qrfy z9)3vY__ry8UwC*sc|c*F6x?vRH?newnh@JWKvSOwum1p#{+c8`rt~hT0M)#Lt@!@{ zIQx+osQ?>5|2@`jg9BBb?UBgn8+4)jK_5PZ=%b zvOGD*DXHOr9#gRN3T<1Dg-S^W>7YeO2!d{2y;6C-N#WIfV{Hp?uP4fll(K90UGm#Z z#Q3$Ulr~7+%Xc^9@D)LfSEh-EFhjZc!-iqgp;`EV@XPaH^Hbun0Bf6}NEpW#cuQ$b zM=Nauom)e*9NdweInH4*-sMpFf9{;%P20mcugVFuyGzE04uG6x4lU#3@eV&JLStm- zlU`A-7^hu=(9?;yT5mL0oezqTU~1wGDalaaa~@@De3TBmXYqO{X`9HP@!HNdI+Kmj zMyod$Ha-|{R69Q^oZRAxw3HA)E~BaPMr7Bu#IT#-W*`81cA(BJ^|fwlZgBqWsKtI;b89Q5vN&8uR7xQwu5PM^F;MSZ71GPR$E&^Em8sL6! zI83f?Z(6mwX3FMXW)T`Yomx1i&Kbqv9%}L8=Tnj$bVACJuAz$YuNXiCA0mh}JH?M+ zR*`&HjgvoW75(7EXmYWKI_STg=2l=FT;SJohGHsE^mZl!9eUyQUUF%D_cHkyblzfr z7T3H~j5=wL*0}bvKHx(9?0ll1s78p<3G4hqOe@KqKWI zltIS4>Q+lUaI)d$xD!u~Ip_&Zy7-*it#wWqoZ}vZUn?QJV3mW5XlU_W^{2&Vb@`lL zZ*bD1+a zV>SoHa*FdDM-ea-!xI$-)iz*wzI|q^Gn{UROxifK(DJ#HsWT+3@4a*!IY*=@Xlg@e z=kf&rU}Kuui9V%FJt1mpIUqox_~`OcY1TI0sJkSl^X{FRJymtW>>en?XX zaswLLxP!^AWgQ;Uc{Kx@I%98L1)0w`EfSg>_O|SG7#vOP*ee$;t~X->=*^!$m+xF* zzdM;}Njfps}2A4SU<1!?70zK;m^)u4YCP2!o+ zRxF24=*`?}W~+As8hgV301j@|&Ur;x-f#pv5^2oftqyY>@Ib&C9(4o2aB(u!^LVe! zq^5rk_sV7j;>-rHQFPKR#-~%6kIkT|e36fJO-UHb#h`~*AH`G-ybmPy;1nbp9y*G4 zk^tk0yV`gTm1gw4lM~K=z*c!35S6X8)W&l=HVRj^2EI0lqZF+P<6P?chziXxZP50; z*LprN*eG>9;;~QW4RP;^7cBHkRI{<8)#=YDH`1 zQp?4Ni`?gNe>SHwBa8vX!Jre<&$HH{jIueL_sHH??EFqp_5Df$W+A+uSk4X1-OAbF zj*Om9DnpGqRiepMwQ=v37fSa0*AT^GZTsAZ8v07c&j*d%TjueNFOzo@qW9FIcwvR*HKy~i2OpB3EDRC?A{00q*V^r^qRO)xhe+2v zj)GnlWtWQ-aTr#nm=9eCYICD`W5fdOInCg#oJ2ED$l(K!FI}!bdYPf6#nQ9Q6&W8L&4)>`?9Fp-&b0(|$J}K#!2#^`! zB%JLuehJK_4}<>zrmFU^d_gyXtwCWSTViBxGCI%$SOz5Ff_#!$UI>;?9kkC?VJwPA z8_#iQZ#BsO03E6*>{&tfhSArIlx5sM?u0CqInHE`bBn2nW2y&}6>73a#RrI^aC6=p zgKuNv z>Q=KG$O6#j>)#n8a@`dq=Xt_(R_2*L6w~reg*5|3^ zV4`%47I!tbgpJQyoD_0ROGJbl!b9cGuE!K?V;xJt;_lBa0*Da6ZsuvISxNFpOtprd zQ+eIW5nOLa&-7ZX?Q4xrP^V?mn#M9-QyWkQen?dT!K`bGx&aL|?@hS!1}$%I{ffHz zo?nS9IkOU;+oaub#B&8ZL8uMD`b|gTLny|Y1D>D`{{X2lk$0DaK5r$MDDkM}cPm?X zphtuXyUBO)>s(|kalOXi&Vc+Cl*^_yr56h_#*yOG}1vbD2fO0MUMwJv`r9 zEEqeF6(HspSgha@Ug??xK*aPb7^lmV-9qePyqv>IfuXn=Kj~7wQ^hr|YlVAoP2@&M z7#6!el;*So1)@wkd}R=Pm7`4$U_$eN99y}w@>Iam?OUIp#!+1aeria=pNdXv+edN4 zRP-qA0(MsT7elc%G)*t5V=%mVyq{Opp)pt-^6pucjV7F@a(mMv;ze4(%5#o<-Kl|v z(Ks17MAHw8!|FlYb`|k&c@tY4tS$}t*t?XR_vK5j;+jK27#euJ%0Ol^5ZM87^b|mV zYlV6-SwI)u_{i}ZT&6sAc^x_vxVbN}>`!D8Cx6vGB*kblGkU@tl}F4Fkg|EZhp>$R+?3e9(ax( zkz7k7agbdXDUXYaYq-+RLCvh>IQv7QXmRBMUzEis=g)WtlKZ|k7_K^7OQd%&2K5`< zoc{m`k-)O)MH;Jo2#OBPc&XJCB4$ejZRh;RKb)AK-nPBv${N)|DmFC^1p^(bSzc=1nZ z?Tl4UIJ>8d7##jHRiBKPiesi|HO#?#IxKas;!UZ|<3$f9`31|tyXx`Sw>KNZ&oKuCiW--ibge(T|!^Q)##GwtP*O*;LT{oyHpR+n8_Q@>ZzDAPP z*9(HDvkzJfIj@{)^cNS!7)fhLal-G7hi!k#tfab(F*fL~WG_p7;w-?{g8G53b#Z+@ zq}Zp5;H~@5?9)@Y-W@w*_zf=+*_WfMlpb+6^P)29_}n7anKjnC3Q{{WQ?W|V0TsXxRYdbD-U z-cj^l?^dXI$sEXplCgocyzBXsSRCp)j^zq-xnh1w__w$V+{efi!dj9rb6j)L7edIR zy5^Sx>T|gkxuL{F;eqG9O82MPm_JqrE1cD}P&^y=njA+*EQ)F}YJCq=`mxQK{2L|KJosJx1HaSRYF`iFkRC@3{7hceh!c({4@fIOV_ZmS*yVBjk~8A1 zyqbP-&90{-_p)ykG_hn4EtQ*%R!82i8w+2f0^fCWXYW{j9DQq>#wx)hA{Wb=IL>ek zls{J0E0{}MWN52%((At+iIJD!-a3HlIZ8jT9rB~*#y#+%82GU6H9MP-{nDoD+wP%s zP#d@%Eml_N7flK5Mo>8s%+Pd`{>sq=-xSU{!uv*=cW@ml&FOn4C;mDVW~5SMh~~_# zjGB3&jk3%K7TVo+7;qj-s=#A&oZczdON9v>W0V@FcnzeDviP@-17w}M@w_*=UG#!Z?%n84L^Ey#}_j9B#}Z# zGL?^UA(ja^vC&tys>)hOB0-xVj0e1elGOdbPbD)jm45S zK<2n-X~z}DSnC7a$27VZ@|*z~{b5*nAV%q1b=>EaDxs;ae&r7&0siGDXvrJ)DP>W@Szog3W8@eUOSnBab;E;abZ;(a9zML7bWTApYjZK;qz%koNY znE4#tH)ufB3FVR*8eC}dC|o*Ip^nWH-1|gK4s)nOoqCPX?tM_K)=PL022Js%K3&jw zy5`Su`6lfRF8=`CS{&oXInY`Oz4DI~^($^LQUGuBpVVR5-cva(cg4?v z&HAS+5t0@{=^qQgW2I#KiHf*+Sc_zhva~$3Up^?I!AVGCx+Rj1X~bgHX?U|mIRFx5buQ;o$uw<^ zrZyK$*Hz$9p&HAY8hGz=Gs(m zIZo8anA>dUV>E@tOE_(D!t7{PqJs<+?qm#mJS2Q$jT|{09vDOX~ll17HWaBQr^oKG?(u6G%GF>g+Nfm!by>@>qV%2aVL zy$JsRGn7@&Sod12w7~xW#F{_KQy$6W2IDaY5y=VdzWONV=h7DV*i!*c?^=w5US58G5s8EXM=QBo zF1g>z8;4>f%J^IHyryUI)tRrE@&?|z38-#l#!oztWOhduHQ9$G4TVN4^1g0!&Go7b zAIFM{=j6t$@-glXY|Sk-#2Tv$tBHKOT9`wLb<>48xs)#L&mi%a})`Ie8b#ESe zU20>ic$dgAJz6g@i@0OhnZ-oV+rBu@<0-#0f!`J57f|NTi`)ZT=E+)VVuu`m-DNAR z4{(+&9B4DSNF<4YmknEoo^=6i zu8)xqDY?fXs?_JpAa(g12RAyiGTl6oz~@#;7|X}SbuiMR7CU6pIkQK^2S;~|`PaHB zj+JCIn2)dQUQ6``k+12c09%ux~5fQmSb z&J6(xqRnBm+E{0ld}FTH z;iFqP8GXdKHuuQ;(;jUPl1DR?955a>9;$lPkUg{l7I!8HNTGtRsHe*+%JCzN z-D^gM~gd++|PB z!Nb&`jm&FX5CM({#>f4X&XJ&gbkTfbe^zi&9N4*7YAZySddbl~HnGlRPHhcp&>chMQpo34d{($woT%j(N<^eUz6Tx9v*{MPnFRgFNguI0|VoI>o-Q!Fz0W2%}!BPNiNmx znmQlvf|QDWwVND+Yt1&{Qi}foKRe|{56+K$svJH{IQk7wdq}0%3RfQ#^C_!`#W&qi z<4U+~YRM9V%aO8IqXHCdsk&wuSIVuFB>Z!>@H78j(TNv?q_Q}{q2SZPK z$**>E3yITx%Foes?cm~DLifuX0IxZ6v{AycK<2#DIIB-`v{@|dYvi6MGy>{ob6xJK zV3D_98_Qe)tq3G6muPc9hG^Yz*EwmSRyi%8@ej2~*O22}BW*sitrjag2OY;bp@rCB z4RP#NYaDH)wsyIbYw~Y@ny5CEH+z;<>KpB2MYX`^38TyRra0%~qi?B=9m*!KQT_)r zUQaz-tdAHTHM>;Smlrl^A0yf-K)J;^&Hn%}3!{{pII2D5a_=67VMH_y)fCt6+?lSu zO4B|xR+;SX<05k|w76##A9Bg|cLsqBqJR#U1D~Bk(T{SDnfbo8S`Qn&WVTxv`9C&| zq_xGg0!c?Uen@IIhMGD!i`q!9C}KXfC4YF|d1TD;HajFy$OyZ1wDN`-{{Y=cVUCfK z9YAY!w8QFBwf-=14yR31ycyC;sk?pPq<8 zYG@9MOR_W`gNkDg4`6d2jd8(Pf%&dK=1!P2Tw!~vPtu5YrcS`q#E6<4ak^a{2=e&9 zQf0hE!4u&VNO;4_CABC0m5$M8fJGb2qw&kC=H0G;Bbtow!cZC=OnD`jqxzcv08+{? z9xI}#^77iPQo_((XJ;6BRHJ$H;1Io6H;5Pi0L4r`rC^1wDVfI&jcT;seb)FG z1k$v{iS2j->j-!F6pyu4(D62dAZ~Ivr>R2;@Qx&3=)3V3e!Iz}=HjRJPf?f&zsABH zTHxZ>hkaX9h%|45)PLzKqIU@9v+^2P4M>dN(aKimjP114eM;42zRo4`zVNs!gR7lb z1dn07_n#07(TqOvP_on<)>N4!ZENJgHcKRh&2$rD_Wclhg0#OWzEBJd5;cJ1izuGU z1oOHVUw`EG~EheBb?vP#p2v(II~Z!Tg#k7*=cj*12L#=>h=WgECO0hYo!>T1^Tq%<#TA9 z1aZ^>o!V5awSdRTEPOy|s4wMGs?Nu|k_HqM&3F%~S=(rl?zFX+NXCtA>ix;l#z>Hm zTn{+KLM*N>h=X*$4uzDpq26*}j~6sX>Q|wyR!V>OF}hP6(9$Dg28y&RQRLk=ceX-) zP6am4emQ0iYwFXN9jq~7%IHHF-U8do`^ zd#a;taKG*`M&~}X4sX-|R=34=t+hsRDkmF-q|RD!`S|^6&K>l*yn#m)hmzYf^J*0U zX~T_gH_}E{c)k&J+NF`S?N)aJ0c_1R?N$R^ILT;6mZo1A7984C4TblZ*VKcB+{y6I z;)gY@py&Jq)H21KjKh6BH|t$cph&RHLj3i!;hk^9IE2nf5ezW zsCb7of9a>+j9rd;%0bTC+5WJs;h4Vt>K=iK`htnG2DUkYb3irCPx_UX%~pUTsRUOW z?+PnqfrfJsb6-pV@lF|$Yh#W{F1)#?k|+y4@~^a#aL=JWNw4@_(bG>G?n2N^vEG5N zG{>5$ww7|DmN}?TJsiQ@tn|%Xf5ft>kdQdowz*0Pb<3E2O2{&Mq%#hRKxaT{Hdl#I{RXCs63ByEH8C$lH5L<6XPy$AOTGO04EgolSi3r(}mEB z?A|TP5qmLS3ukLgj&1-izEA$SO^VLY`5Z$)CYY5YpMwRK za!AhJOO>_GbsE>oYI*!`y>f55$;LARO?*?{tg=}PoWmRM2Ui0}f3kpE-|-QAP`KI< zzzy8XCo;nK9xd*@dA%UfS!ayZoI^o0rXnVT^@6Pq!2DRm;quyvnb)1h0_Oos!~V&J z?dCROZq$ml_G+s=q9y|a;rZzgPd6Kp14F3SSW8UL-_coya0A$GHV_n6u)0o}pPaMq!#OSF{ znm30&J3U*?L(1-|WRX*hmCkNF<=7qNSz8&3#v0R3YP#=kWiYkg3^I$iM?K&BsJX{0 zYRS%hZivO<-iL0LZpNw;iZF!<3O$KbGT;hQ(;`& z9Gz3o?zvl^i|6d@>N^= z${L@d6puJJ5=wNBKIC2ZK__+OOJ;46Gti92TRe;Fg zLoUWBGhP1xx}M1p4wH;?+71|C4$!QY5^2SkxMAfy{i?+4K>@PJ*_lGy)>s~`ga<`HFDDSIFjwH(=KjJ89XL|gdy_>-qn^CJKU2y}! zTZe0q#-hF9492&6*Q8aF%3kg@(#AlhAFdqgaZz41l+ktTJ_Jqd%9GEI`e6D|;pw$N zK8zcUPsY55?v*Kdpmc4iIQ-Qg5b+}k6ej~(va2H38VQH_f{33Nj>iuwwh@RjyBc|T z4wYuo?_|XNA98P6Q`*_fnl19sTsZPZKkTwzIpBA+dV;*|J4zaHQ987xq7zzuB-gmq zxv~O%N-3l#iw%_=5vL53yR3>co!oxHHXQ_J4%I=t3>W_73VD#*cGNm z!+Jw$l-%P08J*9WT3lIi##sp$uTb7i!{qd=s(*mrYZ`H{I8EuV-k8wazY`OwTIXX; zdsJXXTK3syyz}yn>=l=+Zq*cyXluQDahl+UFvGUTD zQ#GggyWQ%=OGEP8?<+kp_@SS*OB60^Su|0J1>5y$XHPZ-Obaf!J-~|An4+>nFWww} zExY3K*@u^MRXL6|{AXNz$BmLg9P>-HCpy04%XQ-8^Nt>+VlTvoxPO^P z8{`d&XGp^9wY$oV-M%l@jhUo|NyRw&EpYEp*rc1{L}82ppC*)6I=--pq`8F%bs2l= zx~&jK1W*FTwlj%nYaG^!^XGEPF$uWB$gY|;fI9nXE6$~5np=_DSYwnH)Ry#lGpQoA zLS=|p?^5;Kytd-9d1apHb&NQUFdvrjU#sd;zA)&mCMp?xVBvUSch%}yO4Hy$k7WRN z9@kyuGn=13*W^>Q(2oYRVPD}rS^#Me@OF~!kyvOp`p`eZo7p`Vfj}*=*18;=)|!t7 zz~-;TXIVb6UHF-~cdL7o(alAaJmFd@Y4Ory+Ez~liWkYb3Uk3V7*|RUo!5kLN-hj4 z+r28-_Rd`|6Bsdu90lfMdopfuSCDjFDsPRb7Ma{ZWjFhqJ?2-wCqDRX zIY$$Rt#Ni~!&+f&c@?c0f+sP<0Dgj$UNN?hH6po-s(nDFS=jIcSkrc%DY`3#YJqVEvqfJc^^6z*ZI`&eyAr8xwv zVd7_y_vj<%;6m!{{RwhCEX*VwGj*MhepRbqdz1mTGxgN zA(5}3tw3o_5lld7G~{;u=!d)(U318%JqwC|7|ZPA4Mk~ORj-MR#T~)OF1_-ai^ZOo zUe+IsA2faHIyUIzIU>2a3{mt|-OArE4Io-uoY3IZ*3Tap$i&1jI0K8-nu36be5&*) z@Va~!Omo-b%7C^?o;8GW2>D5_n-_~vCYiiglr{b9R!wHmm}ZkB^~gPlNG zc9J@PU9Ng!oaa_h*`af&E}UILs>eBwY2>i-l$|~kAg83|WT~ZZjQXex=fv()ht&AE zk4ahMVe#Q`$ES-{_^yM>B=WhrB|>_+VOD+LQhdTd<_owM(3JSN+swK# zyHBY;%K4aKWKE5t=%{Teu~>=y)uxC0#;Y(Nj!{`(IXrwg(wy#GT+oS_=9;j{a=3K! zDs$Wv@?us_V=Rtop3vsJ8HFU}fuH2(=jm29NXzZwp(I|WXqsxIYhEscT5{aFJWM?l`kD^~kdN`XGJ^5E>SIro z#Q7Z^=Qvfy^(%ss&lM$?j&oZhjlf$$%yTEvz%;cwdpy8BptY|p<|3K26>gr_X5%i& zbB%0(P<&n&bDb({O{T=&Iy=l0i$hHVNo#pL^bG-CeO*z>=Ek84+FTykfD!RdH1V*i zx632>Z;6j}8%#^;Y3p2rTKy8OVa}9&zc(&UDb9@Mx@s}<;ZfSH(n}0l|uFDX24x z4ZR~R!%lHIyX7M@oqKG&s97Pb3}7p4(Gi`ed=epUmSBlq0Puml4gSI z&TB<}wPJB@cttV#Y8JXd#x>Ki%mSctM?Yewsg1KSj#gFJX4jPyUKD=5n!PPE)bZ%vAU7 z?t6I87s1{ya*roB)~%(lF>!IYwWX!kusr_zL?)yV3q0f!mWPm-+!2DCHAeCZUeM$#62DmHm{{XVFP*7e6>KLhC z1cEnwWn4vh<50Vrj>olR`~0Scv9!&AXcYRB3&=ht02HP&f4ZFXT4eSQR!15VXK~$4 zm8Tn_wbRG_$?Z5+Cc2sPuoac*iST&+s}R!d@U20{=;DqyYx9+)af1hs8X`}M)s!** zbZP!IXvg-lZxmgIC7w=|`8aF*(VLA~GXMy)Hio&yT&hSE&V@)gw>N4y{GzP$80zS) zF*G8$D~BXHxOF2TRJY1GK9?mwP5B zrepg>X(ixt99!jco1nO?p9p0f$4E=MY8m$+KZ)F}lCgoTbnY!@Fcml_JEo{;Syvo99lq8y z@n4kQ^xAoL6w=~cZnr+DS8tOWZ1Wa2xHhilR$jHcR5q?Lh0{bNxW?0xVsSeNM_EYv z(~6C+K3rlR@^{f8r%gZEHKEsBWfU~baZ=x4a3YkTD$IY1&7niv+@sOny)N~UB4n;( zcBSE}c zU03|$T6w5e7M~gnlD#Bsas-sFMOFU8>66W!{ZJ(hhhowdlb`NdpN5d=N34$o?Ba-Y5JB( zaL)S;E&5NH|x)Gv|S(`I!&cy$tn%$B;1eg zMo8niao0%cybt{<>l|(fWxUGZ<8b5n=~mKQZt)S|P98H6yPuVZpyccGxYFA=;_;(- zWAWOXazfKyWk{rSZG1$~ACvQTdX>hqg^^ux={Evnp@c^ZoB)iP{G4(1t4raAr2iad(S=Pw5j4Ie#0(MNM=OlTwz1(Oq~ zRhqK6vC@N1V@CuD&*4J=De_|IQDQ$>7Eh3pDLjMLf7wP0+)dYQQ3*U|yq1HYDBBeS zoc1&m&5Cr*Aia;fc>N*&0Q{d?mk;N!-g2@QkCN?;LYNz;Vw6^+4)tb;NJE_ynjO*c zYSPEL`?&n)nxGwu%wF1p`cQQ96=x8^#b8W;Qjc~R)7R@(IPB08c!zQr1`TYI)cTXG z&zFn>&l_BRcPgkg&*I$prFl2Aw)8`osicy7!MAHsGgD@{WFF_yiVlNLs>p zmRB`1(zR9`IZlStL;d4bo)N@q`z?sJQsS2^70N^z1K z%xL8RXh^Ois?c2se1Vi}9@C=;LtX80P}&Im4%&T6pVBkEBg&Ly0$N@tf-PCwy@?IhCd+vYdeH+0X0U z&WqT{-f2&ajjtbAR*p!gfm#W}=s9HuI(|A(=evzO;_hzFU{4rU{v z%s%peD*hnWzC;hwJ5(5oadxcD`YG~8^2f&VK^Pp$#`BHl$(czRbGY|H_wl!}0^#KS zN7k){uW(~bm2XM8`1K&m=aOkts1#!3OT%QTdqaU7nqYfpl zg41W?-y~dkH>pOEgL@D*Xar4=Fp%z1@s4B6rnwj{@Ufqp;?z#Kzew68(4C>%rCaj~K;LS_BjmWb?IG7iTZ{0o zXV#|8oMzDEP1=9{E>`}VmXBhNvawSGecP^5uv=#3Np_c=h`eYs{wlKJi;Rznfz6hV zJhxHxC1pc!7YSh%&zv~-C;*Ozt;W3kXzo_9Gk8Rs#YNM`4XWFCRGu-o^K#0S&1KLf zr`F`{RsH9>qhlX&&THu5g%yp|pmov*tueWCNcK51%BCI4D2HGPO9Q?Mc zNvR*4M}>Zjh-jPHleZS%*EyGxcpwAH#m(Olxf6j%-oZOReQ#-g;G7 z2xzZ_yn7}2Ju3+g0&V82J(2D_ORWHk;z_Omsjba$y7CKJd5tmPI#r)by_*yCChL`G zmBvmiTW-?0Ub^LytNVlP9<169(t!F%mOAMY$93HX_H||?a&Jveit<0%MKhZlgKuFk zi{h0mtqlS1E~9e_lbgt_Oliffb?r3?x$lW_(M&tjrnJyPWN|cYbA!yLd>^YWac&gC zxFMlsUpH^HjXaVoeY$>9XagFZg$taaaA(yOQqJZvjFJhQ+nz6t@>Pw*MrkW>lf_n0 z_ej%qjB$|BjTdq6T|A`|TIPX;;0`2RW`5Ocb0sbt;{eP16_5J z9#gpw@qD8{saoQPi+{#O>c`_?*Jk=S#U}&cj}#24;RMr{{Xsx z4;TYqTJ-zYMB*M=_gR~EriQ$9qm$a69_a&o7E(-k@N18&41ASO1DWm9@|H{Dq-*b3 zVso!zL$4ZzYlaqCoeYGAx^c@a#_4aChhhQM^7NtMGh2b7zj2)P7is=)saYQZlZb5f zVL9+DOwXJvJ42-KnmA6`1`Ri`r(z6oXdoP2*sOXTyrAVeoNSb#)K+V2r4lyO1<+0{ zBAN8(Rw0v`_sCe*k~4`pz*bo8qCskFpL>iouBuv>h-u5a^?hp4_C5ETW(C}447q)|{VFV)FNadfF!{WvVfg7*kR1z8gM70< zac&w_qWsLh8Oj&4n}avhtyr`9HK#Gh^)FVxHII5Fj&3G@Wc5AQGx=mSu5p8u?R-s+ zXGTM!Zf3iS@keTkuVZFxG6@>|K1t2qt*tI&LA6rz%^EQnpCYxp!g5RNJKc4%RsJ!| ztSob~bPRNkaCmhYgmH=k+I>n_m>?~@&~U&UPjULxV$Tz=2rU8*2K}owV<~-z~}P+0J3ZB z<)PreIi?(s#)_*TA;9tWiWAXxB;jI?XO_n2KIhHn?8QkQQIgQ$OQ4}`ml;;~`CeM(;ql4jy~yMndFNc%gS=u3ga0pm8SkF&Hnm#&;~ttvJZCmI|Z z;c!-+6n#~d(K7EK9ze`FN--FFoFgXz%~b;%#&MEG8<-o{l+S3i zT{j#nnwMFMvyxm%c5~Inm~r`C%HMXF+To?_-0C9kBy#N(yRM_Ddj0@BBj=-3~zhBD$ozj2K;z8))k%aadXKf#OFM+ve}yuCxig( z-ZPZWBA1!WFwPU1Vsx&L_C`TRQrUh@suupX5u_=BC|+c7>|m8R!2}*iei16 zCAP@dGG{jQ?YlFitG#W^rELEIt!&hOo>f*%EfqX&82K?)Yr-&Y$zT0PCPrHeZt-}J z81hhvz88~cb6ogw1Q)w9cZA6(1;+@+Czl_3v^LExtjF?i4pwY$DVu6^jdN>8zc4s| zx{$K@0ktXNqm5f!+}V`Ty`_@FW_59) zE)I4YRiXB%UdGD{T^pbBh4z|{T7nlw*xMEFj9}$Hq5X_-xm! zX{*D3GPGwo`3}sjp~d%j$+748NKHlt@r|b+gmt0M_|Bt|jihmz_v0!L_8hPHdr2!R!L z6};M~%EmF3aI0F{0Yuy6-Qzh|M>SJC1CDiL;NtAj|ddY^;S@CUU&Nd(}oHTlZP8)Tq1a7gFz)tTmS!W$**aG`5+ z=rmTwvDfP6U3aZW(@@|YhoV+eKf!OcjYe4K7hi4r+{UlX*+nqTn`4u)C;tHPa<hg9uSwvOh@3%g>wgdb5H;RNG-VLhP;QY4H)vqzUZ+&WRF|=`;{-vAbukSmQGJwNI zzX#^bvO}6<4lQXnbS62$q#G04L**oQtdhpMrJ{Slb8?gnn6r#-QH}1rK1U|KizcuT zUM(^bJr*lTpNbLD=ae~E7H0Bek7BdJV~7Takl;B-6_ei3(}Of%0vB9Ms>}7v4Q0*Q zMQF@FIhXA3Rw@8%G51h)=T)JioZq+mlffXKDXzfbi$V3`viJhajhZ}V@rNu9Pp{W? zIJb)??O^dt7K3`VGa@k=HAS1H~p4UJpLAN^vbk!aj)Je z>odK6O;n%cjX&gSg8Q9>YzzlAE)M#q8AuJ{Wdirkxaxg$LrZh^s+@Ue0Gk`!&j@w8 z8-wLi{CU2$Y8QYmwL(DhT$KUjo7t4_Klju__>u3mSz90>*DUxQMg;5MG5PgetkSWd zJjWJ3h*l1d3X}a6`9E6tvuz5^BPBd*Z6MN_fFg(S(3uDg7fw9&=HNP2pG&=)6Z9sZ zrmYrEO~+ce-z{#qcdV3utGn7gS+!2_Ls?AWjU3WGwPl9xEqt%N(_5q6`q!;SX-cpa z4ywoJjfFoyj=gATSy!lUwT(|LJXu)U*QKvM>WPg2lY2g?LFCYij5R{E+1lb`hB=zr z>lJ0|TeW2Pp7_UwaWA6dtWIcfE@>o_UWCmZ>(cL88uMI0vkSC!KW{RB z7mMF#k$`J7x4lWt@r~H!WUK2)8a}e7k+`-VR(fCu`zo9?C%V5ZH`iaeSghc*R&oJ2 zVoL&H=pjDQAPs!4bKDv*6)L?gJ<7?U+sZ6_StKL;ic`zuBV!8%?nngD#QMjqsGBrx zmGUzDWH|B(P`+mh)x&idT-)*SZhL5Mn%t0gcF|j#P;S6C*sW1IB4m>7*N0Fxha5T8 zwT~njY;9|F@vL^nm1B9StN>tCt#D{9fMZT#TC$C-y@W(=xHC2}vXuV-5_}D%`7HL9 zkNp>`Q_nq_jrACQ^at=~_D@m1@PC9`qi6%<(WK-4B>QdFhnV=Ep>7npUY{UV(O>5AZ!rs`RF@2VtZ-JGK@PP}dOlofuXrKh!OZ6s z@~D{F20G8hIFVWkVpPpzs@AguN>`JeK8;#(lZXQIM4h#`-t}W~0Df_4ZW@s(9Rp%{gb&rK}dB0d6D;H`brw4U&PzhBHod`z&Vj1A|F-P3D5WD)X?;MGBVj4IUQ& zIwOI9L1$u zh=O_p4e`c~QRQaj;3}aZhV^6T`V3Lq8_8rjOcG1F8`0?@6JHhCTE>cuc`X%JudtWF zBdJfIX^(2+BMl|r6ma?1zU9R|!t-wmU7@WUr*y(gf$kMy%xbMXNi-~{FIlP9c^OtV zIK^5henI=yl$jjbx~7b;?4nVgG}o7f&mi)vV4-tOwhn(Pv$&ZB=h|>MW_q)>NnFV2 z>bNrT&Cyj6iPdGH>pmVI8@cQ1fUObvm@v_whyvADS z7|ht<>)q+Y3@;y9Z``M{7DOX)8JcT_4tk(#TC+VV&ZQqksnjI8HrfHG!xJCethq$X z$P$>TcjZ)-mnv)d#5>MXbX@NVkhP@9=5h_{0C`6qoGO6KZ;(amsBFcd*P?~uoReN5 zw8dH`YeBhbk<>T?LR7Q32D(YF5HSL`mP>0XUN@iXRtq?}#>!sLCF3iC4Z!}dbwwm@ zd~v!L2C>e(e0THUrj_m>Mat>@tV6v_Z@J7aaBG8ZlS=bf1!;Y$<}6(IaZIf-9$zZ7 zvdrOWY|uI<8KS#(r#Kh0;GE-3X_-wFoM0|%v16(8Q=u`s``IrjJLtHqn9ECFD`RB~ zAUs(PqjQs5<~+*TYK9oEOJtHqhq|JbadkYYvD#S+Sj!Y{hUIEnUe@xwyp+Jl)%f29 zw7ZTJHN{%(L^F#JR-ohjsJAwf6`I>^f=MB@UgCJEg9y54>eZ*npAEIl4|Rgj@x`0i zT$4vaw&VL*z2)HVmFq- zFv=PcfvKvRM!n!)j*TLpAoHsRO_m7@S$(1HeKX?hDdj5H3^BYhu?k;xPmJFp;{>E$ zcO+fUi3*jeyg|dttk#P=V012y(i>81(XYhX4kY_vb8z z;3EN;^LDFk)5rnk_+v|lzKN0Smd`t0EfLJYKydj+zNIO#ct!B$)zY|7i>~HUl1ZoiE>`xOOGTs~{RHabZz~8G za-F(VvoJ?s34r7G1ZE0KHE;x9~+Ric0yc+XSOhc*Oql~zTa4l^x zsCagL0IP3Lgh{+q6m}e7+O3W8s$X8$l{1~6j_-0O=&V8gXHSts&PyI{gG?_GpRlVF zU}M_WT*1z+9Iq#lGASx)VlL#KeB-%U+Se0H<-m-_syjeXxHKMS(@hxdzGQ#!i^6EbLWrlP?B(?^J!T@>hh{ioP8&#IWqkEs=(a^Qjr^b&ak5Nz5n=zubz9|a{h>DE) zoO_ea_?+pVe)HolIj;2m6_m*4wH@-U9c%WL?yuvvHAzHrEhyZ*q?5YXuS!d}%Wp6icwT(ZP20kI@*zyqZzNFtUsAP53k~=raV^YA#M8*E$M~(S_1e{D=;K+mERe{f4Rmq1JciPyjrHbC(cZm2^^&pvZYkvW*;6>zmPf^q z_YC2@>RopG%FTM~kApBa=*`RLR-oBf%h#G(@0Fi2ZiR9NJl>U&v{xFsv{6MYPl`yN zS2g*^h*KKTR}wHypsf(q`<0M95mva0>nQri`-*uSO9NnJgC7~U+TQ;FsaWjxV&_Wc zwlFE3`Kjwxcx`dMD?z;bq}P*x2i6_xUJFRi6&vFQnC6za`8dPs35I(lP{-hvibe_< zTY5ZCPdBY)7e?666d3XkjfLZfU?k{Id2T)qE>|^gw?EdbF@V}O8`x*Z{>so=1HK>* zo_49(Nw}+q^IYzGgFn=zCP2d5HLH|OF8oW?sg=!UVW(_Aat{6!{>kdb$zcq2p4%Kz zIyTTdla20^O{lP36tj54pW=TWw9^%}qWzuKxZ)}CX9J;S3ZL7RXuVo-@@*?4G3*VC zjCpIRiR8DuyVRhMCOUTqIJ!eVCZJ#jnxCmd9ifGdYb|aitpL=AH$%BAA`PH|Ly?2b7l4&}v$IflCB1x%Zz^0mnJaq>9<(^OJY(|DH3$2t-sU~=M{KjcTe-tW8C7Hu#UPN2^rZl4zy8V@t#N!&WdWse ztwN6DT4fM5j}kWw9n7J86?|o;kFI>nDB+8p2?MptJ~9qDl#E>_g0~xwtw8q41VjEN z0gO1++@6oELg>VAH$%udg<2HXBE2IlvvGmgwW98DqP-k<1)@6Lz?>bM)TVpv=KESs zVRywisp23JG}WUAEv^ zsNXQ8<=}E#fach^!!7FCPb{CI8jBP9z_M9rcOA#gTKKf6ueMHz97 zcx^mv;-TM;bPv(ZPa=~z&EBD@!-|r%>dLe@y{x@xbC}_SaKpIvt0QKW#k^%XF6a3_ zT@*4#II!kBIHnw*tz@Drb=Dy2lb*85Q!`y)Yefbkpl9-Z$=GvuwOU?m(lL%|o7yLqh9z-2x7J+9ha2#)pT|@V)11zyR^ieh`1=?SD4y(MnRI?aE?GZjc zEst&H8nM;A*j1U$75!xgmgrW(`>Z@ua6H_mt1J3x12o{XNo#7 z#{_L4VR>6^mUsSZ8yX&5b8?$X_MfR)0JJ_ZOt{{oIkWi?F!6=7ZW0`Gxn%a1x05r# z9VYktg(IfBdDa1VGc_vy^n=ng8-**tDP{-kvlar(~X4ab1< z8}W8;828NHcK zq5ggEsD^)xj-TUdv4G%57iOs)TM=OttWqEHIY7vy51d5{o+;w;MSijtOfPLCl6f5) zkIKd%b}I#*N3^s@eT`|v2L_x|DnDvAr-zC-(bPQzR3A}TBc)mNyVO3C(X~bdb4Od#W$kzwQ+8`?k(8n!sDy;RYKfrM{QUhm0|F=6I(21D*o>qsv-1# zF4Qi1(m$rzScDMj2%Mi`2$=^j;|~ntXe93Ql$CH&pUA;4jZXnnTN9b+y9<=A+*X3vk*RgIRS3+}q@9vy}6B8r&&WS-(> z=bMtSFtm}2Mb`nd)U1V$5x^#d8ZmjMgfobN$m>OFNadiYQ%H=OYv6UG>>{E&H9=B zlhjd5;(x?i;6^39llKH0E--N-^#*qFL%B@&L`;W?b;cUh4lJyp*Oh6h!;X`9tdiHc z&WW#(Mlj^^BR}xK8)A8{Jy^*x$YehV?p-gm5ucg9Uf%5Yn#W`afWiP8FA0~y{aP=cI z4;34WF0)P(ObvB8+es>{ud;GMjifc7ByzCq%%KvO z80m%Khmk=XkjKhwZgY#eD|mL{>Q;8fNM#Kvh~`;E&%7zcb9r174KoH+0bNcmbMb6ru5 zX?4lMN`c0Z(gkjs=T@c%InHci@hzoI_V-av);-N*1TGaAU%Jn&Sqtl1!nkwsQ6}kj zz^(0z#&xa$(O!C#*2^Vm`M_xTJgk1T3Hm3UMlap`)=WUh#8ymNT-Te}a^6mAjM!;S zCifTNPOcdEvGXXg$BYXlJ<`P+cI!Bo~g3T$sc4D<71NmTm~D;XH5fUjAa=G#~KRXa2}&W*s@&KNLdBu$$mCC zns~JQX-%d)p5-NJ&7ZYd*h>RUQdXw{@nu?A$5pJfI_)!1;%Ib}^{XS~mhGjp-MHq{ zlXkbOrv+n-S}e_X7bW5xUCOI7td7<_u5W-cmveryOcay6l3T>`K6PvLP+heuH`Ci(~Bvg&Ga5%cYMCaP7s?E&jVcdyij~YD_SdD zB3mQnl6k1>&IPro0M`%urZigY^E&fG+Yu*=ARGy$JzWbWUy^{@8{&*b50zy0fh4qz z^d+>Y;~h?QeBUmsDhR{HSv9d)36K{X?KN}$HTY3Ewq{D#UnKEtylH+f_ER#z$=xG> z<4i5R%x>2OD;{MQX-62s$`*$kxz91W4hgQ7*EEwx;A3skcsjUHQ8p=`Xz(!W%&R#U ztJ6&NC1ndnP_#HtN=VroNi)YnMLH;~?6upN*qwvkT6ql^14?tW0yKpaVUdrH@eO+o zZw{X)GJO1&%LI41QZ=q?n&)#Kd=^lO*7)LU+aJfc*5JO{;EkFXn&S=NZ2yq6Lj*#%yjRiG{ANy(uaS)7W5w;Gjs>}t zIFLi-sRJJnC`|7xkBl`mugNOW#7o9Qekwc)SCDzMt1OLcUlU&;sWf|<0QouNUZ`H&0@b?tEc^(gMP9KN9tSkBzQtTgW8U z+=k{(bkB^iMC>cihA6exQE7aPh_&q|uz_0b`jxWD9L<^+v5uAS)yy2W&=XH0vP>pr zJ~-U2BQoPDMmFdqI9KI!dexqgw8&p0SrCvB8;%V(vtVfMgyR!j2Y?2VcOTj-D};0c zCg#O8yr9z7Vd*(q+hF7P{xlQCG#*XE-$d6srNp?LJgUiw!p8l;i zPc7b{e)Y2w*-{yiboR^T(-wP*8m>gT+yH2KcmacJF2XvDESxsP&0C~_pG7kGVc`yKYB6kR^C(E;^_gyAHtU8Qh?iue?l8t6QDCv-s^wdFVc=_{{S;pm#pSe@s1!|mk=w@5{jAbyAIWp`U`NbliyWudx5^| zOB_My_{x1$R)bDYhE0c;!CtIXukwRh`YHfN?7QYp0l~n*8?ZIb#+CU-TO(T0WM$xa z`D6Y4YGV1gvvx{hpFSP94%KVBham2~LiS7flXH4Wq6%EP%G2juZSV6{ zWc#7qNwr0IhVpBDyz0`>?n$0HfvWm#LsoBQQ;+rUeL@JsjgNh+C$`SkHHJ73Cm$xj z6B}R=Ha`V;hgxNdi#L2Xcg#D>aR?9r)w^b46)|T_R*=Ld#;xUJ*0gWKMvZ3Wuq^Ve-A;* z&gzjXxW`lI)`RN~QWuI|vpK^>Fjj7{yH%FkJXd0CAah0r`Q(~^hQ6dZSe)Q%F)hqY5rw1dEEAq{%^fltgdBcb)q)i2RS5D zYRHh;oYMF#_hqALM^U4nSb-UI<6PvLJ8XjrQ;S$vSX~BZ7;)tS?50>9~Sgi!K zy|Tj?Wi#^HPt{cgpir6Sc{CqLCeYxmPjPIVZO$&B{;&WVHmQbVdcWn3F~IYTRho~i zoU6i_vB5pIc+8GuWyB7Og=X}&sCqcrHZfTyc?3plh_6D5)Yk^t#kf2WGdBHpD?GMo zc*Zu)8chhs@x$v^g=v0Hd^}&rsaY8V?@JwkHc^fSv46VCu9Nl6YdEt_YNuR#xi(>$ z*@f_|m+%g=G`AjVp8BgmAG)vknz*@QxOk@*e}Jr^cIkPF(B|s#5`(p>+vbt)M8CP8 z**!-YBERtlZn}z4z$oiJANoo-U35K`cR9d)D9ISCpg*h?BiUoCCVY}d@r~s+_J8va zTChTFpBWslFKBq=);OuLLeHMrF91G`2i8K53>yulrlU8-m6WXw6^-_IsP3LrXh>Ra zhv(?Q8A#-dM5p}mCR*crTY5lT);5?5QCct2rS>;5jw3tzJc#j!5fG_3{D8a^%@>97 zatd)+*Og3Ytxa=oNG+ogoCgd7u+1CGTJ|}ucr>j)?wH#x*GL~Du;kzx)syXTac;Y4 zwF(wt;z!NenraBprL@G_#QS>~*9F0%^AdgyNytpTtl0bgMc3>+%#5j=iQEsBm4nn22CqS zlEF6?>eM*h1wzV(@osBy6s|8l>PA+sEb+PgM=7aON4Vk5inY1bAWT{JAaM~spj(p?pKBOBf10LqMhK`HQ?^Y>T z%CVNSSAT&{So_h7Q!bOy%27hoc)8A_eH#8WDW8P~Q+|H2%OJWdx2{ckji<_L#<{yQ zC*w@r>L*sWV3Q7?igL=O?f@BNYXv#z6`7OZL2w}BPEHEQ=)+5Rsc#mu=|-|VR|8)j zPK~Z;r;UB}M$Z%%jGRoPlssFmZELN9lfo>{W!BbKBdM!SxilluA`nv^(df) zj-JsY4bWeSrB6SQDeOiXT5q;1mxqmdl&+O)vfATxQ#X&~9OLz0da^dS-F0cM<(K@H zn>~GRQsMGD!fWOSn`7T20#Fow(6)wh4on zEY~U;TSryjcuf$@^ALg--KnMF8$+Qptchj~6Rv{r)7RAJe`R^~`3=D;vMaPA9Bxtn3N6Qbjlt=F#7 zdGQ?HGJa3AzV1+IU~>qm1jrmQ)x|NiH-VFNGTv;wY*0$*+)gdMbc%gy(>#m~bdpDd za2_F0Sl-avN#j{`k%Nmx1$ncpRi4nxvo;ZVw}H;~k&c2B!NAn*C~5Cj7B~|#Vzu6; z6i%_;y76q|0UDH6U`fn&r`jV9Hfw>m!+AHUo9hZl9Oj3(IM(iKu}fXxn^G%5&GZ_k zY{oj6T}raN?O&6E85#@_!Y3_dK5aqi%vMx}Ia(hr3~Xy@cwdYDlCn(lex^G^-tnAJ zIi#lXZYPpC>CRP^t?`51QD3BsNp=4K1g?H+Jy>Ij)proq5u24$f@4m5tlH&jb%Hq@ zIOc*{>$#YI@R{1IzHk1j$<1WW4-T|)K{#?kw_YTkGAfhWq@^=Eb;{lvYVQSY;Y{Bi z&-PP2mxEh70U*y@g9_U`BU zMMCKX1LGyWQAKPxkl2C$05d|daZe?@V{rzbD;1PFd?sD;pvMk0ZC1i@_}K<0>A6~} zf1GprNQ!)w{pU8Sunfn+WGBnl1kLL!X{3^~l z_>;XdnrLBsk;M~u$Hv-*@mQ4SDnLY3fpTf1{hqy83+6f%=uHkbb4Mnlo0eqKsS(JXAOish{^*F}KKPg+)# z8Ll}00Pi2US;c8fG#jC00kQxaPl#uxnz$vKGo{Y zpzjpjb!ny;w`oyo9i@8Ic#}o|qaQ^bao&!%09kFGaZF2~CZZl~6)mZ3AGGv1O$e9903?Y;G@ zN0rU@l!Kb^?^Y1o#pZTU4E``yK=u}Z({z!aGGOv|qpjYuL!T35YhVPi*_=4pO%b6Y zu{pl9r-xenEQfx(=U~tKb|xNrg=e!v6Jom830~at)YMl- zWm_cDM&QR{%=9#_!Rl6O{Vl34wfh*X^2ugw@r%`CvpWHTWX+ASH^MIn#%2rIln-fX z_@E8Z&v>YSadG44HwVcVTaMrGsyZ8^z{MO}OWdfHwo>T>V~^zKB;YISPX*UUgNk{1 zVOhCVtPSi|sI3-}0|W)Z{U-BpR#*5#o?Bs+&TEctOq`z4wNAkkSVfxDOM%7T67_BK zNcW;&+|TTus^BWybazgcNwG-q8UeFc1AzFIHydtck!pcu$ z1Z*}~NMlX|7?3r?jP5DZHN~!NqH2GNB|Mi4Sr_YG#@3+MKdDI*?51Keeig69g3xdM zXqQ=8AmSV2AK``Iqaj)N^2d%k@xCez4LwTHauQwPMb*cY(r*1keQLohe3Irnk~0j- zCOEho(i*f1fpk}yS=vq3NolkPG*XVEBBEn0RGER-fN%pI!R1d7xWw-cj_GeV#Llgf zI5H zRIyrT4P&j=R+$Ybz(lnxCtA4nx=t;O;)%wbylL1yY74*-n-#}jYaI*?aBY8C$^!4G z{YuRCFh`|J)nnD z@Xwh-`68Amrn5bv<)i`&F#G!z(J^$QMrY6CD}jpzQZeXR z9nn08x_dw)$yR@|j1g1C6I|!Ua5fYvvC)*)!BM`>8yQiTt8#9fr z3{B<=;znameoC|wKk-N4dcv|nWuJ--Y?ovM2jbj&GazftnLnnZy+=xu#7%(b_@^wb z;TB-~)4i8u!tH5CcdHDAjCEghw7Zy!cc`arj0Q;@jR2AW1+19<}W9lCx@3cx$5m)6ANDOZs!i6&Zc$pUCA}Q zj&*JpncW)$CMH*7kHt___WG5b#?aZN5VnU7k9WoFS$c-QoWZ>HDW}N$lo#643r;TP z!Rlwpk*u<|LhFokkHzRr%8bfkmWX1rwXosJ986-rSyso{nc%^*O6o@AOTzXkv%WTm z(lGZ7?;*Eu9W<|M$y}?yoVOFDWYS)4nBFf8)Z<->AT*E+b;CC$WXUDUt%6A$=-m^G zc_WRJ6xqe38O^Hg%sgAs;+Xd|wX$AwYtAZ09;7VxlF-LB)pPR-1on748foJMa8}4* zh0?jvlXaq`@|djcYl-;G-L*lshq=NzNN6KB;){|Aq++;pRoJUs(plRqY%OtXuo6-6 z@=5$>R*7MTV1?Gy~ZWfSj46@poZ2tg9 zigCT`!CPc>EuEr!MPqhnc*)T?039rKkh@7y#%DJgWN#GqP65~-DwLPv zUmriwDj~&_$(!Rj>RPQ&k&n6|9~ka`j30N@oCrQK+EBpfpz`9Z(M@G*WNbF7mzon% zP<=`ELu+F`HuG%;oSIV+>Q)$Rw!v&?7UOtRlgc~L3SBpAw5mY_vc8q1ZMpz4rGBJ(e)AgTHnn$`xIZY%}FuLVvEsgCCrzW+by7g_N!>b*o+CMi=)>Z&; z<-2vt)yk*GyLhWJ@5tslBZ(MRf}m)qR@zjC#41L!Rg5;cNZcu#l(LAg^Qm{rv*C{# zHmh95DLAt{qW#pj&C@aD16KI4vK%H~#F=k3 z%vdOSz;i(QfKH8-EqyOj$W*t9&e8R?S$RUY^C?>SxS&}tDB<#=QQT{sLtb#wvSH0w z$*)f-%H1W)f=Qu$(U?`PQ!+qY8*7*cA2fa+QnAv%$~|5xv*Y^%U-3+(vNhwuXK3T2 zAREGgep0s28{HG^>;mw_H;e-w#~)I+G_*)fXv+CqD^Dfs-io$0jCE*WtZJE*WW%iKkofW@=6vvY{ zvnln?EvJ1#>i*U}_N<=vfB=fmnbpn%z2y{<)`G_Noiuv3+1~MSwn#=NH!dZS+}DQ| z$A~!7NQHo=bn&>3)Jw2DTute~M$uoZh7B& zM^Jr9?1|z?hGU54Rv71VZa9X&5-H0)$=p|q;GSIfar)}4-D7sAUc{KA4ac98-BlEp zSp<=`Aks%Myya#rA;3Xr6$hL_5FuF@%b~)|<0!(nOp)F0R*e2I*ySdX-m+PU(m`vA z>7{YLIhB=<@mdIP)S08b9d7lLJm39UQ^@9spni4JpUU-Oj;;~1*2#QIi^81!%O*BP zv9P*lTq2e6#cG%ab45MZksPezp5o9+jMDWfheD;9k98=3%Q^k?> zg=X}&sCw7zYP0f7m|Wlh>7#4ht_JeB>RmciO`bcBEW8Jjs9bQrdPbd6+IP7K?H6uG zWDSagn)H8gR+~HCTNIJFx}4>$ac|a{kMmJl>!>=G)mnToJjcXxZXSEh{{VG$t|U6g zF#|vBUadp=t*h9*Te1FU7W7N|nf;U2aZe71W6=OomRx`IRjfF;wYFGp5bu=eP5%I{ zt0{HmT95JM$I>e$*Tv)H=KdT%?yU9xQrG-T9>)s4tph%q_9i}1!D*HYWYRYCX+q9n z(^}xI&4|=^BtE*wU#($)GH2{gYySYGPG~g7va~YV85xo)HKdMTCD?sxVGE-!mnI0! zNaYKSZPDeyIfM{f13?`h77%!G4guJWRWrnawX_((BN_+Z`ic7dionwu-2m=X9>1>{gIKk?y^9tt6Am=BHv{ zr8zLrdQqle2Q9{jRljn!-|>7h&01>8vRk5|ws9>FH=SF&zRraJwBO>n?h#+DS#G+a z@HNgCgSlMe4B85_GV@Iu>{{S@Il{`g{JTCquTfc{YnVczig;9ey#j@mbB-taIAS zB(t`K>}2f^HI6VT^(#DWbYbo?6IpJI*@-xR-DO8G7as_VpGxFe~lW93pi*rv%dZNSwR|w&Q^$=}x)h8RToUT#Y}7jK01- z8Co{E_AHIYFtUFLp7Tk^m5=oI>e6^1bQ%edBx-l!F&anPF;t+I&Wf=0KAWk|(? z8t7Ra^7!5Y0idVtWmb6Ih=q+~c{!Ux?dKHw{NC`K+f#^#8jvv@oIbiH<^t)U?J8{z zR_ip+iI6*udyI~{FE@*(K9NkCDdQqIoa1^xF%EVBXFVELUL&5<2R(jM)SA8FMAi;f zFddFf)mWw5}%*xoq z9s_~EN5;DvpC?LmFtN{kj*hKsYELArl{L0v+imm74(OIjRCFu(NwPyIwBXtny~6Z) zgRsxBS)#SNb{H7uFvMHRtK8#T$LVt;5M$p~y9N*rZz_E{5zwcbCosO&*xhlVm#d3G z!$nnztQB*oXwBtTsOAJ+v^k>>DMT<}=&qbTP3J3hm;r$oORxaB@eKWnSlW4hD~RO` z{KaKJ_JDYXR0@yVo|JD;4=Tzf#*TJOwFZHxhmbjxNmHyHakbWo@2Q z8>KCY*Kl*MW*sL599GiUrEFG~I6b5hNZJK^6`qqaR=5W~<4y+BBRe1_e#Lh zE@D6==I0rcS!J_I$CB1vVJ=Jw`-N8jQn#m z>OC{HS+V|a{;5_ojcaPHIxaC$%=D4nw*YQI2cunXxd4 z+}zDGGasxeOCC7Z&eGCz4okbLtwtAz2Ntx{iqeif%Gn?U-6DXH4lRCR&a#Ev*DY9o z{B-SQlfkVoJ2s=0vBl8cY|e28W`afInOLD~B#bsAD@&X?Ma^yP*80lIhNL%$Ye=Zg z#xYpxK`wB_Yb>LO^SQUmvkKwHw|QD@0_laesbpxojqWl0{*tsd(;K5aby^(SVar_9 zxWB#kRh9=hT!uM7K6)-VmO^?Q^-HFZasm1P@6kRXM#t63jWcx`mZZP z*a>(J0-Xf%zsq@L9|m>~VQD$=xbxhh&I6iUYBTbPuhg=)Plg@$D^FVer5>Bi?_ZN1 zXfx`yH;QAfaZqENUgDWwuBrw)fI3EkP8brVU^q6=8VILLlY2AjwPearyTan%YP3l! zOK;B74Kv{7OV>@wv-M>>^P6ekP&EGXJ@%}gGIO5jj5cbQ6Q1K~{#&REEQRbgcaltv zem7sv7E*JLJ_Q}zdGE#k>nE1EwR>+qhcV-6k469}c%muA#_t-uqQ;Z;)iD167rk;t zJ|-S&W5u?-2TFrWw;G;?vX@+}k>O8)mB#BE)J2RgP)Y0U+B)*tlevN zrk|kAfgLIyZx?Z>GL$ZXx2w=@5!|NM8yugccdU~8!MAOYnw-Oy6kWXOx;xR=?^!d= z{{YpIJoA`M6`Wsby1*zij$E1l037PfAu*Vvv^G#E3xm0hIx_|NF3G4EZBhps^Kq@J zvv^19FSm@R&8{i!bBkVVld|KPb-~WLK~F6eS_l<(e?>P)R!J`~-@Sl#BOYIe4E8&n=zn? zlfLK;#|Y!+^xUixGV{Y`7z|wMk(oN6#^=Xub4O&H68@#nZTgCvy!2Bz-E3yU;V3-ppshTrQ)jOe#e7{(H*N|c#yPy8L2ZD|4RG&~27_SD%wAuz9%Na)fzMF0F28=T=8x1=n{htwWkiKG&!xf_FA( zxPsRXV%9Q>J4KBk!;5IAm!A~p%;3nMWtESnB-C>b zbuF2%yd$6WwV*VvIuoXQx)OgHRhX86~gtXnQ zO(Q+Z$OU=5lu5P6#@H57 z$g)c#2sWAUt~uNO{{T{CGD^Vm%hyCsPEUl)SQm#m)lwb8ml?{b5@h6QuB;3~kznez%Ty8TC;>_B<9F)W;mn=0xY@I-lV* zPp%oe)6L=}@L8_(SBg3QwUmvO(U-|;mMJd)YQvtMT*|{E+QyzeuC2fZ1bxWIJe6n? z2}>Pcl+}^yLHx*K5AkI}g`u31YH50nXrSpluTpkXU0B93>Y(-b0$+BM|o1{ zYP8wvF0{_zDbvKOI1Tux2RJ(J4j0@B9-_Y}EBz|Zy!8E5XN8P&-0aP6e9uCnn5))i zS|0YnX1jqPG({QNRg?$uX7`Zvb`_GD8w;J`QJM{5RgoV1B=T7}qS;Jif(GyW(y~|*SsdYpc_F>Hc$3ZWeZ5Y#?B$*8rllqVFPNjun8Da_&wv#RpF$PIQm0Ba-nQH@X!YN^x~H=&P1fY=V%6wb2@9 zb@=anL8@EM0Dvh|MU@E^Xn~xei2ZxH)6JI6EY~S}i*0Gpt%nfVGJUE#)A&v_`jw{8 z*D<$k6N+wl|jeGbfIW45PkL&aALmgSf`aGn~RYSlVu%QfQaNy^T9mh9K@N zzB{CkqH3am&H3fYc^8k)uEcoPy{ExwO$&|gIQLynvOX!BId$GB_{5sfQ5a+oYuMro zN%2T38JRVlG0|G+A1Ds-2YP#)_)JCZ4x_`5jMIY2tZUjX#9N7={cHa8>r>p$Ik53S zh6anI5O<2S*&>?GSn)Od8sKRgvlPsqB+wX3S@IeV4QcU{otCFzhl+LHCQHWg&6`!< zS4zpn{AI>AKM19BR9C$?WKleg7I9kMUo35rAqW=JOX>>C%mrEFU=+8}sTK@pzZjiY6 zf--(}`^H-2*^lD(bh6XZtB^^0n|tEfL;ixB`h2EB&!TZ6&HTdVUeUuFpDSwC6Zu2!;te*SMMRxUNoE`}ozpr<4T)4f^@q}}4W zdFm*#Tgj^%FmsN41I<=YGr9QMWxaN|a>{3fx?=0&i!Hs2ue;s3^{Z%Wnkh8L7gP4G zR+mXEPrYfU#mRb}Hh)?=sn2YXfY}BG^gb^zR$+avmC?(151V*X>%6RuVH~b1x_N5K z_8M0v$6PtVYfsh_qI`{OSmu`0P@20HZ1A@@fem82&n;Vbu|nH3iC~wKNajxP*E~h> zm#*|yDyW-zporgF#)HOLGm0)Q;*T4xdEL}jfemiogPyqDb$+#Ic5&fT3Rn89ipBnPcSU|lm`#g9qE(nk0QTP%V0PLI?WyL{b>I3rp4Dt=IdMCRg~mtEx>rqq5WIb z4!qLqoUD28JGzT2dFkJBwDqsrQPyXA{FJ@@3}fiG?^9Sq*dHK(-MNVZIJx~59}?Qh zdyq5K^CvOwqxkX01BEGgp7nL0G+^o-R+W<4-<4~HzvpVSMnmQCZz~XJ zp%6Gb{{T_wrsxTpsOt{&bnpfFN(&2`Z1B{BnDK&�ahHJk9?A-(s{{Bf*ZlLFj<2 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 literal 0 HcmV?d00001 diff --git a/testrig/processor.go b/testrig/processor.go new file mode 100644 index 000000000..9aa8e2509 --- /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 0d95ef21d..e550c66f7 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 000000000..f2b5b93f7 --- /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 10bdbdc95..9d49e6c99 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 96a979342..0fb8aa887 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 +}