gtsocial-umbx

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

commit 6f5c045284d34ba580d3007f70b97e05d6760527
parent ac9adb172b09882b12659a3e43a94d724eb65378
Author: Tobi Smethurst <31960611+tsmethurst@users.noreply.github.com>
Date:   Sat,  8 May 2021 14:25:55 +0200

Ap (#14)

Big restructuring and initial work on activitypub
Diffstat:
Mgo.mod | 1+
Ainternal/api/apimodule.go | 37+++++++++++++++++++++++++++++++++++++
Ainternal/api/client/account/account.go | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/account/account_test.go | 40++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/account/accountcreate.go | 113+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/account/accountcreate_test.go | 388+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/account/accountget.go | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/account/accountupdate.go | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/account/accountupdate_test.go | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/account/accountverify.go | 48++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/account/accountverify_test.go | 19+++++++++++++++++++
Ainternal/api/client/admin/admin.go | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/admin/emojicreate.go | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/app/app.go | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/app/app_test.go | 21+++++++++++++++++++++
Ainternal/api/client/app/appcreate.go | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/auth/auth.go | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/auth/auth_test.go | 166+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/auth/authorize.go | 204+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/auth/middleware.go | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/auth/signin.go | 116+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rinternal/apimodule/auth/token.go -> internal/api/client/auth/token.go | 0
Ainternal/api/client/fileserver/fileserver.go | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/fileserver/servefile.go | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/fileserver/servefile_test.go | 163+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/media/media.go | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/media/mediacreate.go | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/media/mediacreate_test.go | 200+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/status/status.go | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/status/status_test.go | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/status/statuscreate.go | 130+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/status/statuscreate_test.go | 297+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/status/statusdelete.go | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/status/statusfave.go | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/status/statusfave_test.go | 158+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/status/statusfavedby.go | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/status/statusfavedby_test.go | 114+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/status/statusget.go | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/status/statusget_test.go | 117+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/status/statusunfave.go | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/status/statusunfave_test.go | 170+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/model/account.go | 136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/model/activity.go | 31+++++++++++++++++++++++++++++++
Ainternal/api/model/admin.go | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/model/announcement.go | 37+++++++++++++++++++++++++++++++++++++
Ainternal/api/model/announcementreaction.go | 33+++++++++++++++++++++++++++++++++
Ainternal/api/model/application.go | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/model/attachment.go | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/model/card.go | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/model/content.go | 41+++++++++++++++++++++++++++++++++++++++++
Ainternal/api/model/context.go | 27+++++++++++++++++++++++++++
Ainternal/api/model/conversation.go | 36++++++++++++++++++++++++++++++++++++
Ainternal/api/model/emoji.go | 48++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/model/error.go | 32++++++++++++++++++++++++++++++++
Ainternal/api/model/featuredtag.go | 33+++++++++++++++++++++++++++++++++
Ainternal/api/model/field.go | 33+++++++++++++++++++++++++++++++++
Ainternal/api/model/filter.go | 46++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/model/history.go | 29+++++++++++++++++++++++++++++
Ainternal/api/model/identityproof.go | 33+++++++++++++++++++++++++++++++++
Ainternal/api/model/instance.go | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/model/list.go | 31+++++++++++++++++++++++++++++++
Ainternal/api/model/marker.go | 37+++++++++++++++++++++++++++++++++++++
Ainternal/api/model/mention.go | 31+++++++++++++++++++++++++++++++
Ainternal/api/model/notification.go | 45+++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/model/oauth.go | 37+++++++++++++++++++++++++++++++++++++
Ainternal/api/model/poll.go | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/model/preferences.go | 40++++++++++++++++++++++++++++++++++++++++
Ainternal/api/model/pushsubscription.go | 45+++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/model/relationship.go | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/model/results.go | 29+++++++++++++++++++++++++++++
Ainternal/api/model/scheduledstatus.go | 39+++++++++++++++++++++++++++++++++++++++
Ainternal/api/model/source.go | 41+++++++++++++++++++++++++++++++++++++++++
Ainternal/api/model/status.go | 138+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/model/tag.go | 27+++++++++++++++++++++++++++
Ainternal/api/model/token.go | 31+++++++++++++++++++++++++++++++
Ainternal/api/s2s/user/user.go | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/s2s/user/user_test.go | 40++++++++++++++++++++++++++++++++++++++++
Ainternal/api/s2s/user/userget.go | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/s2s/user/userget_test.go | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rinternal/apimodule/security/flocblock.go -> internal/api/security/flocblock.go | 0
Ainternal/api/security/security.go | 46++++++++++++++++++++++++++++++++++++++++++++++
Dinternal/apimodule/account/account.go | 117-------------------------------------------------------------------------------
Dinternal/apimodule/account/accountcreate.go | 155-------------------------------------------------------------------------------
Dinternal/apimodule/account/accountget.go | 57---------------------------------------------------------
Dinternal/apimodule/account/accountupdate.go | 260-------------------------------------------------------------------------------
Dinternal/apimodule/account/accountverify.go | 50--------------------------------------------------
Dinternal/apimodule/account/test/accountcreate_test.go | 551-------------------------------------------------------------------------------
Dinternal/apimodule/account/test/accountupdate_test.go | 303-------------------------------------------------------------------------------
Dinternal/apimodule/account/test/accountverify_test.go | 19-------------------
Dinternal/apimodule/admin/admin.go | 88-------------------------------------------------------------------------------
Dinternal/apimodule/admin/emojicreate.go | 130-------------------------------------------------------------------------------
Dinternal/apimodule/apimodule.go | 33---------------------------------
Dinternal/apimodule/app/app.go | 77-----------------------------------------------------------------------------
Dinternal/apimodule/app/appcreate.go | 119-------------------------------------------------------------------------------
Dinternal/apimodule/app/test/app_test.go | 21---------------------
Dinternal/apimodule/auth/auth.go | 88-------------------------------------------------------------------------------
Dinternal/apimodule/auth/authorize.go | 204-------------------------------------------------------------------------------
Dinternal/apimodule/auth/middleware.go | 76----------------------------------------------------------------------------
Dinternal/apimodule/auth/signin.go | 116-------------------------------------------------------------------------------
Dinternal/apimodule/auth/test/auth_test.go | 166-------------------------------------------------------------------------------
Dinternal/apimodule/fileserver/fileserver.go | 84-------------------------------------------------------------------------------
Dinternal/apimodule/fileserver/servefile.go | 243-------------------------------------------------------------------------------
Dinternal/apimodule/fileserver/test/servefile_test.go | 157-------------------------------------------------------------------------------
Dinternal/apimodule/media/media.go | 76----------------------------------------------------------------------------
Dinternal/apimodule/media/mediacreate.go | 193-------------------------------------------------------------------------------
Dinternal/apimodule/media/test/mediacreate_test.go | 194-------------------------------------------------------------------------------
Dinternal/apimodule/mock_ClientAPIModule.go | 43-------------------------------------------
Dinternal/apimodule/security/security.go | 52----------------------------------------------------
Dinternal/apimodule/status/status.go | 158-------------------------------------------------------------------------------
Dinternal/apimodule/status/statuscreate.go | 462-------------------------------------------------------------------------------
Dinternal/apimodule/status/statusdelete.go | 107-------------------------------------------------------------------------------
Dinternal/apimodule/status/statusfave.go | 137-------------------------------------------------------------------------------
Dinternal/apimodule/status/statusfavedby.go | 129-------------------------------------------------------------------------------
Dinternal/apimodule/status/statusget.go | 112-------------------------------------------------------------------------------
Dinternal/apimodule/status/statusunfave.go | 137-------------------------------------------------------------------------------
Dinternal/apimodule/status/test/statuscreate_test.go | 346-------------------------------------------------------------------------------
Dinternal/apimodule/status/test/statusfave_test.go | 207-------------------------------------------------------------------------------
Dinternal/apimodule/status/test/statusfavedby_test.go | 159-------------------------------------------------------------------------------
Dinternal/apimodule/status/test/statusget_test.go | 168-------------------------------------------------------------------------------
Dinternal/apimodule/status/test/statusunfave_test.go | 219-------------------------------------------------------------------------------
Dinternal/cache/mock_Cache.go | 47-----------------------------------------------
Dinternal/config/mock_KeyedFlags.go | 66------------------------------------------------------------------
Minternal/db/db.go | 25++++++++-----------------
Minternal/db/federating_db.go | 119++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Dinternal/db/gtsmodel/account.go | 142-------------------------------------------------------------------------------
Dinternal/db/gtsmodel/emoji.go | 75---------------------------------------------------------------------------
Dinternal/db/gtsmodel/mediaattachment.go | 150-------------------------------------------------------------------------------
Dinternal/db/mock_DB.go | 484-------------------------------------------------------------------------------
Minternal/db/pg.go | 43++++++++++++++++++++++++++++---------------
Minternal/db/pg_test.go | 2+-
Dinternal/distributor/distributor.go | 110-------------------------------------------------------------------------------
Dinternal/distributor/mock_Distributor.go | 70----------------------------------------------------------------------
Ainternal/federation/clock.go | 42++++++++++++++++++++++++++++++++++++++++++
Ainternal/federation/commonbehavior.go | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/federation/federatingactor.go | 136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/federation/federatingprotocol.go | 247+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dinternal/federation/federation.go | 303-------------------------------------------------------------------------------
Ainternal/federation/federator.go | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/federation/federator_test.go | 190+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/federation/util.go | 237+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/gotosocial/actions.go | 64++++++++++++++++++++++++++++++++--------------------------------
Minternal/gotosocial/gotosocial.go | 23++++++++++-------------
Dinternal/gotosocial/mock_Gotosocial.go | 42------------------------------------------
Rinternal/db/gtsmodel/README.md -> internal/gtsmodel/README.md | 0
Ainternal/gtsmodel/account.go | 148+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rinternal/db/gtsmodel/activitystreams.go -> internal/gtsmodel/activitystreams.go | 0
Rinternal/db/gtsmodel/application.go -> internal/gtsmodel/application.go | 0
Rinternal/db/gtsmodel/block.go -> internal/gtsmodel/block.go | 0
Rinternal/db/gtsmodel/domainblock.go -> internal/gtsmodel/domainblock.go | 0
Rinternal/db/gtsmodel/emaildomainblock.go -> internal/gtsmodel/emaildomainblock.go | 0
Ainternal/gtsmodel/emoji.go | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rinternal/db/gtsmodel/follow.go -> internal/gtsmodel/follow.go | 0
Rinternal/db/gtsmodel/followrequest.go -> internal/gtsmodel/followrequest.go | 0
Ainternal/gtsmodel/mediaattachment.go | 150+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rinternal/db/gtsmodel/mention.go -> internal/gtsmodel/mention.go | 0
Rinternal/db/gtsmodel/poll.go -> internal/gtsmodel/poll.go | 0
Rinternal/db/gtsmodel/status.go -> internal/gtsmodel/status.go | 0
Rinternal/db/gtsmodel/statusbookmark.go -> internal/gtsmodel/statusbookmark.go | 0
Rinternal/db/gtsmodel/statusfave.go -> internal/gtsmodel/statusfave.go | 0
Rinternal/db/gtsmodel/statusmute.go -> internal/gtsmodel/statusmute.go | 0
Rinternal/db/gtsmodel/statuspin.go -> internal/gtsmodel/statuspin.go | 0
Rinternal/db/gtsmodel/tag.go -> internal/gtsmodel/tag.go | 0
Rinternal/db/gtsmodel/user.go -> internal/gtsmodel/user.go | 0
Dinternal/mastotypes/converter.go | 544-------------------------------------------------------------------------------
Dinternal/mastotypes/mastomodel/README.md | 5-----
Dinternal/mastotypes/mastomodel/account.go | 131-------------------------------------------------------------------------------
Dinternal/mastotypes/mastomodel/activity.go | 31-------------------------------
Dinternal/mastotypes/mastomodel/admin.go | 81-------------------------------------------------------------------------------
Dinternal/mastotypes/mastomodel/announcement.go | 37-------------------------------------
Dinternal/mastotypes/mastomodel/announcementreaction.go | 33---------------------------------
Dinternal/mastotypes/mastomodel/application.go | 55-------------------------------------------------------
Dinternal/mastotypes/mastomodel/attachment.go | 98-------------------------------------------------------------------------------
Dinternal/mastotypes/mastomodel/card.go | 61-------------------------------------------------------------
Dinternal/mastotypes/mastomodel/context.go | 27---------------------------
Dinternal/mastotypes/mastomodel/conversation.go | 36------------------------------------
Dinternal/mastotypes/mastomodel/emoji.go | 48------------------------------------------------
Dinternal/mastotypes/mastomodel/error.go | 32--------------------------------
Dinternal/mastotypes/mastomodel/featuredtag.go | 33---------------------------------
Dinternal/mastotypes/mastomodel/field.go | 33---------------------------------
Dinternal/mastotypes/mastomodel/filter.go | 46----------------------------------------------
Dinternal/mastotypes/mastomodel/history.go | 29-----------------------------
Dinternal/mastotypes/mastomodel/identityproof.go | 33---------------------------------
Dinternal/mastotypes/mastomodel/instance.go | 72------------------------------------------------------------------------
Dinternal/mastotypes/mastomodel/list.go | 31-------------------------------
Dinternal/mastotypes/mastomodel/marker.go | 37-------------------------------------
Dinternal/mastotypes/mastomodel/mention.go | 31-------------------------------
Dinternal/mastotypes/mastomodel/notification.go | 45---------------------------------------------
Dinternal/mastotypes/mastomodel/oauth.go | 37-------------------------------------
Dinternal/mastotypes/mastomodel/poll.go | 64----------------------------------------------------------------
Dinternal/mastotypes/mastomodel/preferences.go | 40----------------------------------------
Dinternal/mastotypes/mastomodel/pushsubscription.go | 45---------------------------------------------
Dinternal/mastotypes/mastomodel/relationship.go | 49-------------------------------------------------
Dinternal/mastotypes/mastomodel/results.go | 29-----------------------------
Dinternal/mastotypes/mastomodel/scheduledstatus.go | 39---------------------------------------
Dinternal/mastotypes/mastomodel/source.go | 41-----------------------------------------
Dinternal/mastotypes/mastomodel/status.go | 120-------------------------------------------------------------------------------
Dinternal/mastotypes/mastomodel/tag.go | 27---------------------------
Dinternal/mastotypes/mastomodel/token.go | 31-------------------------------
Dinternal/mastotypes/mock_Converter.go | 148-------------------------------------------------------------------------------
Minternal/media/media.go | 148++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Minternal/media/media_test.go | 4++--
Minternal/media/mock_MediaHandler.go | 2+-
Minternal/media/util.go | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Minternal/media/util_test.go | 4++--
Ainternal/message/accountprocess.go | 168+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/message/adminprocess.go | 48++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/message/appprocess.go | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/message/error.go | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/message/fediprocess.go | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/message/mediaprocess.go | 188+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/message/processor.go | 215+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/message/processorutil.go | 304+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/message/statusprocess.go | 350+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/oauth/clientstore.go | 3++-
Minternal/oauth/clientstore_test.go | 13+++++++------
Minternal/oauth/oauth_test.go | 2+-
Minternal/oauth/server.go | 172+++++++++++++++++++------------------------------------------------------------
Minternal/oauth/tokenstore_test.go | 2+-
Ainternal/oauth/util.go | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/storage/inmem.go | 2+-
Ainternal/transport/controller.go | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/typeutils/accountable.go | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/typeutils/asextractionutil.go | 216+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/typeutils/astointernal.go | 164+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/typeutils/astointernal_test.go | 206+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/typeutils/converter.go | 113+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/typeutils/converter_test.go | 40++++++++++++++++++++++++++++++++++++++++
Ainternal/typeutils/frontendtointernal.go | 39+++++++++++++++++++++++++++++++++++++++
Ainternal/typeutils/internaltoas.go | 260+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/typeutils/internaltoas_test.go | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/typeutils/internaltofrontend.go | 505+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dinternal/util/parse.go | 96-------------------------------------------------------------------------------
Minternal/util/regexes.go | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Dinternal/util/status.go | 96-------------------------------------------------------------------------------
Dinternal/util/status_test.go | 105-------------------------------------------------------------------------------
Ainternal/util/statustools.go | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/util/statustools_test.go | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/util/uri.go | 218+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/util/validation.go | 49+++++++++++++------------------------------------
Minternal/util/validation_test.go | 81++++++++++++++++++++++++++++++++++++++++---------------------------------------
Mtestrig/actions.go | 71++++++++++++++++++++++++++++++++++++-----------------------------------
Mtestrig/db.go | 4++--
Dtestrig/distributor.go | 26--------------------------
Atestrig/federator.go | 29+++++++++++++++++++++++++++++
Dtestrig/mastoconverter.go | 29-----------------------------
Atestrig/media/test-jpeg.jpg | 0
Atestrig/processor.go | 31+++++++++++++++++++++++++++++++
Mtestrig/testmodels.go | 558+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Atestrig/transportcontroller.go | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atestrig/typeconverter.go | 29+++++++++++++++++++++++++++++
Mtestrig/util.go | 11+++++++++++
251 files changed, 12611 insertions(+), 10634 deletions(-)

diff --git 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/api/apimodule.go b/internal/api/apimodule.go @@ -0,0 +1,37 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package api + +import ( + "github.com/superseriousbusiness/gotosocial/internal/router" +) + +// 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 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 +} diff --git a/internal/api/client/account/account.go b/internal/api/client/account/account.go @@ -0,0 +1,85 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package account + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/message" + + "github.com/superseriousbusiness/gotosocial/internal/router" +) + +const ( + // IDKey is the key to use for retrieving account ID in requests + IDKey = "id" + // BasePath is the base API path for this module + BasePath = "/api/v1/accounts" + // BasePathWithID is the base path for this module with the ID key + BasePathWithID = BasePath + "/:" + IDKey + // VerifyPath is for verifying account credentials + VerifyPath = BasePath + "/verify_credentials" + // UpdateCredentialsPath is for updating account credentials + UpdateCredentialsPath = BasePath + "/update_credentials" +) + +// Module implements the ClientAPIModule interface for account-related actions +type Module struct { + config *config.Config + processor message.Processor + log *logrus.Logger +} + +// New returns a new account module +func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule { + return &Module{ + config: config, + processor: processor, + log: log, + } +} + +// Route attaches all routes from this module to the given router +func (m *Module) Route(r router.Router) error { + r.AttachHandler(http.MethodPost, BasePath, m.AccountCreatePOSTHandler) + r.AttachHandler(http.MethodGet, BasePathWithID, m.muxHandler) + r.AttachHandler(http.MethodPatch, BasePathWithID, m.muxHandler) + return nil +} + +func (m *Module) muxHandler(c *gin.Context) { + ru := c.Request.RequestURI + switch c.Request.Method { + case http.MethodGet: + if strings.HasPrefix(ru, VerifyPath) { + m.AccountVerifyGETHandler(c) + } else { + m.AccountGETHandler(c) + } + case http.MethodPatch: + if strings.HasPrefix(ru, UpdateCredentialsPath) { + m.AccountUpdateCredentialsPATCHHandler(c) + } + } +} diff --git a/internal/api/client/account/account_test.go 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/api/client/account/accountcreate.go b/internal/api/client/account/accountcreate.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 <http://www.gnu.org/licenses/>. +*/ + +package account + +import ( + "errors" + "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/oauth" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +// AccountCreatePOSTHandler handles create account requests, validates them, +// and puts them in the database if they're valid. +// 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.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()}) + return + } + + l.Trace("parsing request form") + 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"}) + return + } + + l.Tracef("validating form %+v", form) + 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 + } + + clientIP := c.ClientIP() + l.Tracef("attempting to parse client ip address %s", clientIP) + signUpIP := net.ParseIP(clientIP) + if signUpIP == nil { + l.Debugf("error validating sign up ip address %s", clientIP) + c.JSON(http.StatusBadRequest, gin.H{"error": "ip address could not be parsed from request"}) + return + } + + 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()}) + return + } + + c.JSON(http.StatusOK, ti) +} + +// 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 *model.AccountCreateRequest, c *config.AccountsConfig) error { + if !c.OpenRegistration { + return errors.New("registration is not open for this server") + } + + if err := util.ValidateUsername(form.Username); err != nil { + return err + } + + if err := util.ValidateEmail(form.Email); err != nil { + return err + } + + if err := util.ValidateNewPassword(form.Password); err != nil { + return err + } + + if !form.Agreement { + return errors.New("agreement to terms and conditions not given") + } + + if err := util.ValidateLanguage(form.Locale); err != nil { + return err + } + + if err := util.ValidateSignUpReason(form.Reason, c.ReasonRequired); err != nil { + return err + } + + return nil +} diff --git a/internal/api/client/account/accountcreate_test.go 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 <http://www.gnu.org/licenses/>. +// */ + +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 := &gtsmodel.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 := &gtsmodel.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/api/client/account/accountget.go b/internal/api/client/account/accountget.go @@ -0,0 +1,52 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package account + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// AccountGETHandler serves the account information held by the server in response to a GET +// request. It should be served as a GET at /api/v1/accounts/:id. +// +// 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 + } + + acctInfo, err := m.processor.AccountGet(authed, targetAcctID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + + c.JSON(http.StatusOK, acctInfo) +} diff --git a/internal/api/client/account/accountupdate.go 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 <http://www.gnu.org/licenses/>. +*/ + +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 @@ -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 <http://www.gnu.org/licenses/>. +*/ + +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/api/client/account/accountverify.go b/internal/api/client/account/accountverify.go @@ -0,0 +1,48 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package account + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// AccountVerifyGETHandler serves a user's account details to them IF they reached this +// handler while in possession of a valid token, according to the oauth middleware. +// 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.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 + } + + acctSensitive, err := m.processor.AccountGet(authed, authed.Account.ID) + if err != nil { + l.Debugf("error getting account from processor: %s", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + return + } + + c.JSON(http.StatusOK, acctSensitive) +} diff --git a/internal/api/client/account/accountverify_test.go b/internal/api/client/account/accountverify_test.go @@ -0,0 +1,19 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package account_test diff --git a/internal/api/client/admin/admin.go b/internal/api/client/admin/admin.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 <http://www.gnu.org/licenses/>. +*/ + +package admin + +import ( + "net/http" + + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/message" + "github.com/superseriousbusiness/gotosocial/internal/router" +) + +const ( + // BasePath is the base API path for this module + 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 + processor message.Processor + log *logrus.Logger +} + +// New returns a new admin module +func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule { + return &Module{ + config: config, + processor: processor, + log: log, + } +} + +// Route attaches all routes from this module to the given router +func (m *Module) Route(r router.Router) error { + r.AttachHandler(http.MethodPost, EmojiPath, m.emojiCreatePOSTHandler) + return nil +} diff --git a/internal/api/client/admin/emojicreate.go b/internal/api/client/admin/emojicreate.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 <http://www.gnu.org/licenses/>. +*/ + +package admin + +import ( + "errors" + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +func (m *Module) emojiCreatePOSTHandler(c *gin.Context) { + l := m.log.WithFields(logrus.Fields{ + "func": "emojiCreatePOSTHandler", + "request_uri": c.Request.RequestURI, + "user_agent": c.Request.UserAgent(), + "origin_ip": c.ClientIP(), + }) + + // make sure we're authed with an admin account + authed, err := oauth.Authed(c, true, true, true, true) // 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 + } + if !authed.User.Admin { + l.Debugf("user %s not an admin", authed.User.ID) + c.JSON(http.StatusForbidden, gin.H{"error": "not an admin"}) + return + } + + // extract the media create form from the request context + l.Tracef("parsing request form: %+v", c.Request.Form) + form := &model.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)}) + 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 := validateCreateEmoji(form); err != nil { + l.Debugf("error validating form: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + mastoEmoji, err := m.processor.AdminEmojiCreate(authed, form) + if err != nil { + 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 *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") + } + + // a very superficial check to see if the media size limit is exceeded + if form.Image.Size > media.EmojiMaxBytes { + return fmt.Errorf("file size limit exceeded: limit is %d bytes but emoji was %d bytes", media.EmojiMaxBytes, form.Image.Size) + } + + return util.ValidateEmojiShortcode(form.Shortcode) +} diff --git a/internal/api/client/app/app.go b/internal/api/client/app/app.go @@ -0,0 +1,54 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package app + +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" +) + +// BasePath is the base path for this api module +const BasePath = "/api/v1/apps" + +// Module implements the ClientAPIModule interface for requests relating to registering/removing applications +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.ClientModule { + return &Module{ + config: config, + processor: processor, + log: log, + } +} + +// Route satisfies the RESTAPIModule interface +func (m *Module) Route(s router.Router) error { + s.AttachHandler(http.MethodPost, BasePath, m.AppsPOSTHandler) + return nil +} diff --git a/internal/api/client/app/app_test.go b/internal/api/client/app/app_test.go @@ -0,0 +1,21 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package app_test + +// TODO: write tests diff --git a/internal/api/client/app/appcreate.go 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 <http://www.gnu.org/licenses/>. +*/ + +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/api/client/auth/auth.go b/internal/api/client/auth/auth.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 <http://www.gnu.org/licenses/>. +*/ + +package auth + +import ( + "net/http" + + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/router" +) + +const ( + // AuthSignInPath is the API path for users to sign in through + AuthSignInPath = "/auth/sign_in" + // OauthTokenPath is the API path to use for granting token requests to users with valid credentials + 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 { + config *config.Config + db db.DB + server oauth.Server + log *logrus.Logger +} + +// New returns a new auth module +func New(config *config.Config, db db.DB, server oauth.Server, log *logrus.Logger) api.ClientModule { + return &Module{ + config: config, + db: db, + server: server, + log: log, + } +} + +// Route satisfies the RESTAPIModule interface +func (m *Module) Route(s router.Router) error { + s.AttachHandler(http.MethodGet, AuthSignInPath, m.SignInGETHandler) + s.AttachHandler(http.MethodPost, AuthSignInPath, m.SignInPOSTHandler) + + s.AttachHandler(http.MethodPost, OauthTokenPath, m.TokenPOSTHandler) + + s.AttachHandler(http.MethodGet, OauthAuthorizePath, m.AuthorizeGETHandler) + s.AttachHandler(http.MethodPost, OauthAuthorizePath, m.AuthorizePOSTHandler) + + s.AttachMiddleware(m.OauthTokenMiddleware) + return nil +} diff --git a/internal/api/client/auth/auth_test.go b/internal/api/client/auth/auth_test.go @@ -0,0 +1,166 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package auth_test + +import ( + "context" + "fmt" + "testing" + + "github.com/google/uuid" + "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/oauth" + "golang.org/x/crypto/bcrypt" +) + +type AuthTestSuite struct { + suite.Suite + oauthServer oauth.Server + db db.DB + testAccount *gtsmodel.Account + testApplication *gtsmodel.Application + testUser *gtsmodel.User + testClient *oauth.Client + config *config.Config +} + +// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout +func (suite *AuthTestSuite) SetupSuite() { + c := config.Empty() + // we're running on localhost without https so set the protocol to http + c.Protocol = "http" + // just for testing + c.Host = "localhost:8080" + // because go tests are run within the test package directory, we need to fiddle with the templateconfig + // basedir in a way that we wouldn't normally have to do when running the binary, in order to make + // the templates actually load + c.TemplateConfig.BaseDir = "../../../web/template/" + c.DBConfig = &config.DBConfig{ + Type: "postgres", + Address: "localhost", + Port: 5432, + User: "postgres", + Password: "postgres", + Database: "postgres", + ApplicationName: "gotosocial", + } + suite.config = c + + encryptedPassword, err := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost) + if err != nil { + logrus.Panicf("error encrypting user pass: %s", err) + } + + acctID := uuid.NewString() + + suite.testAccount = &gtsmodel.Account{ + ID: acctID, + Username: "test_user", + } + suite.testUser = &gtsmodel.User{ + EncryptedPassword: string(encryptedPassword), + Email: "user@example.org", + AccountID: acctID, + } + suite.testClient = &oauth.Client{ + ID: "a-known-client-id", + Secret: "some-secret", + Domain: fmt.Sprintf("%s://%s", c.Protocol, c.Host), + } + suite.testApplication = &gtsmodel.Application{ + 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: uuid.NewString(), + } +} + +// SetupTest creates a postgres connection and creates the oauth_clients table before each test +func (suite *AuthTestSuite) SetupTest() { + + log := logrus.New() + log.SetLevel(logrus.TraceLevel) + db, err := db.NewPostgresService(context.Background(), suite.config, log) + if err != nil { + logrus.Panicf("error creating database connection: %s", err) + } + + suite.db = db + + models := []interface{}{ + &oauth.Client{}, + &oauth.Token{}, + &gtsmodel.User{}, + &gtsmodel.Account{}, + &gtsmodel.Application{}, + } + + for _, m := range models { + if err := suite.db.CreateTable(m); err != nil { + logrus.Panicf("db connection error: %s", err) + } + } + + suite.oauthServer = oauth.New(suite.db, log) + + if err := suite.db.Put(suite.testAccount); err != nil { + logrus.Panicf("could not insert test account into db: %s", err) + } + if err := suite.db.Put(suite.testUser); err != nil { + logrus.Panicf("could not insert test user into db: %s", err) + } + if err := suite.db.Put(suite.testClient); err != nil { + logrus.Panicf("could not insert test client into db: %s", err) + } + if err := suite.db.Put(suite.testApplication); err != nil { + logrus.Panicf("could not insert test application into db: %s", err) + } + +} + +// TearDownTest drops the oauth_clients table and closes the pg connection after each test +func (suite *AuthTestSuite) TearDownTest() { + models := []interface{}{ + &oauth.Client{}, + &oauth.Token{}, + &gtsmodel.User{}, + &gtsmodel.Account{}, + &gtsmodel.Application{}, + } + for _, m := range models { + if err := suite.db.DropTable(m); err != nil { + logrus.Panicf("error dropping table: %s", err) + } + } + if err := suite.db.Stop(context.Background()); err != nil { + logrus.Panicf("error closing db connection: %s", err) + } + suite.db = nil +} + +func TestAuthTestSuite(t *testing.T) { + suite.Run(t, new(AuthTestSuite)) +} diff --git a/internal/api/client/auth/authorize.go b/internal/api/client/auth/authorize.go @@ -0,0 +1,204 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package auth + +import ( + "errors" + "fmt" + "net/http" + "net/url" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "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 +// The idea here is to present an oauth authorize page to the user, with a button +// that they have to click to accept. See here: https://docs.joinmastodon.org/methods/apps/oauth/#authorize-a-user +func (m *Module) AuthorizeGETHandler(c *gin.Context) { + l := m.log.WithField("func", "AuthorizeGETHandler") + s := sessions.Default(c) + + // UserID will be set in the session by AuthorizePOSTHandler if the caller has already gone through the authentication flow + // If it's not set, then we don't know yet who the user is, so we need to redirect them to the sign in page. + userID, ok := s.Get("userid").(string) + if !ok || userID == "" { + l.Trace("userid was empty, parsing form then redirecting to sign in page") + if err := parseAuthForm(c, l); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + } else { + c.Redirect(http.StatusFound, AuthSignInPath) + } + return + } + + // We can use the client_id on the session to retrieve info about the app associated with the client_id + clientID, ok := s.Get("client_id").(string) + if !ok || clientID == "" { + c.JSON(http.StatusInternalServerError, gin.H{"error": "no client_id found in session"}) + return + } + app := &gtsmodel.Application{ + ClientID: clientID, + } + if err := m.db.GetWhere("client_id", app.ClientID, app); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("no application found for client id %s", clientID)}) + return + } + + // we can also use the userid of the user to fetch their username from the db to greet them nicely <3 + user := &gtsmodel.User{ + ID: userID, + } + if err := m.db.GetByID(user.ID, user); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + acct := &gtsmodel.Account{ + ID: user.AccountID, + } + + if err := m.db.GetByID(acct.ID, acct); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Finally we should also get the redirect and scope of this particular request, as stored in the session. + redirect, ok := s.Get("redirect_uri").(string) + if !ok || redirect == "" { + c.JSON(http.StatusInternalServerError, gin.H{"error": "no redirect_uri found in session"}) + return + } + scope, ok := s.Get("scope").(string) + if !ok || scope == "" { + c.JSON(http.StatusInternalServerError, gin.H{"error": "no scope found in session"}) + return + } + + // the authorize template will display a form to the user where they can get some information + // about the app that's trying to authorize, and the scope of the request. + // They can then approve it if it looks OK to them, which will POST to the AuthorizePOSTHandler + l.Trace("serving authorize html") + c.HTML(http.StatusOK, "authorize.tmpl", gin.H{ + "appname": app.Name, + "appwebsite": app.Website, + "redirect": redirect, + "scope": scope, + "user": acct.Username, + }) +} + +// AuthorizePOSTHandler should be served as POST at https://example.org/oauth/authorize +// At this point we assume that the user has A) logged in and B) accepted that the app should act for them, +// so we should proceed with the authentication flow and generate an oauth token for them if we can. +// See here: https://docs.joinmastodon.org/methods/apps/oauth/#authorize-a-user +func (m *Module) AuthorizePOSTHandler(c *gin.Context) { + l := m.log.WithField("func", "AuthorizePOSTHandler") + s := sessions.Default(c) + + // At this point we know the user has said 'yes' to allowing the application and oauth client + // work for them, so we can set the + + // We need to retrieve the original form submitted to the authorizeGEThandler, and + // recreate it on the request so that it can be used further by the oauth2 library. + // So first fetch all the values from the session. + forceLogin, ok := s.Get("force_login").(string) + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": "session missing force_login"}) + return + } + responseType, ok := s.Get("response_type").(string) + if !ok || responseType == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "session missing response_type"}) + return + } + clientID, ok := s.Get("client_id").(string) + if !ok || clientID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "session missing client_id"}) + return + } + redirectURI, ok := s.Get("redirect_uri").(string) + if !ok || redirectURI == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "session missing redirect_uri"}) + return + } + scope, ok := s.Get("scope").(string) + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": "session missing scope"}) + return + } + userID, ok := s.Get("userid").(string) + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": "session missing userid"}) + return + } + // we're done with the session so we can clear it now + s.Clear() + + // now set the values on the request + values := url.Values{} + values.Set("force_login", forceLogin) + values.Set("response_type", responseType) + values.Set("client_id", clientID) + values.Set("redirect_uri", redirectURI) + values.Set("scope", scope) + values.Set("userid", userID) + c.Request.Form = values + l.Tracef("values on request set to %+v", c.Request.Form) + + // and proceed with authorization using the oauth2 library + if err := m.server.HandleAuthorizeRequest(c.Writer, c.Request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + } +} + +// parseAuthForm parses the OAuthAuthorize form in the gin context, and stores +// the values in the form into the session. +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 := &model.OAuthAuthorize{} + if err := c.ShouldBind(form); err != nil { + return err + } + l.Tracef("parsed form: %+v", form) + + // these fields are *required* so check 'em + if form.ResponseType == "" || form.ClientID == "" || form.RedirectURI == "" { + return errors.New("missing one of: response_type, client_id or redirect_uri") + } + + // set default scope to read + if form.Scope == "" { + form.Scope = "read" + } + + // save these values from the form so we can use them elsewhere in the session + s.Set("force_login", form.ForceLogin) + s.Set("response_type", form.ResponseType) + s.Set("client_id", form.ClientID) + s.Set("redirect_uri", form.RedirectURI) + s.Set("scope", form.Scope) + return s.Save() +} diff --git a/internal/api/client/auth/middleware.go b/internal/api/client/auth/middleware.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 <http://www.gnu.org/licenses/>. +*/ + +package auth + +import ( + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// OauthTokenMiddleware checks if the client has presented a valid oauth Bearer token. +// If so, it will check the User that the token belongs to, and set that in the context of +// the request. Then, it will look up the account for that user, and set that in the request too. +// 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", "OauthTokenMiddleware") + l.Trace("entering OauthTokenMiddleware") + + ti, err := m.server.ValidationBearerToken(c.Request) + if err != nil { + l.Trace("no valid token presented: continuing with unauthenticated request") + return + } + c.Set(oauth.SessionAuthorizedToken, ti) + l.Tracef("set gin context %s to %+v", oauth.SessionAuthorizedToken, ti) + + // check for user-level token + if uid := ti.GetUserID(); uid != "" { + l.Tracef("authenticated user %s with bearer token, scope is %s", uid, ti.GetScope()) + + // fetch user's and account for this user id + user := &gtsmodel.User{} + if err := m.db.GetByID(uid, user); err != nil || user == nil { + l.Warnf("no user found for validated uid %s", uid) + return + } + c.Set(oauth.SessionAuthorizedUser, user) + l.Tracef("set gin context %s to %+v", oauth.SessionAuthorizedUser, user) + + acct := &gtsmodel.Account{} + if err := m.db.GetByID(user.AccountID, acct); err != nil || acct == nil { + l.Warnf("no account found for validated user %s", uid) + return + } + c.Set(oauth.SessionAuthorizedAccount, acct) + l.Tracef("set gin context %s to %+v", oauth.SessionAuthorizedAccount, acct) + } + + // check for application token + if cid := ti.GetClientID(); cid != "" { + l.Tracef("authenticated client %s with bearer token, scope is %s", cid, ti.GetScope()) + app := &gtsmodel.Application{} + if err := m.db.GetWhere("client_id", cid, app); err != nil { + l.Tracef("no app found for client %s", cid) + } + c.Set(oauth.SessionAuthorizedApplication, app) + l.Tracef("set gin context %s to %+v", oauth.SessionAuthorizedApplication, app) + } +} diff --git a/internal/api/client/auth/signin.go b/internal/api/client/auth/signin.go @@ -0,0 +1,116 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package auth + +import ( + "errors" + "net/http" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "golang.org/x/crypto/bcrypt" +) + +// login just wraps a form-submitted username (we want an email) and password +type login struct { + Email string `form:"username"` + Password string `form:"password"` +} + +// SignInGETHandler should be served at https://example.org/auth/sign_in. +// The idea is to present a sign in page to the user, where they can enter their username and password. +// The form will then POST to the sign in page, which will be handled by SignInPOSTHandler +func (m *Module) SignInGETHandler(c *gin.Context) { + m.log.WithField("func", "SignInGETHandler").Trace("serving sign in html") + c.HTML(http.StatusOK, "sign-in.tmpl", gin.H{}) +} + +// SignInPOSTHandler should be served at https://example.org/auth/sign_in. +// The idea is to present a sign in page to the user, where they can enter their username and password. +// The handler will then redirect to the auth handler served at /auth +func (m *Module) SignInPOSTHandler(c *gin.Context) { + l := m.log.WithField("func", "SignInPOSTHandler") + s := sessions.Default(c) + form := &login{} + if err := c.ShouldBind(form); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + l.Tracef("parsed form: %+v", form) + + userid, err := m.ValidatePassword(form.Email, form.Password) + if err != nil { + c.String(http.StatusForbidden, err.Error()) + return + } + + s.Set("userid", userid) + if err := s.Save(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + l.Trace("redirecting to auth page") + c.Redirect(http.StatusFound, OauthAuthorizePath) +} + +// ValidatePassword takes an email address and a password. +// The goal is to authenticate the password against the one for that email +// address stored in the database. If OK, we return the userid (a uuid) for that user, +// so that it can be used in further Oauth flows to generate a token/retreieve an oauth client from the db. +func (m *Module) ValidatePassword(email string, password string) (userid string, err error) { + l := m.log.WithField("func", "ValidatePassword") + + // make sure an email/password was provided and bail if not + if email == "" || password == "" { + l.Debug("email or password was not provided") + return incorrectPassword() + } + + // first we select the user from the database based on email address, bail if no user found for that email + gtsUser := &gtsmodel.User{} + + if err := m.db.GetWhere("email", email, gtsUser); err != nil { + l.Debugf("user %s was not retrievable from db during oauth authorization attempt: %s", email, err) + return incorrectPassword() + } + + // make sure a password is actually set and bail if not + if gtsUser.EncryptedPassword == "" { + l.Warnf("encrypted password for user %s was empty for some reason", gtsUser.Email) + return incorrectPassword() + } + + // compare the provided password with the encrypted one from the db, bail if they don't match + if err := bcrypt.CompareHashAndPassword([]byte(gtsUser.EncryptedPassword), []byte(password)); err != nil { + l.Debugf("password hash didn't match for user %s during login attempt: %s", gtsUser.Email, err) + return incorrectPassword() + } + + // If we've made it this far the email/password is correct, so we can just return the id of the user. + userid = gtsUser.ID + l.Tracef("returning (%s, %s)", userid, err) + return +} + +// incorrectPassword is just a little helper function to use in the ValidatePassword function +func incorrectPassword() (string, error) { + return "", errors.New("password/email combination was incorrect") +} diff --git a/internal/apimodule/auth/token.go b/internal/api/client/auth/token.go diff --git a/internal/api/client/fileserver/fileserver.go b/internal/api/client/fileserver/fileserver.go @@ -0,0 +1,82 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package fileserver + +import ( + "fmt" + "net/http" + + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/message" + "github.com/superseriousbusiness/gotosocial/internal/router" +) + +const ( + // AccountIDKey is the url key for account id (an account uuid) + AccountIDKey = "account_id" + // MediaTypeKey is the url key for media type (usually something like attachment or header etc) + MediaTypeKey = "media_type" + // 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" +) + +// 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 + processor message.Processor + log *logrus.Logger + storageBase string +} + +// New returns a new fileServer module +func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule { + return &FileServer{ + config: config, + processor: processor, + log: log, + storageBase: config.StorageConfig.ServeBasePath, + } +} + +// Route satisfies the RESTAPIModule interface +func (m *FileServer) Route(s router.Router) error { + s.AttachHandler(http.MethodGet, fmt.Sprintf("%s/:%s/:%s/:%s/:%s", m.storageBase, AccountIDKey, MediaTypeKey, MediaSizeKey, FileNameKey), m.ServeFile) + return nil +} + +// CreateTables populates necessary tables in the given DB +func (m *FileServer) CreateTables(db db.DB) error { + models := []interface{}{ + &gtsmodel.MediaAttachment{}, + } + + 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/api/client/fileserver/servefile.go 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 <http://www.gnu.org/licenses/>. +*/ + +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/api/client/fileserver/servefile_test.go b/internal/api/client/fileserver/servefile_test.go @@ -0,0 +1,163 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package fileserver_test + +import ( + "context" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "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/api/client/fileserver" + "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/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 + federator federation.Federator + tc typeutils.TypeConverter + processor message.Processor + mediaHandler media.Handler + oauthServer oauth.Server + + // 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 + + // item being tested + fileServer *fileserver.FileServer +} + +/* + TEST INFRASTRUCTURE +*/ + +func (suite *ServeFileTestSuite) SetupSuite() { + // setup standard items + suite.config = testrig.NewTestConfig() + suite.db = testrig.NewTestDB() + suite.log = testrig.NewTestLog() + suite.storage = testrig.NewTestStorage() + 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.processor, suite.log).(*fileserver.FileServer) +} + +func (suite *ServeFileTestSuite) TearDownSuite() { + if err := suite.db.Stop(context.Background()); err != nil { + logrus.Panicf("error closing db connection: %s", err) + } +} + +func (suite *ServeFileTestSuite) 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() +} + +func (suite *ServeFileTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) +} + +/* + ACTUAL TESTS +*/ + +func (suite *ServeFileTestSuite) TestServeOriginalFileSuccessful() { + targetAttachment, ok := suite.testAttachments["admin_account_status_1_attachment_1"] + assert.True(suite.T(), ok) + assert.NotNil(suite.T(), targetAttachment) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodGet, targetAttachment.URL, nil) + + // normally the router would populate these params from the path values, + // but because we're calling the ServeFile function directly, we need to set them manually. + ctx.Params = gin.Params{ + gin.Param{ + Key: fileserver.AccountIDKey, + Value: targetAttachment.AccountID, + }, + gin.Param{ + Key: fileserver.MediaTypeKey, + Value: string(media.Attachment), + }, + gin.Param{ + Key: fileserver.MediaSizeKey, + Value: string(media.Original), + }, + gin.Param{ + Key: fileserver.FileNameKey, + Value: fmt.Sprintf("%s.jpeg", targetAttachment.ID), + }, + } + + // call the function we're testing and check status code + suite.fileServer.ServeFile(ctx) + suite.EqualValues(http.StatusOK, recorder.Code) + + b, err := ioutil.ReadAll(recorder.Body) + assert.NoError(suite.T(), err) + assert.NotNil(suite.T(), b) + + fileInStorage, err := suite.storage.RetrieveFileFrom(targetAttachment.File.Path) + assert.NoError(suite.T(), err) + assert.NotNil(suite.T(), fileInStorage) + assert.Equal(suite.T(), b, fileInStorage) +} + +func TestServeFileTestSuite(t *testing.T) { + suite.Run(t, new(ServeFileTestSuite)) +} diff --git a/internal/api/client/media/media.go b/internal/api/client/media/media.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 <http://www.gnu.org/licenses/>. +*/ + +package media + +import ( + "fmt" + "net/http" + + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/message" + "github.com/superseriousbusiness/gotosocial/internal/router" +) + +// BasePath is the base API path for making media requests +const BasePath = "/api/v1/media" + +// Module implements the ClientAPIModule interface for media +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.ClientModule { + return &Module{ + config: config, + processor: processor, + log: log, + } +} + +// Route satisfies the RESTAPIModule interface +func (m *Module) Route(s router.Router) error { + s.AttachHandler(http.MethodPost, BasePath, m.MediaCreatePOSTHandler) + return nil +} + +// CreateTables populates necessary tables in the given DB +func (m *Module) CreateTables(db db.DB) error { + models := []interface{}{ + &gtsmodel.MediaAttachment{}, + } + + 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/api/client/media/mediacreate.go 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 <http://www.gnu.org/licenses/>. +*/ + +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/api/client/media/mediacreate_test.go b/internal/api/client/media/mediacreate_test.go @@ -0,0 +1,200 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package media_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + 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/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 + federator federation.Federator + tc typeutils.TypeConverter + mediaHandler media.Handler + oauthServer oauth.Server + 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 + + // item being tested + mediaModule *mediamodule.Module +} + +/* + TEST INFRASTRUCTURE +*/ + +func (suite *MediaCreateTestSuite) SetupSuite() { + // setup standard items + suite.config = testrig.NewTestConfig() + suite.db = testrig.NewTestDB() + suite.log = testrig.NewTestLog() + suite.storage = testrig.NewTestStorage() + 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.config, suite.processor, suite.log).(*mediamodule.Module) +} + +func (suite *MediaCreateTestSuite) TearDownSuite() { + if err := suite.db.Stop(context.Background()); err != nil { + logrus.Panicf("error closing db connection: %s", err) + } +} + +func (suite *MediaCreateTestSuite) 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() +} + +func (suite *MediaCreateTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) +} + +/* + ACTUAL TESTS +*/ + +func (suite *MediaCreateTestSuite) TestStatusCreatePOSTImageHandlerSuccessful() { + + // set up the context for the request + t := suite.testTokens["local_account_1"] + oauthToken := oauth.TokenToOauthToken(t) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedToken, oauthToken) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + + // see what's in storage *before* the request + storageKeysBeforeRequest, err := suite.storage.ListKeys() + if err != nil { + panic(err) + } + + // create the request + buf, w, err := testrig.CreateMultipartFormData("file", "../../../../testrig/media/test-jpeg.jpg", map[string]string{ + "description": "this is a test image -- a cool background from somewhere", + "focus": "-0.5,0.5", + }) + if err != nil { + panic(err) + } + ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", mediamodule.BasePath), bytes.NewReader(buf.Bytes())) // the endpoint we're hitting + ctx.Request.Header.Set("Content-Type", w.FormDataContentType()) + + // do the actual request + suite.mediaModule.MediaCreatePOSTHandler(ctx) + + // check what's in storage *after* the request + storageKeysAfterRequest, err := suite.storage.ListKeys() + if err != nil { + panic(err) + } + + // check response + suite.EqualValues(http.StatusAccepted, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + assert.NoError(suite.T(), err) + fmt.Println(string(b)) + + 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(), model.MediaMeta{ + Original: model.MediaDimensions{ + Width: 1920, + Height: 1080, + Size: "1920x1080", + Aspect: 1.7777778, + }, + Small: model.MediaDimensions{ + Width: 256, + Height: 144, + Size: "256x144", + Aspect: 1.7777778, + }, + Focus: model.MediaFocus{ + X: -0.5, + Y: 0.5, + }, + }, attachmentReply.Meta) + assert.Equal(suite.T(), "LjCZnlvyRkRn_NvzRjWF?urqV@f9", attachmentReply.Blurhash) + assert.NotEmpty(suite.T(), attachmentReply.ID) + assert.NotEmpty(suite.T(), attachmentReply.URL) + assert.NotEmpty(suite.T(), attachmentReply.PreviewURL) + assert.Equal(suite.T(), len(storageKeysBeforeRequest)+2, len(storageKeysAfterRequest)) // 2 images should be added to storage: the original and the thumbnail +} + +func TestMediaCreateTestSuite(t *testing.T) { + suite.Run(t, new(MediaCreateTestSuite)) +} diff --git a/internal/api/client/status/status.go b/internal/api/client/status/status.go @@ -0,0 +1,118 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package status + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/message" + "github.com/superseriousbusiness/gotosocial/internal/router" +) + +const ( + // IDKey is for status UUIDs + IDKey = "id" + // BasePath is the base path for serving the status API + 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 + + // ContextPath is used for fetching context of posts + ContextPath = BasePathWithID + "/context" + + // FavouritedPath is for seeing who's faved a given status + FavouritedPath = BasePathWithID + "/favourited_by" + // FavouritePath is for posting a fave on a status + 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" + // UnreblogPath is for undoing a boost/reblog of a given status + UnreblogPath = BasePathWithID + "/unreblog" + + // BookmarkPath is for creating a bookmark on a given status + 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" + // 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" + // 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 + processor message.Processor + log *logrus.Logger +} + +// New returns a new account module +func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule { + return &Module{ + config: config, + processor: processor, + log: log, + } +} + +// Route attaches all routes from this module to the given router +func (m *Module) Route(r router.Router) error { + r.AttachHandler(http.MethodPost, BasePath, m.StatusCreatePOSTHandler) + r.AttachHandler(http.MethodDelete, BasePathWithID, m.StatusDELETEHandler) + + r.AttachHandler(http.MethodPost, FavouritePath, m.StatusFavePOSTHandler) + r.AttachHandler(http.MethodPost, UnfavouritePath, m.StatusUnfavePOSTHandler) + + r.AttachHandler(http.MethodGet, BasePathWithID, m.muxHandler) + 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") + ru := c.Request.RequestURI + + switch c.Request.Method { + case http.MethodGet: + if strings.HasPrefix(ru, ContextPath) { + // TODO + } else if strings.HasPrefix(ru, FavouritedPath) { + m.StatusFavedByGETHandler(c) + } else { + m.StatusGETHandler(c) + } + } +} diff --git a/internal/api/client/status/status_test.go 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 <http://www.gnu.org/licenses/>. +*/ + +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 @@ -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 <http://www.gnu.org/licenses/>. +*/ + +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/api/client/status/statuscreate_test.go b/internal/api/client/status/statuscreate_test.go @@ -0,0 +1,297 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package status_test + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/status" + "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type StatusCreateTestSuite struct { + StatusStandardTestSuite +} + +func (suite *StatusCreateTestSuite) 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 *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") +} + +func (suite *StatusCreateTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) +} + +// Post a new status with some custom visibility settings +func (suite *StatusCreateTestSuite) TestPostNewStatus() { + + 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.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting + ctx.Request.Form = url.Values{ + "status": {"this is a brand new status! #helloworld"}, + "spoiler_text": {"hello hello"}, + "sensitive": {"true"}, + "visibility_advanced": {"mutuals_only"}, + "likeable": {"false"}, + "replyable": {"false"}, + "federated": {"false"}, + } + suite.statusModule.StatusCreatePOSTHandler(ctx) + + // check response + + // 1. we should have OK from our call to the function + suite.EqualValues(http.StatusOK, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + assert.NoError(suite.T(), err) + + 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(), model.VisibilityPrivate, statusReply.Visibility) + assert.Len(suite.T(), statusReply.Tags, 1) + assert.Equal(suite.T(), model.Tag{ + Name: "helloworld", + URL: "http://localhost:8080/tags/helloworld", + }, statusReply.Tags[0]) + + gtsTag := &gtsmodel.Tag{} + err = suite.db.GetWhere("name", "helloworld", gtsTag) + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), statusReply.Account.ID, gtsTag.FirstSeenFromAccountID) +} + +func (suite *StatusCreateTestSuite) TestPostNewStatusWithEmoji() { + + 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.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting + ctx.Request.Form = url.Values{ + "status": {"here is a rainbow emoji a few times! :rainbow: :rainbow: :rainbow: \n here's an emoji that isn't in the db: :test_emoji: "}, + } + suite.statusModule.StatusCreatePOSTHandler(ctx) + + suite.EqualValues(http.StatusOK, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + assert.NoError(suite.T(), err) + + statusReply := &model.Status{} + err = json.Unmarshal(b, statusReply) + assert.NoError(suite.T(), err) + + assert.Equal(suite.T(), "", statusReply.SpoilerText) + assert.Equal(suite.T(), "here is a rainbow emoji a few times! :rainbow: :rainbow: :rainbow: \n here's an emoji that isn't in the db: :test_emoji: ", statusReply.Content) + + assert.Len(suite.T(), statusReply.Emojis, 1) + mastoEmoji := statusReply.Emojis[0] + gtsEmoji := testrig.NewTestEmojis()["rainbow"] + + assert.Equal(suite.T(), gtsEmoji.Shortcode, mastoEmoji.Shortcode) + assert.Equal(suite.T(), gtsEmoji.ImageURL, mastoEmoji.URL) + assert.Equal(suite.T(), gtsEmoji.ImageStaticURL, mastoEmoji.StaticURL) +} + +// Try to reply to a status that doesn't exist +func (suite *StatusCreateTestSuite) TestReplyToNonexistentStatus() { + 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.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting + ctx.Request.Form = url.Values{ + "status": {"this is a reply to a status that doesn't exist"}, + "spoiler_text": {"don't open cuz it won't work"}, + "in_reply_to_id": {"3759e7ef-8ee1-4c0c-86f6-8b70b9ad3d50"}, + } + suite.statusModule.StatusCreatePOSTHandler(ctx) + + // check response + + 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(), `{"error":"bad request"}`, string(b)) +} + +// Post a reply to the status of a local user that allows replies. +func (suite *StatusCreateTestSuite) TestReplyToLocalStatus() { + 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.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting + ctx.Request.Form = url.Values{ + "status": {fmt.Sprintf("hello @%s this reply should work!", testrig.NewTestAccounts()["local_account_2"].Username)}, + "in_reply_to_id": {testrig.NewTestStatuses()["local_account_2_status_1"].ID}, + } + suite.statusModule.StatusCreatePOSTHandler(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) + + 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(), 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) +} + +// Take a media file which is currently not associated with a status, and attach it to a new status. +func (suite *StatusCreateTestSuite) TestAttachNewMediaSuccess() { + 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.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting + ctx.Request.Form = url.Values{ + "status": {"here's an image attachment"}, + "media_ids": {"7a3b9f77-ab30-461e-bdd8-e64bd1db3008"}, + } + suite.statusModule.StatusCreatePOSTHandler(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) + + fmt.Println(string(b)) + + 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(), model.VisibilityPublic, statusReply.Visibility) + + // there should be one media attachment + assert.Len(suite.T(), statusReply.MediaAttachments, 1) + + // get the updated media attachment from the database + gtsAttachment := &gtsmodel.MediaAttachment{} + err = suite.db.GetByID(statusReply.MediaAttachments[0].ID, gtsAttachment) + assert.NoError(suite.T(), err) + + // convert it to a masto attachment + gtsAttachmentAsMasto, err := suite.tc.AttachmentToMasto(gtsAttachment) + assert.NoError(suite.T(), err) + + // compare it with what we have now + assert.EqualValues(suite.T(), statusReply.MediaAttachments[0], gtsAttachmentAsMasto) + + // the status id of the attachment should now be set to the id of the status we just created + assert.Equal(suite.T(), statusReply.ID, gtsAttachment.StatusID) +} + +func TestStatusCreateTestSuite(t *testing.T) { + suite.Run(t, new(StatusCreateTestSuite)) +} diff --git a/internal/api/client/status/statusdelete.go 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 <http://www.gnu.org/licenses/>. +*/ + +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 @@ -0,0 +1,60 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package 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/api/client/status/statusfave_test.go b/internal/api/client/status/statusfave_test.go @@ -0,0 +1,158 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package status_test + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/status" + "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type StatusFaveTestSuite struct { + StatusStandardTestSuite +} + +func (suite *StatusFaveTestSuite) 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 *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) +} + +// fave a status +func (suite *StatusFaveTestSuite) TestPostFave() { + + t := suite.testTokens["local_account_1"] + oauthToken := oauth.TokenToOauthToken(t) + + targetStatus := suite.testStatuses["admin_account_status_2"] + + // setup + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedToken, oauthToken) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.FavouritePath, ":id", targetStatus.ID, 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: status.IDKey, + Value: targetStatus.ID, + }, + } + + suite.statusModule.StatusFavePOSTHandler(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) + + 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(), model.VisibilityPublic, statusReply.Visibility) + assert.True(suite.T(), statusReply.Favourited) + assert.Equal(suite.T(), 1, statusReply.FavouritesCount) +} + +// try to fave a status that's not faveable +func (suite *StatusFaveTestSuite) TestPostUnfaveable() { + + t := suite.testTokens["local_account_1"] + oauthToken := oauth.TokenToOauthToken(t) + + targetStatus := suite.testStatuses["local_account_2_status_3"] // this one is unlikeable and unreplyable + + // setup + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedToken, oauthToken) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.FavouritePath, ":id", targetStatus.ID, 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: status.IDKey, + Value: targetStatus.ID, + }, + } + + suite.statusModule.StatusFavePOSTHandler(ctx) + + // check response + 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(), `{"error":"bad request"}`, string(b)) +} + +func TestStatusFaveTestSuite(t *testing.T) { + suite.Run(t, new(StatusFaveTestSuite)) +} diff --git a/internal/api/client/status/statusfavedby.go 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 <http://www.gnu.org/licenses/>. +*/ + +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/api/client/status/statusfavedby_test.go b/internal/api/client/status/statusfavedby_test.go @@ -0,0 +1,114 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package status_test + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/status" + "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type StatusFavedByTestSuite struct { + StatusStandardTestSuite +} + +func (suite *StatusFavedByTestSuite) 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 *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) +} + +func (suite *StatusFavedByTestSuite) TestGetFavedBy() { + t := suite.testTokens["local_account_2"] + oauthToken := oauth.TokenToOauthToken(t) + + targetStatus := suite.testStatuses["admin_account_status_1"] // this status is faved by local_account_1 + + // setup + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_2"]) + ctx.Set(oauth.SessionAuthorizedToken, oauthToken) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_2"]) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_2"]) + ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.FavouritedPath, ":id", targetStatus.ID, 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: status.IDKey, + Value: targetStatus.ID, + }, + } + + suite.statusModule.StatusFavedByGETHandler(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) + + accts := []model.Account{} + err = json.Unmarshal(b, &accts) + assert.NoError(suite.T(), err) + + assert.Len(suite.T(), accts, 1) + assert.Equal(suite.T(), "the_mighty_zork", accts[0].Username) +} + +func TestStatusFavedByTestSuite(t *testing.T) { + suite.Run(t, new(StatusFavedByTestSuite)) +} diff --git a/internal/api/client/status/statusget.go 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 <http://www.gnu.org/licenses/>. +*/ + +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/api/client/status/statusget_test.go b/internal/api/client/status/statusget_test.go @@ -0,0 +1,117 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package status_test + +import ( + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/status" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type StatusGetTestSuite struct { + StatusStandardTestSuite +} + +func (suite *StatusGetTestSuite) 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 *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") +} + +func (suite *StatusGetTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) +} + +// Post a new status with some custom visibility settings +func (suite *StatusGetTestSuite) TestPostNewStatus() { + + // t := suite.testTokens["local_account_1"] + // oauthToken := oauth.PGTokenToOauthToken(t) + + // // setup + // recorder := httptest.NewRecorder() + // ctx, _ := gin.CreateTestContext(recorder) + // ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + // ctx.Set(oauth.SessionAuthorizedToken, oauthToken) + // ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + // ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + // ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", basePath), nil) // the endpoint we're hitting + // ctx.Request.Form = url.Values{ + // "status": {"this is a brand new status! #helloworld"}, + // "spoiler_text": {"hello hello"}, + // "sensitive": {"true"}, + // "visibility_advanced": {"mutuals_only"}, + // "likeable": {"false"}, + // "replyable": {"false"}, + // "federated": {"false"}, + // } + // suite.statusModule.statusGETHandler(ctx) + + // // check response + + // // 1. we should have OK from our call to the function + // suite.EqualValues(http.StatusOK, recorder.Code) + + // result := recorder.Result() + // defer result.Body.Close() + // b, err := ioutil.ReadAll(result.Body) + // assert.NoError(suite.T(), err) + + // 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(), mastotypes.VisibilityPrivate, statusReply.Visibility) + // assert.Len(suite.T(), statusReply.Tags, 1) + // assert.Equal(suite.T(), mastotypes.Tag{ + // Name: "helloworld", + // URL: "http://localhost:8080/tags/helloworld", + // }, statusReply.Tags[0]) + + // gtsTag := &gtsmodel.Tag{} + // err = suite.db.GetWhere("name", "helloworld", gtsTag) + // assert.NoError(suite.T(), err) + // assert.Equal(suite.T(), statusReply.Account.ID, gtsTag.FirstSeenFromAccountID) +} + +func TestStatusGetTestSuite(t *testing.T) { + suite.Run(t, new(StatusGetTestSuite)) +} diff --git a/internal/api/client/status/statusunfave.go 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 <http://www.gnu.org/licenses/>. +*/ + +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/api/client/status/statusunfave_test.go b/internal/api/client/status/statusunfave_test.go @@ -0,0 +1,170 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package status_test + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/status" + "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type StatusUnfaveTestSuite struct { + StatusStandardTestSuite +} + +func (suite *StatusUnfaveTestSuite) 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 *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) +} + +// unfave a status +func (suite *StatusUnfaveTestSuite) TestPostUnfave() { + + t := suite.testTokens["local_account_1"] + oauthToken := oauth.TokenToOauthToken(t) + + // this is the status we wanna unfave: in the testrig it's already faved by this account + targetStatus := suite.testStatuses["admin_account_status_1"] + + // setup + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedToken, oauthToken) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.UnfavouritePath, ":id", targetStatus.ID, 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: status.IDKey, + Value: targetStatus.ID, + }, + } + + suite.statusModule.StatusUnfavePOSTHandler(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) + + 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(), model.VisibilityPublic, statusReply.Visibility) + assert.False(suite.T(), statusReply.Favourited) + assert.Equal(suite.T(), 0, statusReply.FavouritesCount) +} + +// try to unfave a status that's already not faved +func (suite *StatusUnfaveTestSuite) TestPostAlreadyNotFaved() { + + t := suite.testTokens["local_account_1"] + oauthToken := oauth.TokenToOauthToken(t) + + // this is the status we wanna unfave: in the testrig it's not faved by this account + targetStatus := suite.testStatuses["admin_account_status_2"] + + // setup + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedToken, oauthToken) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.UnfavouritePath, ":id", targetStatus.ID, 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: status.IDKey, + Value: targetStatus.ID, + }, + } + + suite.statusModule.StatusUnfavePOSTHandler(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) + + 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(), model.VisibilityPublic, statusReply.Visibility) + assert.False(suite.T(), statusReply.Favourited) + assert.Equal(suite.T(), 0, statusReply.FavouritesCount) +} + +func TestStatusUnfaveTestSuite(t *testing.T) { + suite.Run(t, new(StatusUnfaveTestSuite)) +} diff --git a/internal/api/model/account.go b/internal/api/model/account.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 <http://www.gnu.org/licenses/>. +*/ + +package model + +import ( + "mime/multipart" + "net" +) + +// Account represents a mastodon-api Account object, as described here: https://docs.joinmastodon.org/entities/account/ +type Account struct { + // The account id + ID string `json:"id"` + // The username of the account, not including domain. + Username string `json:"username"` + // The Webfinger account URI. Equal to username for local users, or username@domain for remote users. + Acct string `json:"acct"` + // The profile's display name. + DisplayName string `json:"display_name"` + // Whether the account manually approves follow requests. + Locked bool `json:"locked"` + // Whether the account has opted into discovery features such as the profile directory. + Discoverable bool `json:"discoverable,omitempty"` + // A presentational flag. Indicates that the account may perform automated actions, may not be monitored, or identifies as a robot. + Bot bool `json:"bot"` + // When the account was created. (ISO 8601 Datetime) + CreatedAt string `json:"created_at"` + // The profile's bio / description. + Note string `json:"note"` + // The location of the user's profile page. + URL string `json:"url"` + // An image icon that is shown next to statuses and in the profile. + Avatar string `json:"avatar"` + // A static version of the avatar. Equal to avatar if its value is a static image; different if avatar is an animated GIF. + AvatarStatic string `json:"avatar_static"` + // An image banner that is shown above the profile and in profile cards. + Header string `json:"header"` + // A static version of the header. Equal to header if its value is a static image; different if header is an animated GIF. + HeaderStatic string `json:"header_static"` + // The reported followers of this profile. + FollowersCount int `json:"followers_count"` + // The reported follows of this profile. + FollowingCount int `json:"following_count"` + // How many statuses are attached to this account. + StatusesCount int `json:"statuses_count"` + // When the most recent status was posted. (ISO 8601 Datetime) + LastStatusAt string `json:"last_status_at"` + // Custom emoji entities to be used when rendering the profile. If none, an empty array will be returned. + Emojis []Emoji `json:"emojis"` + // Additional metadata attached to a profile as name-value pairs. + Fields []Field `json:"fields"` + // An extra entity returned when an account is suspended. + Suspended bool `json:"suspended,omitempty"` + // When a timed mute will expire, if applicable. (ISO 8601 Datetime) + MuteExpiresAt string `json:"mute_expires_at,omitempty"` + // An extra entity to be used with API methods to verify credentials and update credentials. + Source *Source `json:"source,omitempty"` +} + +// AccountCreateRequest represents the form submitted during a POST request to /api/v1/accounts. +// See https://docs.joinmastodon.org/methods/accounts/ +type AccountCreateRequest struct { + // Text that will be reviewed by moderators if registrations require manual approval. + Reason string `form:"reason"` + // The desired username for the account + Username string `form:"username" binding:"required"` + // The email address to be used for login + Email string `form:"email" binding:"required"` + // The password to be used for login + Password string `form:"password" binding:"required"` + // Whether the user agrees to the local rules, terms, and policies. + // These should be presented to the user in order to allow them to consent before setting this parameter to TRUE. + Agreement bool `form:"agreement" binding:"required"` + // 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. +// See https://docs.joinmastodon.org/methods/accounts/ +type UpdateCredentialsRequest struct { + // Whether the account should be shown in the profile directory. + Discoverable *bool `form:"discoverable"` + // Whether the account has a bot flag. + Bot *bool `form:"bot"` + // The display name to use for the profile. + DisplayName *string `form:"display_name"` + // The account bio. + Note *string `form:"note"` + // Avatar image encoded using multipart/form-data + Avatar *multipart.FileHeader `form:"avatar"` + // Header image encoded using multipart/form-data + Header *multipart.FileHeader `form:"header"` + // Whether manual approval of follow requests is required. + Locked *bool `form:"locked"` + // New Source values for this account + Source *UpdateSource `form:"source"` + // Profile metadata name and value + FieldsAttributes *[]UpdateField `form:"fields_attributes"` +} + +// UpdateSource is to be used specifically in an UpdateCredentialsRequest. +type UpdateSource struct { + // Default post privacy for authored statuses. + Privacy *string `form:"privacy"` + // Whether to mark authored statuses as sensitive by default. + Sensitive *bool `form:"sensitive"` + // Default language to use for authored statuses. (ISO 6391) + Language *string `form:"language"` +} + +// UpdateField is to be used specifically in an UpdateCredentialsRequest. +// By default, max 4 fields and 255 characters per property/value. +type UpdateField struct { + // Name of the field + Name *string `form:"name"` + // Value of the field + Value *string `form:"value"` +} diff --git a/internal/api/model/activity.go b/internal/api/model/activity.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 <http://www.gnu.org/licenses/>. +*/ + +package model + +// Activity represents the mastodon-api Activity type. See here: https://docs.joinmastodon.org/entities/activity/ +type Activity struct { + // Midnight at the first day of the week. (UNIX Timestamp as string) + Week string `json:"week"` + // Statuses created since the week began. Integer cast to string. + Statuses string `json:"statuses"` + // User logins since the week began. Integer cast as string. + Logins string `json:"logins"` + // User registrations since the week began. Integer cast as string. + Registrations string `json:"registrations"` +} diff --git a/internal/api/model/admin.go b/internal/api/model/admin.go @@ -0,0 +1,81 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package model + +// AdminAccountInfo represents the *admin* view of an account's details. See here: https://docs.joinmastodon.org/entities/admin-account/ +type AdminAccountInfo struct { + // The ID of the account in the database. + ID string `json:"id"` + // The username of the account. + Username string `json:"username"` + // The domain of the account. + Domain string `json:"domain"` + // When the account was first discovered. (ISO 8601 Datetime) + CreatedAt string `json:"created_at"` + // The email address associated with the account. + Email string `json:"email"` + // The IP address last used to login to this account. + IP string `json:"ip"` + // The locale of the account. (ISO 639 Part 1 two-letter language code) + Locale string `json:"locale"` + // Invite request text + InviteRequest string `json:"invite_request"` + // The current role of the account. + Role string `json:"role"` + // Whether the account has confirmed their email address. + Confirmed bool `json:"confirmed"` + // Whether the account is currently approved. + Approved bool `json:"approved"` + // Whether the account is currently disabled. + Disabled bool `json:"disabled"` + // Whether the account is currently silenced + Silenced bool `json:"silenced"` + // Whether the account is currently suspended. + Suspended bool `json:"suspended"` + // User-level information about the account. + Account *Account `json:"account"` + // The ID of the application that created this account. + CreatedByApplicationID string `json:"created_by_application_id,omitempty"` + // The ID of the account that invited this user + InvitedByAccountID string `json:"invited_by_account_id"` +} + +// AdminReportInfo represents the *admin* view of a report. See here: https://docs.joinmastodon.org/entities/admin-report/ +type AdminReportInfo struct { + // The ID of the report in the database. + ID string `json:"id"` + // The action taken to resolve this report. + ActionTaken string `json:"action_taken"` + // An optional reason for reporting. + Comment string `json:"comment"` + // The time the report was filed. (ISO 8601 Datetime) + CreatedAt string `json:"created_at"` + // The time of last action on this report. (ISO 8601 Datetime) + UpdatedAt string `json:"updated_at"` + // The account which filed the report. + Account *Account `json:"account"` + // The account being reported. + TargetAccount *Account `json:"target_account"` + // The account of the moderator assigned to this report. + AssignedAccount *Account `json:"assigned_account"` + // The action taken by the moderator who handled the report. + ActionTakenByAccount string `json:"action_taken_by_account"` + // Statuses attached to the report, for context. + Statuses []Status `json:"statuses"` +} diff --git a/internal/api/model/announcement.go b/internal/api/model/announcement.go @@ -0,0 +1,37 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package model + +// Announcement represents an admin/moderator announcement for local users. See here: https://docs.joinmastodon.org/entities/announcement/ +type Announcement struct { + ID string `json:"id"` + Content string `json:"content"` + StartsAt string `json:"starts_at"` + EndsAt string `json:"ends_at"` + AllDay bool `json:"all_day"` + PublishedAt string `json:"published_at"` + UpdatedAt string `json:"updated_at"` + Published bool `json:"published"` + Read bool `json:"read"` + Mentions []Mention `json:"mentions"` + Statuses []Status `json:"statuses"` + Tags []Tag `json:"tags"` + Emojis []Emoji `json:"emoji"` + Reactions []AnnouncementReaction `json:"reactions"` +} diff --git a/internal/api/model/announcementreaction.go b/internal/api/model/announcementreaction.go @@ -0,0 +1,33 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package model + +// AnnouncementReaction represents a user reaction to admin/moderator announcement. See here: https://docs.joinmastodon.org/entities/announcementreaction/ +type AnnouncementReaction struct { + // The emoji used for the reaction. Either a unicode emoji, or a custom emoji's shortcode. + Name string `json:"name"` + // The total number of users who have added this reaction. + Count int `json:"count"` + // Whether the authorized user has added this reaction to the announcement. + Me bool `json:"me"` + // A link to the custom emoji. + URL string `json:"url,omitempty"` + // A link to a non-animated version of the custom emoji. + StaticURL string `json:"static_url,omitempty"` +} diff --git a/internal/api/model/application.go b/internal/api/model/application.go @@ -0,0 +1,55 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package model + +// 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. +// See https://docs.joinmastodon.org/methods/apps/ +type Application struct { + // The application ID in the db + ID string `json:"id,omitempty"` + // The name of your application. + Name string `json:"name"` + // The website associated with your application (url) + Website string `json:"website,omitempty"` + // Where the user should be redirected after authorization. + RedirectURI string `json:"redirect_uri,omitempty"` + // ClientID to use when obtaining an oauth token for this application (ie., in client_id parameter of https://docs.joinmastodon.org/methods/apps/) + ClientID string `json:"client_id,omitempty"` + // Client secret to use when obtaining an auth token for this application (ie., in client_secret parameter of https://docs.joinmastodon.org/methods/apps/) + ClientSecret string `json:"client_secret,omitempty"` + // Used for Push Streaming API. Returned with POST /api/v1/apps. Equivalent to https://docs.joinmastodon.org/entities/pushsubscription/#server_key + VapidKey string `json:"vapid_key,omitempty"` +} + +// 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 ApplicationCreateRequest struct { + // A name for your application + ClientName string `form:"client_name" binding:"required"` + // Where the user should be redirected after authorization. + // To display the authorization code to the user instead of redirecting + // to a web page, use urn:ietf:wg:oauth:2.0:oob in this parameter. + RedirectURIs string `form:"redirect_uris" binding:"required"` + // Space separated list of scopes. If none is provided, defaults to read. + Scopes string `form:"scopes"` + // A URL to the homepage of your app + Website string `form:"website"` +} diff --git a/internal/api/model/attachment.go b/internal/api/model/attachment.go @@ -0,0 +1,98 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package model + +import "mime/multipart" + +// AttachmentRequest represents the form data parameters submitted by a client during a media upload request. +// See: https://docs.joinmastodon.org/methods/statuses/media/ +type AttachmentRequest struct { + File *multipart.FileHeader `form:"file"` + Thumbnail *multipart.FileHeader `form:"thumbnail"` + Description string `form:"description"` + Focus string `form:"focus"` +} + +// Attachment represents the object returned to a client after a successful media upload request. +// See: https://docs.joinmastodon.org/methods/statuses/media/ +type Attachment struct { + // The ID of the attachment in the database. + ID string `json:"id"` + // The type of the attachment. + // unknown = unsupported or unrecognized file type. + // image = Static image. + // gifv = Looping, soundless animation. + // video = Video clip. + // audio = Audio track. + Type string `json:"type"` + // The location of the original full-size attachment. + URL string `json:"url"` + // The location of a scaled-down preview of the attachment. + PreviewURL string `json:"preview_url"` + // The location of the full-size original attachment on the remote server. + RemoteURL string `json:"remote_url,omitempty"` + // The location of a scaled-down preview of the attachment on the remote server. + PreviewRemoteURL string `json:"preview_remote_url,omitempty"` + // A shorter URL for the attachment. + TextURL string `json:"text_url,omitempty"` + // Metadata returned by Paperclip. + // May contain subtrees small and original, as well as various other top-level properties. + // More importantly, there may be another top-level focus Hash object as of 2.3.0, with coordinates can be used for smart thumbnail cropping. + // See https://docs.joinmastodon.org/methods/statuses/media/#focal-points points for more. + Meta MediaMeta `json:"meta,omitempty"` + // Alternate text that describes what is in the media attachment, to be used for the visually impaired or when media attachments do not load. + Description string `json:"description,omitempty"` + // A hash computed by the BlurHash algorithm, for generating colorful preview thumbnails when media has not been downloaded yet. + // See https://github.com/woltapp/blurhash + Blurhash string `json:"blurhash,omitempty"` +} + +// MediaMeta describes the returned media +type MediaMeta struct { + Length string `json:"length,omitempty"` + Duration float32 `json:"duration,omitempty"` + FPS uint16 `json:"fps,omitempty"` + Size string `json:"size,omitempty"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + Aspect float32 `json:"aspect,omitempty"` + AudioEncode string `json:"audio_encode,omitempty"` + AudioBitrate string `json:"audio_bitrate,omitempty"` + AudioChannels string `json:"audio_channels,omitempty"` + Original MediaDimensions `json:"original"` + Small MediaDimensions `json:"small,omitempty"` + Focus MediaFocus `json:"focus,omitempty"` +} + +// MediaFocus describes the focal point of a piece of media. It should be returned to the caller as part of MediaMeta. +type MediaFocus struct { + X float32 `json:"x"` // should be between -1 and 1 + Y float32 `json:"y"` // should be between -1 and 1 +} + +// MediaDimensions describes the physical properties of a piece of media. It should be returned to the caller as part of MediaMeta. +type MediaDimensions struct { + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + FrameRate string `json:"frame_rate,omitempty"` + Duration float32 `json:"duration,omitempty"` + Bitrate int `json:"bitrate,omitempty"` + Size string `json:"size,omitempty"` + Aspect float32 `json:"aspect,omitempty"` +} diff --git a/internal/api/model/card.go b/internal/api/model/card.go @@ -0,0 +1,61 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package model + +// 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 { + // REQUIRED + + // Location of linked resource. + URL string `json:"url"` + // Title of linked resource. + Title string `json:"title"` + // Description of preview. + Description string `json:"description"` + // The type of the preview card. + // String (Enumerable, oneOf) + // link = Link OEmbed + // photo = Photo OEmbed + // video = Video OEmbed + // rich = iframe OEmbed. Not currently accepted, so won't show up in practice. + Type string `json:"type"` + + // OPTIONAL + + // The author of the original resource. + AuthorName string `json:"author_name"` + // A link to the author of the original resource. + AuthorURL string `json:"author_url"` + // The provider of the original resource. + ProviderName string `json:"provider_name"` + // A link to the provider of the original resource. + ProviderURL string `json:"provider_url"` + // HTML to be used for generating the preview card. + HTML string `json:"html"` + // Width of preview, in pixels. + Width int `json:"width"` + // Height of preview, in pixels. + Height int `json:"height"` + // Preview thumbnail. + Image string `json:"image"` + // Used for photo embeds, instead of custom html. + EmbedURL string `json:"embed_url"` + // A hash computed by the BlurHash algorithm, for generating colorful preview thumbnails when media has not been downloaded yet. + Blurhash string `json:"blurhash"` +} diff --git a/internal/api/model/content.go 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 <http://www.gnu.org/licenses/>. +*/ + +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/api/model/context.go b/internal/api/model/context.go @@ -0,0 +1,27 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +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 { + // Parents in the thread. + Ancestors []Status `json:"ancestors"` + // Children in the thread. + Descendants []Status `json:"descendants"` +} diff --git a/internal/api/model/conversation.go b/internal/api/model/conversation.go @@ -0,0 +1,36 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package model + +// Conversation represents a conversation with "direct message" visibility. See https://docs.joinmastodon.org/entities/conversation/ +type Conversation struct { + // REQUIRED + + // Local database ID of the conversation. + ID string `json:"id"` + // Participants in the conversation. + Accounts []Account `json:"accounts"` + // Is the conversation currently marked as unread? + Unread bool `json:"unread"` + + // OPTIONAL + + // The last status in the conversation, to be used for optional display. + LastStatus *Status `json:"last_status"` +} diff --git a/internal/api/model/emoji.go b/internal/api/model/emoji.go @@ -0,0 +1,48 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package model + +import "mime/multipart" + +// Emoji represents a custom emoji. See https://docs.joinmastodon.org/entities/emoji/ +type Emoji struct { + // REQUIRED + + // The name of the custom emoji. + Shortcode string `json:"shortcode"` + // A link to the custom emoji. + URL string `json:"url"` + // A link to a static copy of the custom emoji. + StaticURL string `json:"static_url"` + // Whether this Emoji should be visible in the picker or unlisted. + VisibleInPicker bool `json:"visible_in_picker"` + + // OPTIONAL + + // Used for sorting custom emoji in the picker. + Category string `json:"category,omitempty"` +} + +// EmojiCreateRequest represents a request to create a custom emoji made through the admin API. +type EmojiCreateRequest struct { + // Desired shortcode for the emoji, without surrounding colons. This must be unique for the domain. + Shortcode string `form:"shortcode" validation:"required"` + // Image file to use for the emoji. Must be png or gif and no larger than 50kb. + Image *multipart.FileHeader `form:"image" validation:"required"` +} diff --git a/internal/api/model/error.go b/internal/api/model/error.go @@ -0,0 +1,32 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package model + +// Error represents an error message returned from the API. See https://docs.joinmastodon.org/entities/error/ +type Error struct { + // REQUIRED + + // The error message. + Error string `json:"error"` + + // OPTIONAL + + // A longer description of the error, mainly provided with the OAuth API. + ErrorDescription string `json:"error_description"` +} diff --git a/internal/api/model/featuredtag.go b/internal/api/model/featuredtag.go @@ -0,0 +1,33 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package model + +// FeaturedTag represents a hashtag that is featured on a profile. See https://docs.joinmastodon.org/entities/featuredtag/ +type FeaturedTag struct { + // The internal ID of the featured tag in the database. + ID string `json:"id"` + // The name of the hashtag being featured. + Name string `json:"name"` + // A link to all statuses by a user that contain this hashtag. + URL string `json:"url"` + // The number of authored statuses containing this hashtag. + StatusesCount int `json:"statuses_count"` + // The timestamp of the last authored status containing this hashtag. (ISO 8601 Datetime) + LastStatusAt string `json:"last_status_at"` +} diff --git a/internal/api/model/field.go b/internal/api/model/field.go @@ -0,0 +1,33 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package model + +// Field represents a profile field as a name-value pair with optional verification. See https://docs.joinmastodon.org/entities/field/ +type Field struct { + // REQUIRED + + // The key of a given field's key-value pair. + Name string `json:"name"` + // The value associated with the name key. + Value string `json:"value"` + + // OPTIONAL + // Timestamp of when the server verified a URL value for a rel="me” link. String (ISO 8601 Datetime) if value is a verified URL + VerifiedAt string `json:"verified_at,omitempty"` +} diff --git a/internal/api/model/filter.go b/internal/api/model/filter.go @@ -0,0 +1,46 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package 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: +// Define ‘word constituent character’ for your app. In the official implementation, it’s [A-Za-z0-9_] in JavaScript, and [[:word:]] in Ruby. +// Ruby uses the POSIX character class (Letter | Mark | Decimal_Number | Connector_Punctuation). +// If the phrase starts with a word character, and if the previous character before matched range is a word character, its matched range should be treated to not match. +// If the phrase ends with a word character, and if the next character after matched range is a word character, its matched range should be treated to not match. +// Please check app/javascript/mastodon/selectors/index.js and app/lib/feed_manager.rb in the Mastodon source code for more details. +type Filter struct { + // The ID of the filter in the database. + ID string `json:"id"` + // The text to be filtered. + Phrase string `json:"text"` + // The contexts in which the filter should be applied. + // Array of String (Enumerable anyOf) + // home = home timeline and lists + // notifications = notifications timeline + // public = public timelines + // thread = expanded thread of a detailed status + Context []string `json:"context"` + // Should the filter consider word boundaries? + WholeWord bool `json:"whole_word"` + // When the filter should no longer be applied (ISO 8601 Datetime), or null if the filter does not expire + ExpiresAt string `json:"expires_at,omitempty"` + // Should matching entities in home and notifications be dropped by the server? + Irreversible bool `json:"irreversible"` +} diff --git a/internal/api/model/history.go b/internal/api/model/history.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 <http://www.gnu.org/licenses/>. +*/ + +package model + +// History represents daily usage history of a hashtag. See https://docs.joinmastodon.org/entities/history/ +type History struct { + // UNIX timestamp on midnight of the given day (string cast from integer). + Day string `json:"day"` + // The counted usage of the tag within that day (string cast from integer). + Uses string `json:"uses"` + // The total of accounts using the tag within that day (string cast from integer). + Accounts string `json:"accounts"` +} diff --git a/internal/api/model/identityproof.go b/internal/api/model/identityproof.go @@ -0,0 +1,33 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package model + +// IdentityProof represents a proof from an external identity provider. See https://docs.joinmastodon.org/entities/identityproof/ +type IdentityProof struct { + // The name of the identity provider. + Provider string `json:"provider"` + // The account owner's username on the identity provider's service. + ProviderUsername string `json:"provider_username"` + // The account owner's profile URL on the identity provider. + ProfileURL string `json:"profile_url"` + // A link to a statement of identity proof, hosted by the identity provider. + ProofURL string `json:"proof_url"` + // When the identity proof was last updated. + UpdatedAt string `json:"updated_at"` +} diff --git a/internal/api/model/instance.go b/internal/api/model/instance.go @@ -0,0 +1,72 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package model + +// Instance represents the software instance of Mastodon running on this domain. See https://docs.joinmastodon.org/entities/instance/ +type Instance struct { + // REQUIRED + + // The domain name of the instance. + URI string `json:"uri"` + // The title of the website. + Title string `json:"title"` + // Admin-defined description of the Mastodon site. + Description string `json:"description"` + // A shorter description defined by the admin. + ShortDescription string `json:"short_description"` + // An email that may be contacted for any inquiries. + Email string `json:"email"` + // The version of Mastodon installed on the instance. + Version string `json:"version"` + // Primary langauges of the website and its staff. + Languages []string `json:"languages"` + // Whether registrations are enabled. + Registrations bool `json:"registrations"` + // Whether registrations require moderator approval. + ApprovalRequired bool `json:"approval_required"` + // Whether invites are enabled. + InvitesEnabled bool `json:"invites_enabled"` + // URLs of interest for clients apps. + URLS *InstanceURLs `json:"urls"` + // Statistics about how much information the instance contains. + Stats *InstanceStats `json:"stats"` + + // OPTIONAL + + // Banner image for the website. + Thumbnail string `json:"thumbnail,omitempty"` + // A user that can be contacted, as an alternative to email. + ContactAccount *Account `json:"contact_account,omitempty"` +} + +// InstanceURLs represents URLs necessary for successfully connecting to the instance as a user. See https://docs.joinmastodon.org/entities/instance/ +type InstanceURLs struct { + // Websockets address for push streaming. + StreamingAPI string `json:"streaming_api"` +} + +// InstanceStats represents some public-facing stats about the instance. See https://docs.joinmastodon.org/entities/instance/ +type InstanceStats struct { + // Users registered on this instance. + UserCount int `json:"user_count"` + // Statuses authored by users on instance. + StatusCount int `json:"status_count"` + // Domains federated with this instance. + DomainCount int `json:"domain_count"` +} diff --git a/internal/api/model/list.go b/internal/api/model/list.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 <http://www.gnu.org/licenses/>. +*/ + +package model + +// List represents a list of some users that the authenticated user follows. See https://docs.joinmastodon.org/entities/list/ +type List struct { + // The internal database ID of the list. + ID string `json:"id"` + // The user-defined title of the list. + Title string `json:"title"` + // followed = Show replies to any followed user + // list = Show replies to members of the list + // none = Show replies to no one + RepliesPolicy string `json:"replies_policy"` +} diff --git a/internal/api/model/marker.go b/internal/api/model/marker.go @@ -0,0 +1,37 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package model + +// Marker represents the last read position within a user's timelines. See https://docs.joinmastodon.org/entities/marker/ +type Marker struct { + // Information about the user's position in the home timeline. + Home *TimelineMarker `json:"home"` + // Information about the user's position in their notifications. + Notifications *TimelineMarker `json:"notifications"` +} + +// TimelineMarker contains information about a user's progress through a specific timeline. See https://docs.joinmastodon.org/entities/marker/ +type TimelineMarker struct { + // The ID of the most recently viewed entity. + LastReadID string `json:"last_read_id"` + // The timestamp of when the marker was set (ISO 8601 Datetime) + UpdatedAt string `json:"updated_at"` + // Used for locking to prevent write conflicts. + Version string `json:"version"` +} diff --git a/internal/api/model/mention.go b/internal/api/model/mention.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 <http://www.gnu.org/licenses/>. +*/ + +package model + +// Mention represents the mastodon-api mention type, as documented here: https://docs.joinmastodon.org/entities/mention/ +type Mention struct { + // The account id of the mentioned user. + ID string `json:"id"` + // The username of the mentioned user. + Username string `json:"username"` + // The location of the mentioned user's profile. + URL string `json:"url"` + // The webfinger acct: URI of the mentioned user. Equivalent to username for local users, or username@domain for remote users. + Acct string `json:"acct"` +} diff --git a/internal/api/model/notification.go b/internal/api/model/notification.go @@ -0,0 +1,45 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package model + +// Notification represents a notification of an event relevant to the user. See https://docs.joinmastodon.org/entities/notification/ +type Notification struct { + // REQUIRED + + // The id of the notification in the database. + ID string `json:"id"` + // The type of event that resulted in the notification. + // follow = Someone followed you + // follow_request = Someone requested to follow you + // mention = Someone mentioned you in their status + // reblog = Someone boosted one of your statuses + // favourite = Someone favourited one of your statuses + // poll = A poll you have voted in or created has ended + // status = Someone you enabled notifications for has posted a status + Type string `json:"type"` + // The timestamp of the notification (ISO 8601 Datetime) + CreatedAt string `json:"created_at"` + // The account that performed the action that generated the notification. + Account *Account `json:"account"` + + // OPTIONAL + + // Status that was the object of the notification, e.g. in mentions, reblogs, favourites, or polls. + Status *Status `json:"status"` +} diff --git a/internal/api/model/oauth.go b/internal/api/model/oauth.go @@ -0,0 +1,37 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package model + +// OAuthAuthorize represents a request sent to https://example.org/oauth/authorize +// See here: https://docs.joinmastodon.org/methods/apps/oauth/ +type OAuthAuthorize struct { + // Forces the user to re-login, which is necessary for authorizing with multiple accounts from the same instance. + ForceLogin string `form:"force_login,omitempty"` + // Should be set equal to `code`. + ResponseType string `form:"response_type"` + // Client ID, obtained during app registration. + ClientID string `form:"client_id"` + // Set a URI to redirect the user to. + // If this parameter is set to urn:ietf:wg:oauth:2.0:oob then the authorization code will be shown instead. + // Must match one of the redirect URIs declared during app registration. + RedirectURI string `form:"redirect_uri"` + // List of requested OAuth scopes, separated by spaces (or by pluses, if using query parameters). + // Must be a subset of scopes declared during app registration. If not provided, defaults to read. + Scope string `form:"scope,omitempty"` +} diff --git a/internal/api/model/poll.go b/internal/api/model/poll.go @@ -0,0 +1,64 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package model + +// Poll represents the mastodon-api poll type, as described here: https://docs.joinmastodon.org/entities/poll/ +type Poll struct { + // The ID of the poll in the database. + ID string `json:"id"` + // When the poll ends. (ISO 8601 Datetime), or null if the poll does not end + ExpiresAt string `json:"expires_at"` + // Is the poll currently expired? + Expired bool `json:"expired"` + // Does the poll allow multiple-choice answers? + Multiple bool `json:"multiple"` + // How many votes have been received. + VotesCount int `json:"votes_count"` + // How many unique accounts have voted on a multiple-choice poll. Null if multiple is false. + VotersCount int `json:"voters_count,omitempty"` + // When called with a user token, has the authorized user voted? + Voted bool `json:"voted,omitempty"` + // When called with a user token, which options has the authorized user chosen? Contains an array of index values for options. + OwnVotes []int `json:"own_votes,omitempty"` + // Possible answers for the poll. + Options []PollOptions `json:"options"` + // Custom emoji to be used for rendering poll options. + Emojis []Emoji `json:"emojis"` +} + +// PollOptions represents the current vote counts for different poll options +type PollOptions struct { + // The text value of the poll option. String. + Title string `json:"title"` + // The number of received votes for this option. Number, or null if results are not published yet. + VotesCount int `json:"votes_count,omitempty"` +} + +// PollRequest represents a mastodon-api poll attached to a status POST request, as defined here: https://docs.joinmastodon.org/methods/statuses/ +// It should be used at the path https://example.org/api/v1/statuses +type PollRequest struct { + // Array of possible answers. If provided, media_ids cannot be used, and poll[expires_in] must be provided. + Options []string `form:"options"` + // Duration the poll should be open, in seconds. If provided, media_ids cannot be used, and poll[options] must be provided. + ExpiresIn int `form:"expires_in"` + // Allow multiple choices? + Multiple bool `form:"multiple"` + // Hide vote counts until the poll ends? + HideTotals bool `form:"hide_totals"` +} diff --git a/internal/api/model/preferences.go b/internal/api/model/preferences.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 <http://www.gnu.org/licenses/>. +*/ + +package model + +// Preferences represents a user's preferences. See https://docs.joinmastodon.org/entities/preferences/ +type Preferences struct { + // Default visibility for new posts. + // public = Public post + // unlisted = Unlisted post + // private = Followers-only post + // direct = Direct post + PostingDefaultVisibility string `json:"posting:default:visibility"` + // Default sensitivity flag for new posts. + PostingDefaultSensitive bool `json:"posting:default:sensitive"` + // Default language for new posts. (ISO 639-1 language two-letter code), or null + PostingDefaultLanguage string `json:"posting:default:language,omitempty"` + // Whether media attachments should be automatically displayed or blurred/hidden. + // default = Hide media marked as sensitive + // show_all = Always show all media by default, regardless of sensitivity + // hide_all = Always hide all media by default, regardless of sensitivity + ReadingExpandMedia string `json:"reading:expand:media"` + // Whether CWs should be expanded by default. + ReadingExpandSpoilers bool `json:"reading:expand:spoilers"` +} diff --git a/internal/api/model/pushsubscription.go b/internal/api/model/pushsubscription.go @@ -0,0 +1,45 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package model + +// PushSubscription represents a subscription to the push streaming server. See https://docs.joinmastodon.org/entities/pushsubscription/ +type PushSubscription struct { + // The id of the push subscription in the database. + ID string `json:"id"` + // Where push alerts will be sent to. + Endpoint string `json:"endpoint"` + // The streaming server's VAPID key. + ServerKey string `json:"server_key"` + // Which alerts should be delivered to the endpoint. + Alerts *PushSubscriptionAlerts `json:"alerts"` +} + +// PushSubscriptionAlerts represents the specific alerts that this push subscription will give. +type PushSubscriptionAlerts struct { + // Receive a push notification when someone has followed you? + Follow bool `json:"follow"` + // Receive a push notification when a status you created has been favourited by someone else? + Favourite bool `json:"favourite"` + // Receive a push notification when someone else has mentioned you in a status? + Mention bool `json:"mention"` + // Receive a push notification when a status you created has been boosted by someone else? + Reblog bool `json:"reblog"` + // Receive a push notification when a poll you voted in or created has ended? + Poll bool `json:"poll"` +} diff --git a/internal/api/model/relationship.go b/internal/api/model/relationship.go @@ -0,0 +1,49 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package model + +// Relationship represents a relationship between accounts. See https://docs.joinmastodon.org/entities/relationship/ +type Relationship struct { + // The account id. + ID string `json:"id"` + // Are you following this user? + Following bool `json:"following"` + // Are you receiving this user's boosts in your home timeline? + ShowingReblogs bool `json:"showing_reblogs"` + // Have you enabled notifications for this user? + Notifying bool `json:"notifying"` + // Are you followed by this user? + FollowedBy bool `json:"followed_by"` + // Are you blocking this user? + Blocking bool `json:"blocking"` + // Is this user blocking you? + BlockedBy bool `json:"blocked_by"` + // Are you muting this user? + Muting bool `json:"muting"` + // Are you muting notifications from this user? + MutingNotifications bool `json:"muting_notifications"` + // Do you have a pending follow request for this user? + Requested bool `json:"requested"` + // Are you blocking this user's domain? + DomainBlocking bool `json:"domain_blocking"` + // Are you featuring this user on your profile? + Endorsed bool `json:"endorsed"` + // Your note on this account. + Note string `json:"note"` +} diff --git a/internal/api/model/results.go b/internal/api/model/results.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 <http://www.gnu.org/licenses/>. +*/ + +package model + +// Results represents the results of a search. See https://docs.joinmastodon.org/entities/results/ +type Results struct { + // Accounts which match the given query + Accounts []Account `json:"accounts"` + // Statuses which match the given query + Statuses []Status `json:"statuses"` + // Hashtags which match the given query + Hashtags []Tag `json:"hashtags"` +} diff --git a/internal/api/model/scheduledstatus.go b/internal/api/model/scheduledstatus.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 <http://www.gnu.org/licenses/>. +*/ + +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 { + ID string `json:"id"` + ScheduledAt string `json:"scheduled_at"` + Params *StatusParams `json:"params"` + MediaAttachments []Attachment `json:"media_attachments"` +} + +// StatusParams represents parameters for a scheduled status. See https://docs.joinmastodon.org/entities/scheduledstatus/ +type StatusParams struct { + Text string `json:"text"` + InReplyToID string `json:"in_reply_to_id,omitempty"` + MediaIDs []string `json:"media_ids,omitempty"` + Sensitive bool `json:"sensitive,omitempty"` + SpoilerText string `json:"spoiler_text,omitempty"` + Visibility string `json:"visibility"` + ScheduledAt string `json:"scheduled_at,omitempty"` + ApplicationID string `json:"application_id"` +} diff --git a/internal/api/model/source.go b/internal/api/model/source.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 <http://www.gnu.org/licenses/>. +*/ + +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. +// See https://docs.joinmastodon.org/entities/source/ +type Source struct { + // The default post privacy to be used for new statuses. + // public = Public post + // unlisted = Unlisted post + // private = Followers-only post + // direct = Direct post + Privacy Visibility `json:"privacy,omitempty"` + // Whether new statuses should be marked sensitive by default. + Sensitive bool `json:"sensitive,omitempty"` + // The default posting language for new statuses. + Language string `json:"language,omitempty"` + // Profile bio. + Note string `json:"note"` + // Metadata about the account. + Fields []Field `json:"fields"` + // The number of pending follow requests. + FollowRequestsCount int `json:"follow_requests_count,omitempty"` +} diff --git a/internal/api/model/status.go b/internal/api/model/status.go @@ -0,0 +1,138 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package model + +// Status represents a mastodon-api Status type, as defined here: https://docs.joinmastodon.org/entities/status/ +type Status struct { + // ID of the status in the database. + ID string `json:"id"` + // The date when this status was created (ISO 8601 Datetime) + CreatedAt string `json:"created_at"` + // ID of the status being replied. + InReplyToID string `json:"in_reply_to_id,omitempty"` + // ID of the account being replied to. + InReplyToAccountID string `json:"in_reply_to_account_id,omitempty"` + // Is this status marked as sensitive content? + Sensitive bool `json:"sensitive"` + // Subject or summary line, below which status content is collapsed until expanded. + SpoilerText string `json:"spoiler_text,omitempty"` + // Visibility of this status. + Visibility Visibility `json:"visibility"` + // Primary language of this status. (ISO 639 Part 1 two-letter language code) + Language string `json:"language"` + // URI of the status used for federation. + URI string `json:"uri"` + // A link to the status's HTML representation. + URL string `json:"url"` + // How many replies this status has received. + RepliesCount int `json:"replies_count"` + // How many boosts this status has received. + ReblogsCount int `json:"reblogs_count"` + // How many favourites this status has received. + FavouritesCount int `json:"favourites_count"` + // Have you favourited this status? + Favourited bool `json:"favourited"` + // Have you boosted this status? + Reblogged bool `json:"reblogged"` + // Have you muted notifications for this status's conversation? + Muted bool `json:"muted"` + // Have you bookmarked this status? + Bookmarked bool `json:"bookmarked"` + // Have you pinned this status? Only appears if the status is pinnable. + Pinned bool `json:"pinned"` + // HTML-encoded status content. + Content string `json:"content"` + // The status being reblogged. + Reblog *Status `json:"reblog,omitempty"` + // The application used to post this status. + Application *Application `json:"application"` + // The account that authored this status. + Account *Account `json:"account"` + // Media that is attached to this status. + MediaAttachments []Attachment `json:"media_attachments"` + // Mentions of users within the status content. + Mentions []Mention `json:"mentions"` + // Hashtags used within the status content. + Tags []Tag `json:"tags"` + // Custom emoji to be used when rendering status content. + Emojis []Emoji `json:"emojis"` + // Preview card for links included within status content. + Card *Card `json:"card"` + // The poll attached to the status. + Poll *Poll `json:"poll"` + // Plain-text source of a status. Returned instead of content when status is deleted, + // so the user may redraft from the source text without the client having to reverse-engineer + // the original text from the HTML content. + Text string `json:"text"` +} + +// StatusCreateRequest represents a mastodon-api status POST request, as defined here: https://docs.joinmastodon.org/methods/statuses/ +// It should be used at the path https://mastodon.example/api/v1/statuses +type StatusCreateRequest struct { + // Text content of the status. If media_ids is provided, this becomes optional. Attaching a poll is optional while status is provided. + Status string `form:"status"` + // Array of Attachment ids to be attached as media. If provided, status becomes optional, and poll cannot be used. + MediaIDs []string `form:"media_ids"` + // Poll to include with this status. + Poll *PollRequest `form:"poll"` + // ID of the status being replied to, if status is a reply + InReplyToID string `form:"in_reply_to_id"` + // Mark status and attached media as sensitive? + Sensitive bool `form:"sensitive"` + // Text to be shown as a warning or subject before the actual content. Statuses are generally collapsed behind this field. + SpoilerText string `form:"spoiler_text"` + // Visibility of the posted status. Enumerable oneOf public, unlisted, private, direct. + Visibility Visibility `form:"visibility"` + // ISO 8601 Datetime at which to schedule a status. Providing this paramter will cause ScheduledStatus to be returned instead of Status. Must be at least 5 minutes in the future. + ScheduledAt string `form:"scheduled_at"` + // ISO 639 language code for this status. + Language string `form:"language"` +} + +// Visibility denotes the visibility of this status to other users +type Visibility string + +const ( + // VisibilityPublic means visible to everyone + VisibilityPublic Visibility = "public" + // VisibilityUnlisted means visible to everyone but only on home timelines or in lists + VisibilityUnlisted Visibility = "unlisted" + // VisibilityPrivate means visible to followers only + VisibilityPrivate Visibility = "private" + // 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/api/model/tag.go b/internal/api/model/tag.go @@ -0,0 +1,27 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package model + +// Tag represents a hashtag used within the content of a status. See https://docs.joinmastodon.org/entities/tag/ +type Tag struct { + // The value of the hashtag after the # sign. + Name string `json:"name"` + // A link to the hashtag on the instance. + URL string `json:"url"` +} diff --git a/internal/api/model/token.go b/internal/api/model/token.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 <http://www.gnu.org/licenses/>. +*/ + +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 { + // An OAuth token to be used for authorization. + AccessToken string `json:"access_token"` + // The OAuth token type. Mastodon uses Bearer tokens. + TokenType string `json:"token_type"` + // The OAuth scopes granted by this token, space-separated. + Scope string `json:"scope"` + // When the token was generated. (UNIX timestamp seconds) + CreatedAt int64 `json:"created_at"` +} diff --git a/internal/api/s2s/user/user.go 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 <http://www.gnu.org/licenses/>. +*/ + +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 @@ -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 @@ -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 <http://www.gnu.org/licenses/>. +*/ + +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 @@ -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 diff --git a/internal/api/security/security.go b/internal/api/security/security.go @@ -0,0 +1,46 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package security + +import ( + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/router" +) + +// Module implements the ClientAPIModule interface for security middleware +type Module struct { + config *config.Config + log *logrus.Logger +} + +// New returns a new security module +func New(config *config.Config, log *logrus.Logger) api.ClientModule { + return &Module{ + config: config, + log: log, + } +} + +// Route attaches security middleware to the given router +func (m *Module) Route(s router.Router) error { + s.AttachMiddleware(m.FlocBlock) + return nil +} diff --git a/internal/apimodule/account/account.go b/internal/apimodule/account/account.go @@ -1,117 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package 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/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/router" -) - -const ( - // IDKey is the key to use for retrieving account ID in requests - IDKey = "id" - // BasePath is the base API path for this module - BasePath = "/api/v1/accounts" - // BasePathWithID is the base path for this module with the ID key - BasePathWithID = BasePath + "/:" + IDKey - // VerifyPath is for verifying account credentials - VerifyPath = BasePath + "/verify_credentials" - // UpdateCredentialsPath is for updating account credentials - UpdateCredentialsPath = BasePath + "/update_credentials" -) - -// 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 -} - -// 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 { - return &Module{ - config: config, - db: db, - oauthServer: oauthServer, - mediaHandler: mediaHandler, - mastoConverter: mastoConverter, - log: log, - } -} - -// Route attaches all routes from this module to the given router -func (m *Module) Route(r router.Router) error { - r.AttachHandler(http.MethodPost, BasePath, m.AccountCreatePOSTHandler) - r.AttachHandler(http.MethodGet, BasePathWithID, m.muxHandler) - r.AttachHandler(http.MethodPatch, BasePathWithID, m.muxHandler) - return nil -} - -// CreateTables creates the required tables for this module in the given database -func (m *Module) CreateTables(db db.DB) error { - models := []interface{}{ - &gtsmodel.User{}, - &gtsmodel.Account{}, - &gtsmodel.Follow{}, - &gtsmodel.FollowRequest{}, - &gtsmodel.Status{}, - &gtsmodel.Application{}, - &gtsmodel.EmailDomainBlock{}, - &gtsmodel.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 { - case http.MethodGet: - if strings.HasPrefix(ru, VerifyPath) { - m.AccountVerifyGETHandler(c) - } else { - m.AccountGETHandler(c) - } - case http.MethodPatch: - if strings.HasPrefix(ru, UpdateCredentialsPath) { - m.AccountUpdateCredentialsPATCHHandler(c) - } - } -} diff --git a/internal/apimodule/account/accountcreate.go b/internal/apimodule/account/accountcreate.go @@ -1,155 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package account - -import ( - "errors" - "fmt" - "net" - "net/http" - - "github.com/gin-gonic/gin" - "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, -// and puts them in the database if they're valid. -// 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) - if err != nil { - l.Debugf("couldn't auth: %s", err) - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) - return - } - - l.Trace("parsing request form") - form := &mastotypes.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"}) - return - } - - l.Tracef("validating form %+v", form) - if err := validateCreateAccount(form, m.config.AccountsConfig, m.db); err != nil { - l.Debugf("error validating form: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - clientIP := c.ClientIP() - l.Tracef("attempting to parse client ip address %s", clientIP) - signUpIP := net.ParseIP(clientIP) - if signUpIP == nil { - l.Debugf("error validating sign up ip address %s", clientIP) - c.JSON(http.StatusBadRequest, gin.H{"error": "ip address could not be parsed from request"}) - return - } - - ti, err := m.accountCreate(form, signUpIP, authed.Token, authed.Application) - if err != nil { - l.Errorf("internal server error while creating new account: %s", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - 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 { - if !c.OpenRegistration { - return errors.New("registration is not open for this server") - } - - if err := util.ValidateUsername(form.Username); err != nil { - return err - } - - if err := util.ValidateEmail(form.Email); err != nil { - return err - } - - if err := util.ValidateNewPassword(form.Password); err != nil { - return err - } - - if !form.Agreement { - return errors.New("agreement to terms and conditions not given") - } - - if err := util.ValidateLanguage(form.Locale); err != nil { - return err - } - - if err := util.ValidateSignUpReason(form.Reason, c.ReasonRequired); err != nil { - 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/apimodule/account/accountget.go b/internal/apimodule/account/accountget.go @@ -1,57 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package account - -import ( - "net/http" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" -) - -// AccountGETHandler serves the account information held by the server in response to a GET -// request. It should be served as a GET at /api/v1/accounts/:id. -// -// See: https://docs.joinmastodon.org/methods/accounts/ -func (m *Module) AccountGETHandler(c *gin.Context) { - targetAcctID := c.Param(IDKey) - if targetAcctID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no account id specified"}) - return - } - - targetAccount := &gtsmodel.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) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, acctInfo) -} diff --git a/internal/apimodule/account/accountupdate.go b/internal/apimodule/account/accountupdate.go @@ -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 <http://www.gnu.org/licenses/>. -*/ - -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, &gtsmodel.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, &gtsmodel.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, &gtsmodel.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, &gtsmodel.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, &gtsmodel.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, &gtsmodel.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, &gtsmodel.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, &gtsmodel.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 := &gtsmodel.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/accountverify.go b/internal/apimodule/account/accountverify.go @@ -1,50 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package account - -import ( - "net/http" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// AccountVerifyGETHandler serves a user's account details to them IF they reached this -// handler while in possession of a valid token, according to the oauth middleware. -// 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) - 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) - 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) -} diff --git a/internal/apimodule/account/test/accountcreate_test.go b/internal/apimodule/account/test/accountcreate_test.go @@ -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 <http://www.gnu.org/licenses/>. -*/ - -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 = &gtsmodel.Account{ - ID: uuid.NewString(), - Username: "test_user", - } - - // can use this test application throughout - suite.testApplication = &gtsmodel.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{}{ - &gtsmodel.User{}, - &gtsmodel.Account{}, - &gtsmodel.Follow{}, - &gtsmodel.FollowRequest{}, - &gtsmodel.Status{}, - &gtsmodel.Application{}, - &gtsmodel.EmailDomainBlock{}, - &gtsmodel.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{}{ - &gtsmodel.User{}, - &gtsmodel.Account{}, - &gtsmodel.Follow{}, - &gtsmodel.FollowRequest{}, - &gtsmodel.Status{}, - &gtsmodel.Application{}, - &gtsmodel.EmailDomainBlock{}, - &gtsmodel.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 := &gtsmodel.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 := &gtsmodel.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 @@ -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 <http://www.gnu.org/licenses/>. -*/ - -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 = &gtsmodel.Account{ - ID: uuid.NewString(), - Username: "test_user", - } - - // can use this test application throughout - suite.testApplication = &gtsmodel.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{}{ - &gtsmodel.User{}, - &gtsmodel.Account{}, - &gtsmodel.Follow{}, - &gtsmodel.FollowRequest{}, - &gtsmodel.Status{}, - &gtsmodel.Application{}, - &gtsmodel.EmailDomainBlock{}, - &gtsmodel.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{}{ - &gtsmodel.User{}, - &gtsmodel.Account{}, - &gtsmodel.Follow{}, - &gtsmodel.FollowRequest{}, - &gtsmodel.Status{}, - &gtsmodel.Application{}, - &gtsmodel.EmailDomainBlock{}, - &gtsmodel.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/account/test/accountverify_test.go b/internal/apimodule/account/test/accountverify_test.go @@ -1,19 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package account diff --git a/internal/apimodule/admin/admin.go b/internal/apimodule/admin/admin.go @@ -1,88 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package admin - -import ( - "fmt" - "net/http" - - "github.com/sirupsen/logrus" - "github.com/superseriousbusiness/gotosocial/internal/apimodule" - "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/router" -) - -const ( - // BasePath is the base API path for this module - 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 -} - -// 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 { - return &Module{ - config: config, - db: db, - mediaHandler: mediaHandler, - mastoConverter: mastoConverter, - log: log, - } -} - -// Route attaches all routes from this module to the given router -func (m *Module) Route(r router.Router) error { - r.AttachHandler(http.MethodPost, EmojiPath, m.emojiCreatePOSTHandler) - return nil -} - -// CreateTables creates the necessary tables for this module in the given database -func (m *Module) CreateTables(db db.DB) error { - models := []interface{}{ - &gtsmodel.User{}, - &gtsmodel.Account{}, - &gtsmodel.Follow{}, - &gtsmodel.FollowRequest{}, - &gtsmodel.Status{}, - &gtsmodel.Application{}, - &gtsmodel.EmailDomainBlock{}, - &gtsmodel.MediaAttachment{}, - &gtsmodel.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/apimodule/admin/emojicreate.go @@ -1,130 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package 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/media" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/util" -) - -func (m *Module) emojiCreatePOSTHandler(c *gin.Context) { - l := m.log.WithFields(logrus.Fields{ - "func": "emojiCreatePOSTHandler", - "request_uri": c.Request.RequestURI, - "user_agent": c.Request.UserAgent(), - "origin_ip": c.ClientIP(), - }) - - // make sure we're authed with an admin account - authed, err := oauth.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 - } - if !authed.User.Admin { - l.Debugf("user %s not an admin", authed.User.ID) - c.JSON(http.StatusForbidden, gin.H{"error": "not an admin"}) - return - } - - // extract the media create form from the request context - l.Tracef("parsing request form: %+v", c.Request.Form) - form := &mastotypes.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)}) - 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 := validateCreateEmoji(form); err != nil { - l.Debugf("error validating form: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // open the emoji and extract the bytes from it - f, err := form.Image.Open() - 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)}) - return - } - - c.JSON(http.StatusOK, mastoEmoji) -} - -func validateCreateEmoji(form *mastotypes.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") - } - - // a very superficial check to see if the media size limit is exceeded - if form.Image.Size > media.EmojiMaxBytes { - return fmt.Errorf("file size limit exceeded: limit is %d bytes but emoji was %d bytes", media.EmojiMaxBytes, form.Image.Size) - } - - return util.ValidateEmojiShortcode(form.Shortcode) -} diff --git a/internal/apimodule/apimodule.go b/internal/apimodule/apimodule.go @@ -1,33 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -// Package apimodule is basically a wrapper for a lot of modules (in subdirectories) that satisfy the ClientAPIModule interface. -package apimodule - -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 -// 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 { - Route(s router.Router) error - CreateTables(db db.DB) error -} diff --git a/internal/apimodule/app/app.go b/internal/apimodule/app/app.go @@ -1,77 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package 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/router" -) - -// BasePath is the base path for this api module -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 -} - -// New returns a new auth module -func New(srv oauth.Server, db db.DB, mastoConverter mastotypes.Converter, log *logrus.Logger) apimodule.ClientAPIModule { - return &Module{ - server: srv, - db: db, - mastoConverter: mastoConverter, - log: log, - } -} - -// Route satisfies the RESTAPIModule interface -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{}, - &gtsmodel.User{}, - &gtsmodel.Account{}, - &gtsmodel.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/appcreate.go b/internal/apimodule/app/appcreate.go @@ -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 <http://www.gnu.org/licenses/>. -*/ - -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 := &gtsmodel.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/app/test/app_test.go b/internal/apimodule/app/test/app_test.go @@ -1,21 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package app - -// TODO: write tests diff --git a/internal/apimodule/auth/auth.go b/internal/apimodule/auth/auth.go @@ -1,88 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package auth - -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/oauth" - "github.com/superseriousbusiness/gotosocial/internal/router" -) - -const ( - // AuthSignInPath is the API path for users to sign in through - AuthSignInPath = "/auth/sign_in" - // OauthTokenPath is the API path to use for granting token requests to users with valid credentials - 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 - db db.DB - log *logrus.Logger -} - -// New returns a new auth module -func New(srv oauth.Server, db db.DB, log *logrus.Logger) apimodule.ClientAPIModule { - return &Module{ - server: srv, - db: db, - log: log, - } -} - -// Route satisfies the RESTAPIModule interface -func (m *Module) Route(s router.Router) error { - s.AttachHandler(http.MethodGet, AuthSignInPath, m.SignInGETHandler) - s.AttachHandler(http.MethodPost, AuthSignInPath, m.SignInPOSTHandler) - - s.AttachHandler(http.MethodPost, OauthTokenPath, m.TokenPOSTHandler) - - s.AttachHandler(http.MethodGet, OauthAuthorizePath, m.AuthorizeGETHandler) - s.AttachHandler(http.MethodPost, OauthAuthorizePath, m.AuthorizePOSTHandler) - - 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{}, - &gtsmodel.User{}, - &gtsmodel.Account{}, - &gtsmodel.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/authorize.go b/internal/apimodule/auth/authorize.go @@ -1,204 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package auth - -import ( - "errors" - "fmt" - "net/http" - "net/url" - - "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" -) - -// AuthorizeGETHandler should be served as GET at https://example.org/oauth/authorize -// The idea here is to present an oauth authorize page to the user, with a button -// that they have to click to accept. See here: https://docs.joinmastodon.org/methods/apps/oauth/#authorize-a-user -func (m *Module) AuthorizeGETHandler(c *gin.Context) { - l := m.log.WithField("func", "AuthorizeGETHandler") - s := sessions.Default(c) - - // UserID will be set in the session by AuthorizePOSTHandler if the caller has already gone through the authentication flow - // If it's not set, then we don't know yet who the user is, so we need to redirect them to the sign in page. - userID, ok := s.Get("userid").(string) - if !ok || userID == "" { - l.Trace("userid was empty, parsing form then redirecting to sign in page") - if err := parseAuthForm(c, l); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - } else { - c.Redirect(http.StatusFound, AuthSignInPath) - } - return - } - - // We can use the client_id on the session to retrieve info about the app associated with the client_id - clientID, ok := s.Get("client_id").(string) - if !ok || clientID == "" { - c.JSON(http.StatusInternalServerError, gin.H{"error": "no client_id found in session"}) - return - } - app := &gtsmodel.Application{ - ClientID: clientID, - } - if err := m.db.GetWhere("client_id", app.ClientID, app); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("no application found for client id %s", clientID)}) - return - } - - // we can also use the userid of the user to fetch their username from the db to greet them nicely <3 - user := &gtsmodel.User{ - ID: userID, - } - if err := m.db.GetByID(user.ID, user); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - acct := &gtsmodel.Account{ - ID: user.AccountID, - } - - if err := m.db.GetByID(acct.ID, acct); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - // Finally we should also get the redirect and scope of this particular request, as stored in the session. - redirect, ok := s.Get("redirect_uri").(string) - if !ok || redirect == "" { - c.JSON(http.StatusInternalServerError, gin.H{"error": "no redirect_uri found in session"}) - return - } - scope, ok := s.Get("scope").(string) - if !ok || scope == "" { - c.JSON(http.StatusInternalServerError, gin.H{"error": "no scope found in session"}) - return - } - - // the authorize template will display a form to the user where they can get some information - // about the app that's trying to authorize, and the scope of the request. - // They can then approve it if it looks OK to them, which will POST to the AuthorizePOSTHandler - l.Trace("serving authorize html") - c.HTML(http.StatusOK, "authorize.tmpl", gin.H{ - "appname": app.Name, - "appwebsite": app.Website, - "redirect": redirect, - "scope": scope, - "user": acct.Username, - }) -} - -// AuthorizePOSTHandler should be served as POST at https://example.org/oauth/authorize -// At this point we assume that the user has A) logged in and B) accepted that the app should act for them, -// so we should proceed with the authentication flow and generate an oauth token for them if we can. -// See here: https://docs.joinmastodon.org/methods/apps/oauth/#authorize-a-user -func (m *Module) AuthorizePOSTHandler(c *gin.Context) { - l := m.log.WithField("func", "AuthorizePOSTHandler") - s := sessions.Default(c) - - // At this point we know the user has said 'yes' to allowing the application and oauth client - // work for them, so we can set the - - // We need to retrieve the original form submitted to the authorizeGEThandler, and - // recreate it on the request so that it can be used further by the oauth2 library. - // So first fetch all the values from the session. - forceLogin, ok := s.Get("force_login").(string) - if !ok { - c.JSON(http.StatusBadRequest, gin.H{"error": "session missing force_login"}) - return - } - responseType, ok := s.Get("response_type").(string) - if !ok || responseType == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "session missing response_type"}) - return - } - clientID, ok := s.Get("client_id").(string) - if !ok || clientID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "session missing client_id"}) - return - } - redirectURI, ok := s.Get("redirect_uri").(string) - if !ok || redirectURI == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "session missing redirect_uri"}) - return - } - scope, ok := s.Get("scope").(string) - if !ok { - c.JSON(http.StatusBadRequest, gin.H{"error": "session missing scope"}) - return - } - userID, ok := s.Get("userid").(string) - if !ok { - c.JSON(http.StatusBadRequest, gin.H{"error": "session missing userid"}) - return - } - // we're done with the session so we can clear it now - s.Clear() - - // now set the values on the request - values := url.Values{} - values.Set("force_login", forceLogin) - values.Set("response_type", responseType) - values.Set("client_id", clientID) - values.Set("redirect_uri", redirectURI) - values.Set("scope", scope) - values.Set("userid", userID) - c.Request.Form = values - l.Tracef("values on request set to %+v", c.Request.Form) - - // and proceed with authorization using the oauth2 library - if err := m.server.HandleAuthorizeRequest(c.Writer, c.Request); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - } -} - -// parseAuthForm parses the OAuthAuthorize form in the gin context, and stores -// the values in the form into the session. -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{} - if err := c.ShouldBind(form); err != nil { - return err - } - l.Tracef("parsed form: %+v", form) - - // these fields are *required* so check 'em - if form.ResponseType == "" || form.ClientID == "" || form.RedirectURI == "" { - return errors.New("missing one of: response_type, client_id or redirect_uri") - } - - // set default scope to read - if form.Scope == "" { - form.Scope = "read" - } - - // save these values from the form so we can use them elsewhere in the session - s.Set("force_login", form.ForceLogin) - s.Set("response_type", form.ResponseType) - s.Set("client_id", form.ClientID) - s.Set("redirect_uri", form.RedirectURI) - s.Set("scope", form.Scope) - return s.Save() -} diff --git a/internal/apimodule/auth/middleware.go b/internal/apimodule/auth/middleware.go @@ -1,76 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package auth - -import ( - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// OauthTokenMiddleware checks if the client has presented a valid oauth Bearer token. -// If so, it will check the User that the token belongs to, and set that in the context of -// the request. Then, it will look up the account for that user, and set that in the request too. -// 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.Trace("entering OauthTokenMiddleware") - - ti, err := m.server.ValidationBearerToken(c.Request) - if err != nil { - l.Trace("no valid token presented: continuing with unauthenticated request") - return - } - c.Set(oauth.SessionAuthorizedToken, ti) - l.Tracef("set gin context %s to %+v", oauth.SessionAuthorizedToken, ti) - - // check for user-level token - if uid := ti.GetUserID(); uid != "" { - l.Tracef("authenticated user %s with bearer token, scope is %s", uid, ti.GetScope()) - - // fetch user's and account for this user id - user := &gtsmodel.User{} - if err := m.db.GetByID(uid, user); err != nil || user == nil { - l.Warnf("no user found for validated uid %s", uid) - return - } - c.Set(oauth.SessionAuthorizedUser, user) - l.Tracef("set gin context %s to %+v", oauth.SessionAuthorizedUser, user) - - acct := &gtsmodel.Account{} - if err := m.db.GetByID(user.AccountID, acct); err != nil || acct == nil { - l.Warnf("no account found for validated user %s", uid) - return - } - c.Set(oauth.SessionAuthorizedAccount, acct) - l.Tracef("set gin context %s to %+v", oauth.SessionAuthorizedAccount, acct) - } - - // check for application token - if cid := ti.GetClientID(); cid != "" { - l.Tracef("authenticated client %s with bearer token, scope is %s", cid, ti.GetScope()) - app := &gtsmodel.Application{} - if err := m.db.GetWhere("client_id", cid, app); err != nil { - l.Tracef("no app found for client %s", cid) - } - c.Set(oauth.SessionAuthorizedApplication, app) - l.Tracef("set gin context %s to %+v", oauth.SessionAuthorizedApplication, app) - } -} diff --git a/internal/apimodule/auth/signin.go b/internal/apimodule/auth/signin.go @@ -1,116 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package auth - -import ( - "errors" - "net/http" - - "github.com/gin-contrib/sessions" - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "golang.org/x/crypto/bcrypt" -) - -// login just wraps a form-submitted username (we want an email) and password -type login struct { - Email string `form:"username"` - Password string `form:"password"` -} - -// SignInGETHandler should be served at https://example.org/auth/sign_in. -// The idea is to present a sign in page to the user, where they can enter their username and password. -// The form will then POST to the sign in page, which will be handled by SignInPOSTHandler -func (m *Module) SignInGETHandler(c *gin.Context) { - m.log.WithField("func", "SignInGETHandler").Trace("serving sign in html") - c.HTML(http.StatusOK, "sign-in.tmpl", gin.H{}) -} - -// SignInPOSTHandler should be served at https://example.org/auth/sign_in. -// The idea is to present a sign in page to the user, where they can enter their username and password. -// The handler will then redirect to the auth handler served at /auth -func (m *Module) SignInPOSTHandler(c *gin.Context) { - l := m.log.WithField("func", "SignInPOSTHandler") - s := sessions.Default(c) - form := &login{} - if err := c.ShouldBind(form); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - l.Tracef("parsed form: %+v", form) - - userid, err := m.ValidatePassword(form.Email, form.Password) - if err != nil { - c.String(http.StatusForbidden, err.Error()) - return - } - - s.Set("userid", userid) - if err := s.Save(); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - l.Trace("redirecting to auth page") - c.Redirect(http.StatusFound, OauthAuthorizePath) -} - -// ValidatePassword takes an email address and a password. -// The goal is to authenticate the password against the one for that email -// address stored in the database. If OK, we return the userid (a uuid) for that user, -// so that it can be used in further Oauth flows to generate a token/retreieve an oauth client from the db. -func (m *Module) ValidatePassword(email string, password string) (userid string, err error) { - l := m.log.WithField("func", "ValidatePassword") - - // make sure an email/password was provided and bail if not - if email == "" || password == "" { - l.Debug("email or password was not provided") - return incorrectPassword() - } - - // first we select the user from the database based on email address, bail if no user found for that email - gtsUser := &gtsmodel.User{} - - if err := m.db.GetWhere("email", email, gtsUser); err != nil { - l.Debugf("user %s was not retrievable from db during oauth authorization attempt: %s", email, err) - return incorrectPassword() - } - - // make sure a password is actually set and bail if not - if gtsUser.EncryptedPassword == "" { - l.Warnf("encrypted password for user %s was empty for some reason", gtsUser.Email) - return incorrectPassword() - } - - // compare the provided password with the encrypted one from the db, bail if they don't match - if err := bcrypt.CompareHashAndPassword([]byte(gtsUser.EncryptedPassword), []byte(password)); err != nil { - l.Debugf("password hash didn't match for user %s during login attempt: %s", gtsUser.Email, err) - return incorrectPassword() - } - - // If we've made it this far the email/password is correct, so we can just return the id of the user. - userid = gtsUser.ID - l.Tracef("returning (%s, %s)", userid, err) - return -} - -// incorrectPassword is just a little helper function to use in the ValidatePassword function -func incorrectPassword() (string, error) { - return "", errors.New("password/email combination was incorrect") -} diff --git a/internal/apimodule/auth/test/auth_test.go b/internal/apimodule/auth/test/auth_test.go @@ -1,166 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package auth - -import ( - "context" - "fmt" - "testing" - - "github.com/google/uuid" - "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/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "golang.org/x/crypto/bcrypt" -) - -type AuthTestSuite struct { - suite.Suite - oauthServer oauth.Server - db db.DB - testAccount *gtsmodel.Account - testApplication *gtsmodel.Application - testUser *gtsmodel.User - testClient *oauth.Client - config *config.Config -} - -// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout -func (suite *AuthTestSuite) SetupSuite() { - c := config.Empty() - // we're running on localhost without https so set the protocol to http - c.Protocol = "http" - // just for testing - c.Host = "localhost:8080" - // because go tests are run within the test package directory, we need to fiddle with the templateconfig - // basedir in a way that we wouldn't normally have to do when running the binary, in order to make - // the templates actually load - c.TemplateConfig.BaseDir = "../../../web/template/" - c.DBConfig = &config.DBConfig{ - Type: "postgres", - Address: "localhost", - Port: 5432, - User: "postgres", - Password: "postgres", - Database: "postgres", - ApplicationName: "gotosocial", - } - suite.config = c - - encryptedPassword, err := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost) - if err != nil { - logrus.Panicf("error encrypting user pass: %s", err) - } - - acctID := uuid.NewString() - - suite.testAccount = &gtsmodel.Account{ - ID: acctID, - Username: "test_user", - } - suite.testUser = &gtsmodel.User{ - EncryptedPassword: string(encryptedPassword), - Email: "user@example.org", - AccountID: acctID, - } - suite.testClient = &oauth.Client{ - ID: "a-known-client-id", - Secret: "some-secret", - Domain: fmt.Sprintf("%s://%s", c.Protocol, c.Host), - } - suite.testApplication = &gtsmodel.Application{ - 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: uuid.NewString(), - } -} - -// SetupTest creates a postgres connection and creates the oauth_clients table before each test -func (suite *AuthTestSuite) SetupTest() { - - log := logrus.New() - log.SetLevel(logrus.TraceLevel) - db, err := db.New(context.Background(), suite.config, log) - if err != nil { - logrus.Panicf("error creating database connection: %s", err) - } - - suite.db = db - - models := []interface{}{ - &oauth.Client{}, - &oauth.Token{}, - &gtsmodel.User{}, - &gtsmodel.Account{}, - &gtsmodel.Application{}, - } - - for _, m := range models { - if err := suite.db.CreateTable(m); err != nil { - logrus.Panicf("db connection error: %s", err) - } - } - - suite.oauthServer = oauth.New(suite.db, log) - - if err := suite.db.Put(suite.testAccount); err != nil { - logrus.Panicf("could not insert test account into db: %s", err) - } - if err := suite.db.Put(suite.testUser); err != nil { - logrus.Panicf("could not insert test user into db: %s", err) - } - if err := suite.db.Put(suite.testClient); err != nil { - logrus.Panicf("could not insert test client into db: %s", err) - } - if err := suite.db.Put(suite.testApplication); err != nil { - logrus.Panicf("could not insert test application into db: %s", err) - } - -} - -// TearDownTest drops the oauth_clients table and closes the pg connection after each test -func (suite *AuthTestSuite) TearDownTest() { - models := []interface{}{ - &oauth.Client{}, - &oauth.Token{}, - &gtsmodel.User{}, - &gtsmodel.Account{}, - &gtsmodel.Application{}, - } - for _, m := range models { - if err := suite.db.DropTable(m); err != nil { - logrus.Panicf("error dropping table: %s", err) - } - } - if err := suite.db.Stop(context.Background()); err != nil { - logrus.Panicf("error closing db connection: %s", err) - } - suite.db = nil -} - -func TestAuthTestSuite(t *testing.T) { - suite.Run(t, new(AuthTestSuite)) -} diff --git a/internal/apimodule/fileserver/fileserver.go b/internal/apimodule/fileserver/fileserver.go @@ -1,84 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package fileserver - -import ( - "fmt" - "net/http" - - "github.com/sirupsen/logrus" - "github.com/superseriousbusiness/gotosocial/internal/apimodule" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/router" - "github.com/superseriousbusiness/gotosocial/internal/storage" -) - -const ( - // AccountIDKey is the url key for account id (an account uuid) - AccountIDKey = "account_id" - // MediaTypeKey is the url key for media type (usually something like attachment or header etc) - MediaTypeKey = "media_type" - // 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" -) - -// 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 - 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 { - return &FileServer{ - config: config, - db: db, - storage: storage, - log: log, - storageBase: config.StorageConfig.ServeBasePath, - } -} - -// Route satisfies the RESTAPIModule interface -func (m *FileServer) Route(s router.Router) error { - s.AttachHandler(http.MethodGet, fmt.Sprintf("%s/:%s/:%s/:%s/:%s", m.storageBase, AccountIDKey, MediaTypeKey, MediaSizeKey, FileNameKey), m.ServeFile) - return nil -} - -// CreateTables populates necessary tables in the given DB -func (m *FileServer) CreateTables(db db.DB) error { - models := []interface{}{ - &gtsmodel.MediaAttachment{}, - } - - 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/fileserver/servefile.go b/internal/apimodule/fileserver/servefile.go @@ -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 <http://www.gnu.org/licenses/>. -*/ - -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 := &gtsmodel.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 := &gtsmodel.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 := &gtsmodel.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/fileserver/test/servefile_test.go b/internal/apimodule/fileserver/test/servefile_test.go @@ -1,157 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package test - -import ( - "context" - "fmt" - "io/ioutil" - "net/http" - "net/http/httptest" - "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/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/media" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/storage" - "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 - - // 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 - - // item being tested - fileServer *fileserver.FileServer -} - -/* - TEST INFRASTRUCTURE -*/ - -func (suite *ServeFileTestSuite) 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) - - // setup module being tested - suite.fileServer = fileserver.New(suite.config, suite.db, suite.storage, suite.log).(*fileserver.FileServer) -} - -func (suite *ServeFileTestSuite) TearDownSuite() { - if err := suite.db.Stop(context.Background()); err != nil { - logrus.Panicf("error closing db connection: %s", err) - } -} - -func (suite *ServeFileTestSuite) 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() -} - -func (suite *ServeFileTestSuite) TearDownTest() { - testrig.StandardDBTeardown(suite.db) - testrig.StandardStorageTeardown(suite.storage) -} - -/* - ACTUAL TESTS -*/ - -func (suite *ServeFileTestSuite) TestServeOriginalFileSuccessful() { - targetAttachment, ok := suite.testAttachments["admin_account_status_1_attachment_1"] - assert.True(suite.T(), ok) - assert.NotNil(suite.T(), targetAttachment) - - recorder := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(recorder) - ctx.Request = httptest.NewRequest(http.MethodGet, targetAttachment.URL, nil) - - // normally the router would populate these params from the path values, - // but because we're calling the ServeFile function directly, we need to set them manually. - ctx.Params = gin.Params{ - gin.Param{ - Key: fileserver.AccountIDKey, - Value: targetAttachment.AccountID, - }, - gin.Param{ - Key: fileserver.MediaTypeKey, - Value: media.MediaAttachment, - }, - gin.Param{ - Key: fileserver.MediaSizeKey, - Value: media.MediaOriginal, - }, - gin.Param{ - Key: fileserver.FileNameKey, - Value: fmt.Sprintf("%s.jpeg", targetAttachment.ID), - }, - } - - // call the function we're testing and check status code - suite.fileServer.ServeFile(ctx) - suite.EqualValues(http.StatusOK, recorder.Code) - - b, err := ioutil.ReadAll(recorder.Body) - assert.NoError(suite.T(), err) - assert.NotNil(suite.T(), b) - - fileInStorage, err := suite.storage.RetrieveFileFrom(targetAttachment.File.Path) - assert.NoError(suite.T(), err) - assert.NotNil(suite.T(), fileInStorage) - assert.Equal(suite.T(), b, fileInStorage) -} - -func TestServeFileTestSuite(t *testing.T) { - suite.Run(t, new(ServeFileTestSuite)) -} diff --git a/internal/apimodule/media/media.go b/internal/apimodule/media/media.go @@ -1,76 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package media - -import ( - "fmt" - "net/http" - - "github.com/sirupsen/logrus" - "github.com/superseriousbusiness/gotosocial/internal/apimodule" - "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/router" -) - -// BasePath is the base API path for making media requests -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 -} - -// 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 { - return &Module{ - mediaHandler: mediaHandler, - config: config, - db: db, - mastoConverter: mastoConverter, - log: log, - } -} - -// Route satisfies the RESTAPIModule interface -func (m *Module) Route(s router.Router) error { - s.AttachHandler(http.MethodPost, BasePath, m.MediaCreatePOSTHandler) - return nil -} - -// CreateTables populates necessary tables in the given DB -func (m *Module) CreateTables(db db.DB) error { - models := []interface{}{ - &gtsmodel.MediaAttachment{}, - } - - 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/media/mediacreate.go b/internal/apimodule/media/mediacreate.go @@ -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 <http://www.gnu.org/licenses/>. -*/ - -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/media/test/mediacreate_test.go b/internal/apimodule/media/test/mediacreate_test.go @@ -1,194 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package test - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io/ioutil" - "net/http" - "net/http/httptest" - "testing" - - "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/suite" - mediamodule "github.com/superseriousbusiness/gotosocial/internal/apimodule/media" - "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/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 - - // 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 - - // item being tested - mediaModule *mediamodule.Module -} - -/* - TEST INFRASTRUCTURE -*/ - -func (suite *MediaCreateTestSuite) 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) - - // setup module being tested - suite.mediaModule = mediamodule.New(suite.db, suite.mediaHandler, suite.mastoConverter, suite.config, suite.log).(*mediamodule.Module) -} - -func (suite *MediaCreateTestSuite) TearDownSuite() { - if err := suite.db.Stop(context.Background()); err != nil { - logrus.Panicf("error closing db connection: %s", err) - } -} - -func (suite *MediaCreateTestSuite) 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() -} - -func (suite *MediaCreateTestSuite) TearDownTest() { - testrig.StandardDBTeardown(suite.db) - testrig.StandardStorageTeardown(suite.storage) -} - -/* - ACTUAL TESTS -*/ - -func (suite *MediaCreateTestSuite) TestStatusCreatePOSTImageHandlerSuccessful() { - - // set up the context for the request - t := suite.testTokens["local_account_1"] - oauthToken := oauth.TokenToOauthToken(t) - recorder := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(recorder) - ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) - ctx.Set(oauth.SessionAuthorizedToken, oauthToken) - ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) - ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) - - // see what's in storage *before* the request - storageKeysBeforeRequest, err := suite.storage.ListKeys() - if err != nil { - panic(err) - } - - // create the request - buf, w, err := testrig.CreateMultipartFormData("file", "../../../../testrig/media/test-jpeg.jpg", map[string]string{ - "description": "this is a test image -- a cool background from somewhere", - "focus": "-0.5,0.5", - }) - if err != nil { - panic(err) - } - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", mediamodule.BasePath), bytes.NewReader(buf.Bytes())) // the endpoint we're hitting - ctx.Request.Header.Set("Content-Type", w.FormDataContentType()) - - // do the actual request - suite.mediaModule.MediaCreatePOSTHandler(ctx) - - // check what's in storage *after* the request - storageKeysAfterRequest, err := suite.storage.ListKeys() - if err != nil { - panic(err) - } - - // check response - suite.EqualValues(http.StatusAccepted, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) - fmt.Println(string(b)) - - attachmentReply := &mastomodel.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{ - Width: 1920, - Height: 1080, - Size: "1920x1080", - Aspect: 1.7777778, - }, - Small: mastomodel.MediaDimensions{ - Width: 256, - Height: 144, - Size: "256x144", - Aspect: 1.7777778, - }, - Focus: mastomodel.MediaFocus{ - X: -0.5, - Y: 0.5, - }, - }, attachmentReply.Meta) - assert.Equal(suite.T(), "LjCZnlvyRkRn_NvzRjWF?urqV@f9", attachmentReply.Blurhash) - assert.NotEmpty(suite.T(), attachmentReply.ID) - assert.NotEmpty(suite.T(), attachmentReply.URL) - assert.NotEmpty(suite.T(), attachmentReply.PreviewURL) - assert.Equal(suite.T(), len(storageKeysBeforeRequest)+2, len(storageKeysAfterRequest)) // 2 images should be added to storage: the original and the thumbnail -} - -func TestMediaCreateTestSuite(t *testing.T) { - suite.Run(t, new(MediaCreateTestSuite)) -} diff --git a/internal/apimodule/mock_ClientAPIModule.go b/internal/apimodule/mock_ClientAPIModule.go @@ -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/security/security.go b/internal/apimodule/security/security.go @@ -1,52 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package security - -import ( - "github.com/sirupsen/logrus" - "github.com/superseriousbusiness/gotosocial/internal/apimodule" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/router" -) - -// Module implements the ClientAPIModule interface for security middleware -type Module struct { - config *config.Config - log *logrus.Logger -} - -// New returns a new security module -func New(config *config.Config, log *logrus.Logger) apimodule.ClientAPIModule { - return &Module{ - config: config, - log: log, - } -} - -// Route attaches security middleware to the given router -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/status/status.go b/internal/apimodule/status/status.go @@ -1,158 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package 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/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/router" -) - -const ( - // IDKey is for status UUIDs - IDKey = "id" - // BasePath is the base path for serving the status API - 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 - - // ContextPath is used for fetching context of posts - ContextPath = BasePathWithID + "/context" - - // FavouritedPath is for seeing who's faved a given status - FavouritedPath = BasePathWithID + "/favourited_by" - // FavouritePath is for posting a fave on a status - 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" - // UnreblogPath is for undoing a boost/reblog of a given status - UnreblogPath = BasePathWithID + "/unreblog" - - // BookmarkPath is for creating a bookmark on a given status - 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" - // 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" - // 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 -} - -// 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 { - return &Module{ - config: config, - db: db, - mediaHandler: mediaHandler, - mastoConverter: mastoConverter, - distributor: distributor, - log: log, - } -} - -// Route attaches all routes from this module to the given router -func (m *Module) Route(r router.Router) error { - r.AttachHandler(http.MethodPost, BasePath, m.StatusCreatePOSTHandler) - r.AttachHandler(http.MethodDelete, BasePathWithID, m.StatusDELETEHandler) - - r.AttachHandler(http.MethodPost, FavouritePath, m.StatusFavePOSTHandler) - r.AttachHandler(http.MethodPost, UnfavouritePath, m.StatusFavePOSTHandler) - - 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{}{ - &gtsmodel.User{}, - &gtsmodel.Account{}, - &gtsmodel.Block{}, - &gtsmodel.Follow{}, - &gtsmodel.FollowRequest{}, - &gtsmodel.Status{}, - &gtsmodel.StatusFave{}, - &gtsmodel.StatusBookmark{}, - &gtsmodel.StatusMute{}, - &gtsmodel.StatusPin{}, - &gtsmodel.Application{}, - &gtsmodel.EmailDomainBlock{}, - &gtsmodel.MediaAttachment{}, - &gtsmodel.Emoji{}, - &gtsmodel.Tag{}, - &gtsmodel.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") - ru := c.Request.RequestURI - - switch c.Request.Method { - case http.MethodGet: - if strings.HasPrefix(ru, ContextPath) { - // TODO - } else if strings.HasPrefix(ru, FavouritedPath) { - m.StatusFavedByGETHandler(c) - } else { - m.StatusGETHandler(c) - } - } -} diff --git a/internal/apimodule/status/statuscreate.go b/internal/apimodule/status/statuscreate.go @@ -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 <http://www.gnu.org/licenses/>. -*/ - -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 := &gtsmodel.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 := &gtsmodel.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 := &gtsmodel.Status{} - repliedAccount := &gtsmodel.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 := &gtsmodel.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 @@ -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 <http://www.gnu.org/licenses/>. -*/ - -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 := &gtsmodel.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 = &gtsmodel.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 @@ -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 <http://www.gnu.org/licenses/>. -*/ - -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 := &gtsmodel.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 := &gtsmodel.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 = &gtsmodel.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 @@ -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 <http://www.gnu.org/licenses/>. -*/ - -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 := &gtsmodel.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 := &gtsmodel.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 @@ -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 <http://www.gnu.org/licenses/>. -*/ - -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 := &gtsmodel.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 := &gtsmodel.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 = &gtsmodel.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 @@ -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 <http://www.gnu.org/licenses/>. -*/ - -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 := &gtsmodel.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 := &gtsmodel.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 = &gtsmodel.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/apimodule/status/test/statuscreate_test.go b/internal/apimodule/status/test/statuscreate_test.go @@ -1,346 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package status - -import ( - "encoding/json" - "fmt" - "io/ioutil" - "net/http" - "net/http/httptest" - "net/url" - "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/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 -} - -/* - 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() -} - -// TearDownTest drops tables to make sure there's no data in the db -func (suite *StatusCreateTestSuite) TearDownTest() { - testrig.StandardDBTeardown(suite.db) -} - -/* - ACTUAL TESTS -*/ - -/* - TESTING: StatusCreatePOSTHandler -*/ - -// Post a new status with some custom visibility settings -func (suite *StatusCreateTestSuite) TestPostNewStatus() { - - 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.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) - ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting - ctx.Request.Form = url.Values{ - "status": {"this is a brand new status! #helloworld"}, - "spoiler_text": {"hello hello"}, - "sensitive": {"true"}, - "visibility_advanced": {"mutuals_only"}, - "likeable": {"false"}, - "replyable": {"false"}, - "federated": {"false"}, - } - suite.statusModule.StatusCreatePOSTHandler(ctx) - - // check response - - // 1. we should have OK from our call to the function - suite.EqualValues(http.StatusOK, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) - - statusReply := &mastomodel.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.Len(suite.T(), statusReply.Tags, 1) - assert.Equal(suite.T(), mastomodel.Tag{ - Name: "helloworld", - URL: "http://localhost:8080/tags/helloworld", - }, statusReply.Tags[0]) - - gtsTag := &gtsmodel.Tag{} - err = suite.db.GetWhere("name", "helloworld", gtsTag) - assert.NoError(suite.T(), err) - assert.Equal(suite.T(), statusReply.Account.ID, gtsTag.FirstSeenFromAccountID) -} - -func (suite *StatusCreateTestSuite) TestPostNewStatusWithEmoji() { - - 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.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) - ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting - ctx.Request.Form = url.Values{ - "status": {"here is a rainbow emoji a few times! :rainbow: :rainbow: :rainbow: \n here's an emoji that isn't in the db: :test_emoji: "}, - } - suite.statusModule.StatusCreatePOSTHandler(ctx) - - suite.EqualValues(http.StatusOK, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) - - statusReply := &mastomodel.Status{} - err = json.Unmarshal(b, statusReply) - assert.NoError(suite.T(), err) - - assert.Equal(suite.T(), "", statusReply.SpoilerText) - assert.Equal(suite.T(), "here is a rainbow emoji a few times! :rainbow: :rainbow: :rainbow: \n here's an emoji that isn't in the db: :test_emoji: ", statusReply.Content) - - assert.Len(suite.T(), statusReply.Emojis, 1) - mastoEmoji := statusReply.Emojis[0] - gtsEmoji := testrig.NewTestEmojis()["rainbow"] - - assert.Equal(suite.T(), gtsEmoji.Shortcode, mastoEmoji.Shortcode) - assert.Equal(suite.T(), gtsEmoji.ImageURL, mastoEmoji.URL) - assert.Equal(suite.T(), gtsEmoji.ImageStaticURL, mastoEmoji.StaticURL) -} - -// Try to reply to a status that doesn't exist -func (suite *StatusCreateTestSuite) TestReplyToNonexistentStatus() { - 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.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) - ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting - ctx.Request.Form = url.Values{ - "status": {"this is a reply to a status that doesn't exist"}, - "spoiler_text": {"don't open cuz it won't work"}, - "in_reply_to_id": {"3759e7ef-8ee1-4c0c-86f6-8b70b9ad3d50"}, - } - suite.statusModule.StatusCreatePOSTHandler(ctx) - - // check response - - 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(), `{"error":"status with id 3759e7ef-8ee1-4c0c-86f6-8b70b9ad3d50 not replyable because it doesn't exist"}`, string(b)) -} - -// Post a reply to the status of a local user that allows replies. -func (suite *StatusCreateTestSuite) TestReplyToLocalStatus() { - 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.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) - ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting - ctx.Request.Form = url.Values{ - "status": {fmt.Sprintf("hello @%s this reply should work!", testrig.NewTestAccounts()["local_account_2"].Username)}, - "in_reply_to_id": {testrig.NewTestStatuses()["local_account_2_status_1"].ID}, - } - suite.statusModule.StatusCreatePOSTHandler(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) - - statusReply := &mastomodel.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(), 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) -} - -// Take a media file which is currently not associated with a status, and attach it to a new status. -func (suite *StatusCreateTestSuite) TestAttachNewMediaSuccess() { - 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.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) - ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting - ctx.Request.Form = url.Values{ - "status": {"here's an image attachment"}, - "media_ids": {"7a3b9f77-ab30-461e-bdd8-e64bd1db3008"}, - } - suite.statusModule.StatusCreatePOSTHandler(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) - - fmt.Println(string(b)) - - statusReply := &mastomodel.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) - - // there should be one media attachment - assert.Len(suite.T(), statusReply.MediaAttachments, 1) - - // get the updated media attachment from the database - gtsAttachment := &gtsmodel.MediaAttachment{} - err = suite.db.GetByID(statusReply.MediaAttachments[0].ID, gtsAttachment) - assert.NoError(suite.T(), err) - - // convert it to a masto attachment - gtsAttachmentAsMasto, err := suite.mastoConverter.AttachmentToMasto(gtsAttachment) - assert.NoError(suite.T(), err) - - // compare it with what we have now - assert.EqualValues(suite.T(), statusReply.MediaAttachments[0], gtsAttachmentAsMasto) - - // the status id of the attachment should now be set to the id of the status we just created - assert.Equal(suite.T(), statusReply.ID, gtsAttachment.StatusID) -} - -func TestStatusCreateTestSuite(t *testing.T) { - suite.Run(t, new(StatusCreateTestSuite)) -} diff --git a/internal/apimodule/status/test/statusfave_test.go b/internal/apimodule/status/test/statusfave_test.go @@ -1,207 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package status - -import ( - "encoding/json" - "fmt" - "io/ioutil" - "net/http" - "net/http/httptest" - "strings" - "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/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 -} - -/* - 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() - suite.testUsers = testrig.NewTestUsers() - suite.testAccounts = testrig.NewTestAccounts() - suite.testAttachments = testrig.NewTestAttachments() - suite.testStatuses = testrig.NewTestStatuses() -} - -// TearDownTest drops tables to make sure there's no data in the db -func (suite *StatusFaveTestSuite) TearDownTest() { - testrig.StandardDBTeardown(suite.db) - testrig.StandardStorageTeardown(suite.storage) -} - -/* - ACTUAL TESTS -*/ - -// fave a status -func (suite *StatusFaveTestSuite) TestPostFave() { - - t := suite.testTokens["local_account_1"] - oauthToken := oauth.TokenToOauthToken(t) - - targetStatus := suite.testStatuses["admin_account_status_2"] - - // setup - recorder := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(recorder) - ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) - ctx.Set(oauth.SessionAuthorizedToken, oauthToken) - ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) - ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.FavouritePath, ":id", targetStatus.ID, 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: status.IDKey, - Value: targetStatus.ID, - }, - } - - suite.statusModule.StatusFavePOSTHandler(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) - - statusReply := &mastomodel.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.True(suite.T(), statusReply.Favourited) - assert.Equal(suite.T(), 1, statusReply.FavouritesCount) -} - -// try to fave a status that's not faveable -func (suite *StatusFaveTestSuite) TestPostUnfaveable() { - - t := suite.testTokens["local_account_1"] - oauthToken := oauth.TokenToOauthToken(t) - - targetStatus := suite.testStatuses["local_account_2_status_3"] // this one is unlikeable and unreplyable - - // setup - recorder := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(recorder) - ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) - ctx.Set(oauth.SessionAuthorizedToken, oauthToken) - ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) - ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.FavouritePath, ":id", targetStatus.ID, 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: status.IDKey, - Value: targetStatus.ID, - }, - } - - suite.statusModule.StatusFavePOSTHandler(ctx) - - // check response - suite.EqualValues(http.StatusForbidden, recorder.Code) // we 403 unlikeable statuses - - 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)) -} - -func TestStatusFaveTestSuite(t *testing.T) { - suite.Run(t, new(StatusFaveTestSuite)) -} diff --git a/internal/apimodule/status/test/statusfavedby_test.go b/internal/apimodule/status/test/statusfavedby_test.go @@ -1,159 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package status - -import ( - "encoding/json" - "fmt" - "io/ioutil" - "net/http" - "net/http/httptest" - "strings" - "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/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 -} - -// 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() - suite.testUsers = testrig.NewTestUsers() - suite.testAccounts = testrig.NewTestAccounts() - suite.testAttachments = testrig.NewTestAttachments() - suite.testStatuses = testrig.NewTestStatuses() -} - -// TearDownTest drops tables to make sure there's no data in the db -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) - - targetStatus := suite.testStatuses["admin_account_status_1"] // this status is faved by local_account_1 - - // setup - recorder := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(recorder) - ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_2"]) - ctx.Set(oauth.SessionAuthorizedToken, oauthToken) - ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_2"]) - ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_2"]) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.FavouritedPath, ":id", targetStatus.ID, 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: status.IDKey, - Value: targetStatus.ID, - }, - } - - suite.statusModule.StatusFavedByGETHandler(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) - - accts := []mastomodel.Account{} - err = json.Unmarshal(b, &accts) - assert.NoError(suite.T(), err) - - assert.Len(suite.T(), accts, 1) - assert.Equal(suite.T(), "the_mighty_zork", accts[0].Username) -} - -func TestStatusFavedByTestSuite(t *testing.T) { - suite.Run(t, new(StatusFavedByTestSuite)) -} diff --git a/internal/apimodule/status/test/statusget_test.go b/internal/apimodule/status/test/statusget_test.go @@ -1,168 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package status - -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/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 -} - -/* - 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() -} - -// TearDownTest drops tables to make sure there's no data in the db -func (suite *StatusGetTestSuite) TearDownTest() { - testrig.StandardDBTeardown(suite.db) -} - -/* - ACTUAL TESTS -*/ - -/* - TESTING: StatusGetPOSTHandler -*/ - -// Post a new status with some custom visibility settings -func (suite *StatusGetTestSuite) TestPostNewStatus() { - - // t := suite.testTokens["local_account_1"] - // oauthToken := oauth.PGTokenToOauthToken(t) - - // // setup - // recorder := httptest.NewRecorder() - // ctx, _ := gin.CreateTestContext(recorder) - // ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) - // ctx.Set(oauth.SessionAuthorizedToken, oauthToken) - // ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) - // ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) - // ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", basePath), nil) // the endpoint we're hitting - // ctx.Request.Form = url.Values{ - // "status": {"this is a brand new status! #helloworld"}, - // "spoiler_text": {"hello hello"}, - // "sensitive": {"true"}, - // "visibility_advanced": {"mutuals_only"}, - // "likeable": {"false"}, - // "replyable": {"false"}, - // "federated": {"false"}, - // } - // suite.statusModule.statusGETHandler(ctx) - - // // check response - - // // 1. we should have OK from our call to the function - // suite.EqualValues(http.StatusOK, recorder.Code) - - // result := recorder.Result() - // defer result.Body.Close() - // b, err := ioutil.ReadAll(result.Body) - // assert.NoError(suite.T(), err) - - // statusReply := &mastomodel.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.Len(suite.T(), statusReply.Tags, 1) - // assert.Equal(suite.T(), mastomodel.Tag{ - // Name: "helloworld", - // URL: "http://localhost:8080/tags/helloworld", - // }, statusReply.Tags[0]) - - // gtsTag := &gtsmodel.Tag{} - // err = suite.db.GetWhere("name", "helloworld", gtsTag) - // assert.NoError(suite.T(), err) - // assert.Equal(suite.T(), statusReply.Account.ID, gtsTag.FirstSeenFromAccountID) -} - -func TestStatusGetTestSuite(t *testing.T) { - suite.Run(t, new(StatusGetTestSuite)) -} diff --git a/internal/apimodule/status/test/statusunfave_test.go b/internal/apimodule/status/test/statusunfave_test.go @@ -1,219 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package status - -import ( - "encoding/json" - "fmt" - "io/ioutil" - "net/http" - "net/http/httptest" - "strings" - "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/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 -} - -/* - 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() - suite.testUsers = testrig.NewTestUsers() - suite.testAccounts = testrig.NewTestAccounts() - suite.testAttachments = testrig.NewTestAttachments() - suite.testStatuses = testrig.NewTestStatuses() -} - -// TearDownTest drops tables to make sure there's no data in the db -func (suite *StatusUnfaveTestSuite) TearDownTest() { - testrig.StandardDBTeardown(suite.db) - testrig.StandardStorageTeardown(suite.storage) -} - -/* - ACTUAL TESTS -*/ - -// unfave a status -func (suite *StatusUnfaveTestSuite) TestPostUnfave() { - - t := suite.testTokens["local_account_1"] - oauthToken := oauth.TokenToOauthToken(t) - - // this is the status we wanna unfave: in the testrig it's already faved by this account - targetStatus := suite.testStatuses["admin_account_status_1"] - - // setup - recorder := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(recorder) - ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) - ctx.Set(oauth.SessionAuthorizedToken, oauthToken) - ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) - ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.UnfavouritePath, ":id", targetStatus.ID, 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: status.IDKey, - Value: targetStatus.ID, - }, - } - - suite.statusModule.StatusUnfavePOSTHandler(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) - - statusReply := &mastomodel.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.False(suite.T(), statusReply.Favourited) - assert.Equal(suite.T(), 0, statusReply.FavouritesCount) -} - -// try to unfave a status that's already not faved -func (suite *StatusUnfaveTestSuite) TestPostAlreadyNotFaved() { - - t := suite.testTokens["local_account_1"] - oauthToken := oauth.TokenToOauthToken(t) - - // this is the status we wanna unfave: in the testrig it's not faved by this account - targetStatus := suite.testStatuses["admin_account_status_2"] - - // setup - recorder := httptest.NewRecorder() - ctx, _ := gin.CreateTestContext(recorder) - ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) - ctx.Set(oauth.SessionAuthorizedToken, oauthToken) - ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) - ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.UnfavouritePath, ":id", targetStatus.ID, 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: status.IDKey, - Value: targetStatus.ID, - }, - } - - suite.statusModule.StatusUnfavePOSTHandler(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) - - statusReply := &mastomodel.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.False(suite.T(), statusReply.Favourited) - assert.Equal(suite.T(), 0, statusReply.FavouritesCount) -} - -func TestStatusUnfaveTestSuite(t *testing.T) { - suite.Run(t, new(StatusUnfaveTestSuite)) -} diff --git a/internal/cache/mock_Cache.go b/internal/cache/mock_Cache.go @@ -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 @@ -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 @@ -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 @@ -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, &gtsmodel.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, &gtsmodel.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, &gtsmodel.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 := &gtsmodel.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 := &gtsmodel.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 := &gtsmodel.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/gtsmodel/account.go b/internal/db/gtsmodel/account.go @@ -1,142 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -// Package gtsmodel contains types used *internally* by GoToSocial and added/removed/selected from the database. -// These types should never be serialized and/or sent out via public APIs, as they contain sensitive information. -// The annotation used on these structs is for handling them via the go-pg ORM (hence why they're in this db subdir). -// See here for more info on go-pg model annotations: https://pg.uptrace.dev/models/ -package gtsmodel - -import ( - "crypto/rsa" - "time" -) - -// Account represents either a local or a remote fediverse account, gotosocial or otherwise (mastodon, pleroma, etc) -type Account struct { - /* - BASIC INFO - */ - - // id of this account in the local database; the end-user will never need to know this, it's strictly internal - ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` - // Username of the account, should just be a string of [a-z0-9_]. Can be added to domain to create the full username in the form ``[username]@[domain]`` eg., ``user_96@example.org`` - Username string `pg:",notnull,unique:userdomain"` // username and domain should be unique *with* each other - // Domain of the account, will be null if this is a local account, otherwise something like ``example.org`` or ``mastodon.social``. Should be unique with username. - Domain string `pg:",unique:userdomain"` // username and domain should be unique *with* each other - - /* - ACCOUNT METADATA - */ - - // ID of the avatar as a media attachment - AvatarMediaAttachmentID string - // ID of the header as a media attachment - HeaderMediaAttachmentID 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 - Fields []Field - // A note that this account has on their profile (ie., the account's bio/description of themselves) - Note string - // Is this a memorial account, ie., has the user passed away? - Memorial bool - // This account has moved this account id in the database - MovedToAccountID string - // When was this account created? - CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` - // When was this account last updated? - UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` - // Does this account identify itself as a bot? - Bot bool - // What reason was given for signing up when this account was created? - Reason string - - /* - USER AND PRIVACY PREFERENCES - */ - - // Does this account need an approval for new followers? - Locked bool - // Should this account be shown in the instance's profile directory? - Discoverable bool - // Default post privacy for this account - Privacy Visibility - // Set posts from this account to sensitive by default? - Sensitive bool - // What language does this account post in? - Language string - - /* - ACTIVITYPUB THINGS - */ - - // What is the activitypub URI for this account discovered by webfinger? - URI string `pg:",unique"` - // At which URL can we see the user account in a web browser? - URL string `pg:",unique"` - // 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"` - // 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"` - // URL for getting the featured collection list of this account - FeaturedCollectionURL string `pg:",unique"` - // What type of activitypub actor is this account? - ActorType ActivityStreamsActor - // This account is associated with x account id - AlsoKnownAs string - - /* - CRYPTO FIELDS - */ - - // Privatekey for validating activitypub requests, will obviously only be defined for local accounts - PrivateKey *rsa.PrivateKey - // Publickey for encoding activitypub requests, will be defined for both local and remote accounts - PublicKey *rsa.PublicKey - - /* - ADMIN FIELDS - */ - - // When was this account set to have all its media shown as sensitive? - SensitizedAt time.Time `pg:"type:timestamp"` - // When was this account silenced (eg., statuses only visible to followers, not public)? - SilencedAt time.Time `pg:"type:timestamp"` - // When was this account suspended (eg., don't allow it to log in/post, don't accept media/posts from this account) - SuspendedAt time.Time `pg:"type:timestamp"` - // Should we hide this account's collections? - HideCollections bool - // id of the user that suspended this account through an admin action - SuspensionOrigin string -} - -// Field represents a key value field on an account, for things like pronouns, website, etc. -// VerifiedAt is optional, to be used only if Value is a URL to a webpage that contains the -// username of the user. -type Field struct { - Name string - Value string - VerifiedAt time.Time `pg:"type:timestamp"` -} diff --git a/internal/db/gtsmodel/emoji.go b/internal/db/gtsmodel/emoji.go @@ -1,75 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package gtsmodel - -import "time" - -// Emoji represents a custom emoji that's been uploaded through the admin UI, and is useable by instance denizens. -type Emoji struct { - // database ID of this emoji - ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull"` - // String shortcode for this emoji -- the part that's between colons. This should be lowercase a-z_ - // eg., 'blob_hug' 'purple_heart' Must be unique with domain. - Shortcode string `pg:",notnull,unique:shortcodedomain"` - // Origin domain of this emoji, eg 'example.org', 'queer.party'. empty string for local emojis. - Domain string `pg:",notnull,default:'',use_zero,unique:shortcodedomain"` - // When was this emoji created. Must be unique with shortcode. - CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` - // When was this emoji updated - UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` - // Where can this emoji be retrieved remotely? Null for local emojis. - // For remote emojis, it'll be something like: - // https://hackers.town/system/custom_emojis/images/000/049/842/original/1b74481204feabfd.png - ImageRemoteURL string - // Where can a static / non-animated version of this emoji be retrieved remotely? Null for local emojis. - // For remote emojis, it'll be something like: - // https://hackers.town/system/custom_emojis/images/000/049/842/static/1b74481204feabfd.png - ImageStaticRemoteURL string - // Where can this emoji be retrieved from the local server? Null for remote emojis. - // Assuming our server is hosted at 'example.org', this will be something like: - // 'https://example.org/fileserver/6339820e-ef65-4166-a262-5a9f46adb1a7/emoji/original/bfa6c9c5-6c25-4ea4-98b4-d78b8126fb52.png' - ImageURL string - // Where can a static version of this emoji be retrieved from the local server? Null for remote emojis. - // Assuming our server is hosted at 'example.org', this will be something like: - // 'https://example.org/fileserver/6339820e-ef65-4166-a262-5a9f46adb1a7/emoji/small/bfa6c9c5-6c25-4ea4-98b4-d78b8126fb52.png' - ImageStaticURL string - // Path of the emoji image in the server storage system. Will be something like: - // '/gotosocial/storage/6339820e-ef65-4166-a262-5a9f46adb1a7/emoji/original/bfa6c9c5-6c25-4ea4-98b4-d78b8126fb52.png' - ImagePath string `pg:",notnull"` - // Path of a static version of the emoji image in the server storage system. Will be something like: - // '/gotosocial/storage/6339820e-ef65-4166-a262-5a9f46adb1a7/emoji/small/bfa6c9c5-6c25-4ea4-98b4-d78b8126fb52.png' - ImageStaticPath string `pg:",notnull"` - // MIME content type of the emoji image - // Probably "image/png" - ImageContentType 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. - ImageStaticFileSize int `pg:",notnull"` - // When was the emoji image last updated? - ImageUpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` - // Has a moderation action disabled this emoji from being shown? - Disabled bool `pg:",notnull,default:false"` - // ActivityStreams uri of this emoji. Something like 'https://example.org/emojis/1234' - URI string `pg:",notnull,unique"` - // Is this emoji visible in the admin emoji picker? - VisibleInPicker bool `pg:",notnull,default:true"` - // In which emoji category is this emoji visible? - CategoryID string -} diff --git a/internal/db/gtsmodel/mediaattachment.go b/internal/db/gtsmodel/mediaattachment.go @@ -1,150 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package gtsmodel - -import ( - "time" -) - -// MediaAttachment represents a user-uploaded media attachment: an image/video/audio/gif that is -// somewhere in storage and that can be retrieved and served by the router. -type MediaAttachment struct { - // ID of the attachment in the database - ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` - // ID of the status to which this is attached - StatusID string - // Where can the attachment be retrieved on *this* server - URL string - // Where can the attachment be retrieved on a remote server (empty for local media) - RemoteURL string - // When was the attachment created - CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` - // When was the attachment last updated - UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` - // Type of file (image/gif/audio/video) - Type FileType `pg:",notnull"` - // Metadata about the file - FileMeta FileMeta - // To which account does this attachment belong - AccountID string `pg:",notnull"` - // Description of the attachment (for screenreaders) - Description string - // To which scheduled status does this attachment belong - ScheduledStatusID string - // What is the generated blurhash of this attachment - Blurhash string - // What is the processing status of this attachment - Processing ProcessingStatus - // metadata for the whole file - File File - // small image thumbnail derived from a larger image, video, or audio file. - Thumbnail Thumbnail - // Is this attachment being used as an avatar? - Avatar bool - // Is this attachment being used as a header? - Header bool -} - -// File refers to the metadata for the whole file -type File struct { - // What is the path of the file in storage. - Path string - // What is the MIME content type of the file. - ContentType string - // What is the size of the file in bytes. - FileSize int - // When was the file last updated. - UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` -} - -// Thumbnail refers to a small image thumbnail derived from a larger image, video, or audio file. -type Thumbnail struct { - // What is the path of the file in storage - Path string - // What is the MIME content type of the file. - ContentType string - // What is the size of the file in bytes - FileSize int - // When was the file last updated - UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` - // What is the URL of the thumbnail on the local server - URL string - // What is the remote URL of the thumbnail (empty for local media) - RemoteURL string -} - -// ProcessingStatus refers to how far along in the processing stage the attachment is. -type ProcessingStatus int - -const ( - // ProcessingStatusReceived indicates the attachment has been received and is awaiting processing. No thumbnail available yet. - ProcessingStatusReceived ProcessingStatus = 0 - // ProcessingStatusProcessing indicates the attachment is currently being processed. Thumbnail is available but full media is not. - ProcessingStatusProcessing ProcessingStatus = 1 - // ProcessingStatusProcessed indicates the attachment has been fully processed and is ready to be served. - ProcessingStatusProcessed ProcessingStatus = 2 - // ProcessingStatusError indicates something went wrong processing the attachment and it won't be tried again--these can be deleted. - ProcessingStatusError ProcessingStatus = 666 -) - -// FileType refers to the file type of the media attaachment. -type FileType string - -const ( - // FileTypeImage is for jpegs and pngs - FileTypeImage FileType = "image" - // FileTypeGif is for native gifs and soundless videos that have been converted to gifs - FileTypeGif FileType = "gif" - // FileTypeAudio is for audio-only files (no video) - FileTypeAudio FileType = "audio" - // FileTypeVideo is for files with audio + visual - FileTypeVideo FileType = "video" - // FileTypeUnknown is for unknown file types (surprise surprise!) - FileTypeUnknown FileType = "unknown" -) - -// FileMeta describes metadata about the actual contents of the file. -type FileMeta struct { - Original Original - Small Small - Focus Focus -} - -// Small can be used for a thumbnail of any media type -type Small struct { - Width int - Height int - Size int - Aspect float64 -} - -// Original can be used for original metadata for any media type -type Original struct { - Width int - Height int - Size int - Aspect float64 -} - -// Focus describes the 'center' of the image for display purposes. -// X and Y should each be between -1 and 1 -type Focus struct { - X float32 - Y float32 -} diff --git a/internal/db/mock_DB.go b/internal/db/mock_DB.go @@ -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 @@ -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 := &gtsmodel.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(&gtsmodel.Block{}). Where("account_id = ?", account1).Where("target_account_id = ?", account2). diff --git a/internal/db/pg_test.go b/internal/db/pg_test.go @@ -16,6 +16,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. */ -package db +package db_test // TODO: write tests for postgres diff --git a/internal/distributor/distributor.go b/internal/distributor/distributor.go @@ -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 <http://www.gnu.org/licenses/>. -*/ - -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 @@ -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/internal/federation/clock.go b/internal/federation/clock.go @@ -0,0 +1,42 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package federation + +import ( + "time" + + "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 @@ -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 <http://www.gnu.org/licenses/>. +*/ + +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 := &gtsmodel.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 @@ -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 <http://www.gnu.org/licenses/>. +*/ + +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/federatingprotocol.go b/internal/federation/federatingprotocol.go @@ -0,0 +1,247 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package federation + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + + "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" +) + +/* + GO FED FEDERATING PROTOCOL INTERFACE + FederatingProtocol contains behaviors an application needs to satisfy for the + full ActivityPub S2S implementation to be supported by this library. + It is only required if the client application wants to support the server-to- + server, or federating, protocol. + It is passed to the library as a dependency injection from the client + application. +*/ + +// PostInboxRequestBodyHook callback after parsing the request body for a federated request +// to the Actor's inbox. +// +// Can be used to set contextual information based on the Activity +// received. +// +// Only called if the Federated Protocol is enabled. +// +// Warning: Neither authentication nor authorization has taken place at +// this time. Doing anything beyond setting contextual information is +// strongly discouraged. +// +// If an error is returned, it is passed back to the caller of +// 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) { + 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 +// inbox. +// +// If an error is returned, it is passed back to the caller of +// PostInbox. 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) 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 := &gtsmodel.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 +// their ids are able to interact with this particular end user due to +// being blocked or other application-specific logic. +// +// If an error is returned, it is passed back to the caller of +// PostInbox. +// +// If no error is returned, but authentication or authorization fails, +// then blocked must be true and error nil. An http.StatusForbidden +// will be written in the wresponse. +// +// 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) { + // TODO + return false, nil +} + +// FederatingCallbacks returns the application logic that handles +// ActivityStreams received from federating peers. +// +// Note that certain types of callbacks will be 'wrapped' with default +// behaviors supported natively by the library. Other callbacks +// compatible with streams.TypeResolver can be specified by 'other'. +// +// For example, setting the 'Create' field in the +// FederatingWrappedCallbacks lets an application dependency inject +// additional behaviors they want to take place, including the default +// behavior supplied by this library. This is guaranteed to be compliant +// with the ActivityPub Social protocol. +// +// To override the default behavior, instead supply the function in +// 'other', which does not guarantee the application will be compliant +// with the ActivityPub Social Protocol. +// +// Applications are not expected to handle every single ActivityStreams +// type and extension. The unhandled ones are passed to DefaultCallback. +func (f *federator) FederatingCallbacks(ctx context.Context) (pub.FederatingWrappedCallbacks, []interface{}, error) { + // TODO + return pub.FederatingWrappedCallbacks{}, nil, nil +} + +// DefaultCallback is called for types that go-fed can deserialize but +// are not handled by the application's callbacks returned in the +// Callbacks method. +// +// 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 { + l := f.log.WithFields(logrus.Fields{ + "func": "DefaultCallback", + "aptype": activity.GetTypeName(), + }) + l.Debugf("received unhandle-able activity type so ignoring it") + return nil +} + +// MaxInboxForwardingRecursionDepth determines how deep to search within +// 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 { + // TODO + return 0 +} + +// MaxDeliveryRecursionDepth determines how deep to search within +// collections owned by peers when they are targeted to receive a +// delivery. +// +// Zero or negative numbers indicate infinite recursion. +func (f *federator) MaxDeliveryRecursionDepth(ctx context.Context) int { + // TODO + return 0 +} + +// FilterForwarding allows the implementation to apply business logic +// such as blocks, spam filtering, and so on to a list of potential +// Collections and OrderedCollections of recipients when inbox +// forwarding has been triggered. +// +// 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) { + // TODO + return nil, nil +} + +// GetInbox 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. +// +// AuthenticateGetInbox will be called prior to this. +// +// Always called, regardless whether the Federated Protocol or Social +// API is enabled. +func (f *federator) GetInbox(ctx context.Context, r *http.Request) (vocab.ActivityStreamsOrderedCollectionPage, error) { + // IMPLEMENTATION NOTE: For GoToSocial, we serve outboxes and inboxes through + // the CLIENT API, not through the federation API, so we just do nothing here. + return nil, nil +} diff --git a/internal/federation/federation.go b/internal/federation/federation.go @@ -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 <http://www.gnu.org/licenses/>. -*/ - -// Package federation provides ActivityPub/federation functionality for GoToSocial -package federation - -import ( - "context" - "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" -) - -// 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 - full ActivityPub S2S implementation to be supported by this library. - It is only required if the client application wants to support the server-to- - server, or federating, protocol. - It is passed to the library as a dependency injection from the client - application. -*/ - -// PostInboxRequestBodyHook callback after parsing the request body for a federated request -// to the Actor's inbox. -// -// Can be used to set contextual information based on the Activity -// received. -// -// Only called if the Federated Protocol is enabled. -// -// Warning: Neither authentication nor authorization has taken place at -// this time. Doing anything beyond setting contextual information is -// strongly discouraged. -// -// If an error is returned, it is passed back to the caller of -// 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 -} - -// AuthenticatePostInbox delegates the authentication of a POST to an -// inbox. -// -// If an error is returned, it is passed back to the caller of -// PostInbox. 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) AuthenticatePostInbox(ctx context.Context, w http.ResponseWriter, r *http.Request) (context.Context, bool, error) { - // TODO - return nil, false, nil -} - -// Blocked should determine whether to permit a set of actors given by -// their ids are able to interact with this particular end user due to -// being blocked or other application-specific logic. -// -// If an error is returned, it is passed back to the caller of -// PostInbox. -// -// If no error is returned, but authentication or authorization fails, -// then blocked must be true and error nil. An http.StatusForbidden -// will be written in the wresponse. -// -// 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) { - // TODO - return false, nil -} - -// FederatingCallbacks returns the application logic that handles -// ActivityStreams received from federating peers. -// -// Note that certain types of callbacks will be 'wrapped' with default -// behaviors supported natively by the library. Other callbacks -// compatible with streams.TypeResolver can be specified by 'other'. -// -// For example, setting the 'Create' field in the -// FederatingWrappedCallbacks lets an application dependency inject -// additional behaviors they want to take place, including the default -// behavior supplied by this library. This is guaranteed to be compliant -// with the ActivityPub Social protocol. -// -// To override the default behavior, instead supply the function in -// 'other', which does not guarantee the application will be compliant -// with the ActivityPub Social Protocol. -// -// Applications are not expected to handle every single ActivityStreams -// type and extension. The unhandled ones are passed to DefaultCallback. -func (f *Federator) FederatingCallbacks(ctx context.Context) (pub.FederatingWrappedCallbacks, []interface{}, error) { - // TODO - return pub.FederatingWrappedCallbacks{}, nil, nil -} - -// DefaultCallback is called for types that go-fed can deserialize but -// are not handled by the application's callbacks returned in the -// Callbacks method. -// -// 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 - return nil -} - -// MaxInboxForwardingRecursionDepth determines how deep to search within -// 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 { - // TODO - return 0 -} - -// MaxDeliveryRecursionDepth determines how deep to search within -// collections owned by peers when they are targeted to receive a -// delivery. -// -// Zero or negative numbers indicate infinite recursion. -func (f *Federator) MaxDeliveryRecursionDepth(ctx context.Context) int { - // TODO - return 0 -} - -// FilterForwarding allows the implementation to apply business logic -// such as blocks, spam filtering, and so on to a list of potential -// Collections and OrderedCollections of recipients when inbox -// forwarding has been triggered. -// -// 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) { - // TODO - return nil, nil -} - -// GetInbox 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. -// -// AuthenticateGetInbox will be called prior to this. -// -// 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 - 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 @@ -0,0 +1,79 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package 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 @@ -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 <http://www.gnu.org/licenses/>. +*/ + +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 @@ -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 <http://www.gnu.org/licenses/>. +*/ + +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 := &gtsmodel.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 @@ -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 @@ -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 @@ -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 diff --git a/internal/gtsmodel/account.go b/internal/gtsmodel/account.go @@ -0,0 +1,148 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +// Package gtsmodel contains types used *internally* by GoToSocial and added/removed/selected from the database. +// These types should never be serialized and/or sent out via public APIs, as they contain sensitive information. +// The annotation used on these structs is for handling them via the go-pg ORM (hence why they're in this db subdir). +// See here for more info on go-pg model annotations: https://pg.uptrace.dev/models/ +package gtsmodel + +import ( + "crypto/rsa" + "time" +) + +// Account represents either a local or a remote fediverse account, gotosocial or otherwise (mastodon, pleroma, etc) +type Account struct { + /* + BASIC INFO + */ + + // id of this account in the local database; the end-user will never need to know this, it's strictly internal + ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` + // Username of the account, should just be a string of [a-z0-9_]. Can be added to domain to create the full username in the form ``[username]@[domain]`` eg., ``user_96@example.org`` + Username string `pg:",notnull,unique:userdomain"` // username and domain should be unique *with* each other + // Domain of the account, will be null if this is a local account, otherwise something like ``example.org`` or ``mastodon.social``. Should be unique with username. + Domain string `pg:",unique:userdomain"` // username and domain should be unique *with* each other + + /* + ACCOUNT METADATA + */ + + // 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 + Fields []Field + // A note that this account has on their profile (ie., the account's bio/description of themselves) + Note string + // Is this a memorial account, ie., has the user passed away? + Memorial bool + // This account has moved this account id in the database + MovedToAccountID string + // When was this account created? + CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // When was this account last updated? + UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // Does this account identify itself as a bot? + Bot bool + // What reason was given for signing up when this account was created? + Reason string + + /* + USER AND PRIVACY PREFERENCES + */ + + // Does this account need an approval for new followers? + Locked bool + // Should this account be shown in the instance's profile directory? + Discoverable bool + // Default post privacy for this account + Privacy Visibility + // Set posts from this account to sensitive by default? + Sensitive bool + // What language does this account post in? + Language string + + /* + ACTIVITYPUB THINGS + */ + + // What is the activitypub URI for this account discovered by webfinger? + URI string `pg:",unique"` + // At which URL can we see the user account in a web browser? + URL string `pg:",unique"` + // 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 + InboxURI string `pg:",unique"` + // Address of this account's activitypub outbox + 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 + FeaturedCollectionURI string `pg:",unique"` + // What type of activitypub actor is this account? + ActorType ActivityStreamsActor + // This account is associated with x account id + AlsoKnownAs string + + /* + CRYPTO FIELDS + */ + + // Privatekey for validating activitypub requests, will obviously only be defined for local accounts + 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 + */ + + // When was this account set to have all its media shown as sensitive? + SensitizedAt time.Time `pg:"type:timestamp"` + // When was this account silenced (eg., statuses only visible to followers, not public)? + SilencedAt time.Time `pg:"type:timestamp"` + // When was this account suspended (eg., don't allow it to log in/post, don't accept media/posts from this account) + SuspendedAt time.Time `pg:"type:timestamp"` + // Should we hide this account's collections? + HideCollections bool + // id of the user that suspended this account through an admin action + SuspensionOrigin string +} + +// Field represents a key value field on an account, for things like pronouns, website, etc. +// VerifiedAt is optional, to be used only if Value is a URL to a webpage that contains the +// username of the user. +type Field struct { + Name string + Value string + VerifiedAt time.Time `pg:"type:timestamp"` +} diff --git a/internal/db/gtsmodel/activitystreams.go b/internal/gtsmodel/activitystreams.go diff --git a/internal/db/gtsmodel/application.go b/internal/gtsmodel/application.go diff --git a/internal/db/gtsmodel/block.go b/internal/gtsmodel/block.go diff --git a/internal/db/gtsmodel/domainblock.go b/internal/gtsmodel/domainblock.go diff --git a/internal/db/gtsmodel/emaildomainblock.go b/internal/gtsmodel/emaildomainblock.go diff --git a/internal/gtsmodel/emoji.go b/internal/gtsmodel/emoji.go @@ -0,0 +1,77 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package gtsmodel + +import "time" + +// Emoji represents a custom emoji that's been uploaded through the admin UI, and is useable by instance denizens. +type Emoji struct { + // database ID of this emoji + ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull"` + // String shortcode for this emoji -- the part that's between colons. This should be lowercase a-z_ + // eg., 'blob_hug' 'purple_heart' Must be unique with domain. + Shortcode string `pg:",notnull,unique:shortcodedomain"` + // Origin domain of this emoji, eg 'example.org', 'queer.party'. empty string for local emojis. + Domain string `pg:",notnull,default:'',use_zero,unique:shortcodedomain"` + // When was this emoji created. Must be unique with shortcode. + CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // When was this emoji updated + UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // Where can this emoji be retrieved remotely? Null for local emojis. + // For remote emojis, it'll be something like: + // https://hackers.town/system/custom_emojis/images/000/049/842/original/1b74481204feabfd.png + ImageRemoteURL string + // Where can a static / non-animated version of this emoji be retrieved remotely? Null for local emojis. + // For remote emojis, it'll be something like: + // https://hackers.town/system/custom_emojis/images/000/049/842/static/1b74481204feabfd.png + ImageStaticRemoteURL string + // Where can this emoji be retrieved from the local server? Null for remote emojis. + // Assuming our server is hosted at 'example.org', this will be something like: + // 'https://example.org/fileserver/6339820e-ef65-4166-a262-5a9f46adb1a7/emoji/original/bfa6c9c5-6c25-4ea4-98b4-d78b8126fb52.png' + ImageURL string + // Where can a static version of this emoji be retrieved from the local server? Null for remote emojis. + // Assuming our server is hosted at 'example.org', this will be something like: + // 'https://example.org/fileserver/6339820e-ef65-4166-a262-5a9f46adb1a7/emoji/small/bfa6c9c5-6c25-4ea4-98b4-d78b8126fb52.png' + ImageStaticURL string + // Path of the emoji image in the server storage system. Will be something like: + // '/gotosocial/storage/6339820e-ef65-4166-a262-5a9f46adb1a7/emoji/original/bfa6c9c5-6c25-4ea4-98b4-d78b8126fb52.png' + ImagePath string `pg:",notnull"` + // Path of a static version of the emoji image in the server storage system. Will be something like: + // '/gotosocial/storage/6339820e-ef65-4166-a262-5a9f46adb1a7/emoji/small/bfa6c9c5-6c25-4ea4-98b4-d78b8126fb52.png' + ImageStaticPath string `pg:",notnull"` + // 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. + ImageStaticFileSize int `pg:",notnull"` + // When was the emoji image last updated? + ImageUpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // Has a moderation action disabled this emoji from being shown? + Disabled bool `pg:",notnull,default:false"` + // ActivityStreams uri of this emoji. Something like 'https://example.org/emojis/1234' + URI string `pg:",notnull,unique"` + // Is this emoji visible in the admin emoji picker? + VisibleInPicker bool `pg:",notnull,default:true"` + // In which emoji category is this emoji visible? + CategoryID string +} diff --git a/internal/db/gtsmodel/follow.go b/internal/gtsmodel/follow.go diff --git a/internal/db/gtsmodel/followrequest.go b/internal/gtsmodel/followrequest.go diff --git a/internal/gtsmodel/mediaattachment.go b/internal/gtsmodel/mediaattachment.go @@ -0,0 +1,150 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package gtsmodel + +import ( + "time" +) + +// MediaAttachment represents a user-uploaded media attachment: an image/video/audio/gif that is +// somewhere in storage and that can be retrieved and served by the router. +type MediaAttachment struct { + // ID of the attachment in the database + ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` + // ID of the status to which this is attached + StatusID string + // Where can the attachment be retrieved on *this* server + URL string + // Where can the attachment be retrieved on a remote server (empty for local media) + RemoteURL string + // When was the attachment created + CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // When was the attachment last updated + UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // Type of file (image/gif/audio/video) + Type FileType `pg:",notnull"` + // Metadata about the file + FileMeta FileMeta + // To which account does this attachment belong + AccountID string `pg:",notnull"` + // Description of the attachment (for screenreaders) + Description string + // To which scheduled status does this attachment belong + ScheduledStatusID string + // What is the generated blurhash of this attachment + Blurhash string + // What is the processing status of this attachment + Processing ProcessingStatus + // metadata for the whole file + File File + // small image thumbnail derived from a larger image, video, or audio file. + Thumbnail Thumbnail + // Is this attachment being used as an avatar? + Avatar bool + // Is this attachment being used as a header? + Header bool +} + +// File refers to the metadata for the whole file +type File struct { + // What is the path of the file in storage. + Path string + // What is the MIME content type of the file. + ContentType string + // What is the size of the file in bytes. + FileSize int + // When was the file last updated. + UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` +} + +// Thumbnail refers to a small image thumbnail derived from a larger image, video, or audio file. +type Thumbnail struct { + // What is the path of the file in storage + Path string + // What is the MIME content type of the file. + ContentType string + // What is the size of the file in bytes + FileSize int + // When was the file last updated + UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // What is the URL of the thumbnail on the local server + URL string + // What is the remote URL of the thumbnail (empty for local media) + RemoteURL string +} + +// ProcessingStatus refers to how far along in the processing stage the attachment is. +type ProcessingStatus int + +const ( + // ProcessingStatusReceived indicates the attachment has been received and is awaiting processing. No thumbnail available yet. + ProcessingStatusReceived ProcessingStatus = 0 + // ProcessingStatusProcessing indicates the attachment is currently being processed. Thumbnail is available but full media is not. + ProcessingStatusProcessing ProcessingStatus = 1 + // ProcessingStatusProcessed indicates the attachment has been fully processed and is ready to be served. + ProcessingStatusProcessed ProcessingStatus = 2 + // ProcessingStatusError indicates something went wrong processing the attachment and it won't be tried again--these can be deleted. + ProcessingStatusError ProcessingStatus = 666 +) + +// FileType refers to the file type of the media attaachment. +type FileType string + +const ( + // FileTypeImage is for jpegs and pngs + FileTypeImage FileType = "Image" + // FileTypeGif is for native gifs and soundless videos that have been converted to gifs + FileTypeGif FileType = "Gif" + // FileTypeAudio is for audio-only files (no video) + FileTypeAudio FileType = "Audio" + // FileTypeVideo is for files with audio + visual + FileTypeVideo FileType = "Video" + // FileTypeUnknown is for unknown file types (surprise surprise!) + FileTypeUnknown FileType = "Unknown" +) + +// FileMeta describes metadata about the actual contents of the file. +type FileMeta struct { + Original Original + Small Small + Focus Focus +} + +// Small can be used for a thumbnail of any media type +type Small struct { + Width int + Height int + Size int + Aspect float64 +} + +// Original can be used for original metadata for any media type +type Original struct { + Width int + Height int + Size int + Aspect float64 +} + +// Focus describes the 'center' of the image for display purposes. +// X and Y should each be between -1 and 1 +type Focus struct { + X float32 + Y float32 +} diff --git a/internal/db/gtsmodel/mention.go b/internal/gtsmodel/mention.go diff --git a/internal/db/gtsmodel/poll.go b/internal/gtsmodel/poll.go diff --git a/internal/db/gtsmodel/status.go b/internal/gtsmodel/status.go diff --git a/internal/db/gtsmodel/statusbookmark.go b/internal/gtsmodel/statusbookmark.go diff --git a/internal/db/gtsmodel/statusfave.go b/internal/gtsmodel/statusfave.go diff --git a/internal/db/gtsmodel/statusmute.go b/internal/gtsmodel/statusmute.go diff --git a/internal/db/gtsmodel/statuspin.go b/internal/gtsmodel/statuspin.go diff --git a/internal/db/gtsmodel/tag.go b/internal/gtsmodel/tag.go diff --git a/internal/db/gtsmodel/user.go b/internal/gtsmodel/user.go diff --git a/internal/mastotypes/converter.go b/internal/mastotypes/converter.go @@ -1,544 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package mastotypes - -import ( - "fmt" - "time" - - "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/util" -) - -// 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) { - // we can build this sensitive account easily by first getting the public account.... - mastoAccount, err := c.AccountToMastoPublic(a) - if err != nil { - return nil, err - } - - // then adding the Source object to it... - - // check pending follow requests aimed at this account - fr := []gtsmodel.FollowRequest{} - if err := c.db.GetFollowRequestsForAccountID(a.ID, &fr); err != nil { - if _, ok := err.(db.ErrNoEntries); !ok { - return nil, fmt.Errorf("error getting follow requests: %s", err) - } - } - var frc int - if fr != nil { - frc = len(fr) - } - - mastoAccount.Source = &mastotypes.Source{ - Privacy: util.ParseMastoVisFromGTSVis(a.Privacy), - Sensitive: a.Sensitive, - Language: a.Language, - Note: a.Note, - Fields: mastoAccount.Fields, - FollowRequestsCount: frc, - } - - return mastoAccount, nil -} - -func (c *converter) AccountToMastoPublic(a *gtsmodel.Account) (*mastotypes.Account, error) { - // count followers - followers := []gtsmodel.Follow{} - if err := c.db.GetFollowersByAccountID(a.ID, &followers); err != nil { - if _, ok := err.(db.ErrNoEntries); !ok { - return nil, fmt.Errorf("error getting followers: %s", err) - } - } - var followersCount int - if followers != nil { - followersCount = len(followers) - } - - // count following - following := []gtsmodel.Follow{} - if err := c.db.GetFollowingByAccountID(a.ID, &following); err != nil { - if _, ok := err.(db.ErrNoEntries); !ok { - return nil, fmt.Errorf("error getting following: %s", err) - } - } - var followingCount int - if following != nil { - followingCount = len(following) - } - - // count statuses - statuses := []gtsmodel.Status{} - if err := c.db.GetStatusesByAccountID(a.ID, &statuses); err != nil { - if _, ok := err.(db.ErrNoEntries); !ok { - return nil, fmt.Errorf("error getting last statuses: %s", err) - } - } - var statusesCount int - if statuses != nil { - statusesCount = len(statuses) - } - - // check when the last status was - lastStatus := &gtsmodel.Status{} - if err := c.db.GetLastStatusForAccountID(a.ID, lastStatus); err != nil { - if _, ok := err.(db.ErrNoEntries); !ok { - return nil, fmt.Errorf("error getting last status: %s", err) - } - } - var lastStatusAt string - if lastStatus != nil { - lastStatusAt = lastStatus.CreatedAt.Format(time.RFC3339) - } - - // build the avatar and header URLs - avi := &gtsmodel.MediaAttachment{} - if err := c.db.GetAvatarForAccountID(avi, a.ID); err != nil { - if _, ok := err.(db.ErrNoEntries); !ok { - return nil, fmt.Errorf("error getting avatar: %s", err) - } - } - aviURL := avi.URL - aviURLStatic := avi.Thumbnail.URL - - header := &gtsmodel.MediaAttachment{} - if err := c.db.GetHeaderForAccountID(avi, a.ID); err != nil { - if _, ok := err.(db.ErrNoEntries); !ok { - return nil, fmt.Errorf("error getting header: %s", err) - } - } - headerURL := header.URL - headerURLStatic := header.Thumbnail.URL - - // get the fields set on this account - fields := []mastotypes.Field{} - for _, f := range a.Fields { - mField := mastotypes.Field{ - Name: f.Name, - Value: f.Value, - } - if !f.VerifiedAt.IsZero() { - mField.VerifiedAt = f.VerifiedAt.Format(time.RFC3339) - } - fields = append(fields, mField) - } - - var acct string - if a.Domain != "" { - // this is a remote user - acct = fmt.Sprintf("%s@%s", a.Username, a.Domain) - } else { - // this is a local user - acct = a.Username - } - - return &mastotypes.Account{ - ID: a.ID, - Username: a.Username, - Acct: acct, - DisplayName: a.DisplayName, - Locked: a.Locked, - Bot: a.Bot, - CreatedAt: a.CreatedAt.Format(time.RFC3339), - Note: a.Note, - URL: a.URL, - Avatar: aviURL, - AvatarStatic: aviURLStatic, - Header: headerURL, - HeaderStatic: headerURLStatic, - FollowersCount: followersCount, - FollowingCount: followingCount, - StatusesCount: statusesCount, - LastStatusAt: lastStatusAt, - Emojis: nil, // TODO: implement this - Fields: fields, - }, nil -} - -func (c *converter) AppToMastoSensitive(a *gtsmodel.Application) (*mastotypes.Application, error) { - return &mastotypes.Application{ - ID: a.ID, - Name: a.Name, - Website: a.Website, - RedirectURI: a.RedirectURI, - ClientID: a.ClientID, - ClientSecret: a.ClientSecret, - VapidKey: a.VapidKey, - }, nil -} - -func (c *converter) AppToMastoPublic(a *gtsmodel.Application) (*mastotypes.Application, error) { - return &mastotypes.Application{ - Name: a.Name, - Website: a.Website, - }, nil -} - -func (c *converter) AttachmentToMasto(a *gtsmodel.MediaAttachment) (mastotypes.Attachment, error) { - return mastotypes.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{ - 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{ - 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{ - X: a.FileMeta.Focus.X, - Y: a.FileMeta.Focus.Y, - }, - }, - Description: a.Description, - Blurhash: a.Blurhash, - }, nil -} - -func (c *converter) MentionToMasto(m *gtsmodel.Mention) (mastotypes.Mention, error) { - target := &gtsmodel.Account{} - if err := c.db.GetByID(m.TargetAccountID, target); err != nil { - return mastotypes.Mention{}, err - } - - var local bool - if target.Domain == "" { - local = true - } - - var acct string - if local { - acct = fmt.Sprintf("@%s", target.Username) - } else { - acct = fmt.Sprintf("@%s@%s", target.Username, target.Domain) - } - - return mastotypes.Mention{ - ID: target.ID, - Username: target.Username, - URL: target.URL, - Acct: acct, - }, nil -} - -func (c *converter) EmojiToMasto(e *gtsmodel.Emoji) (mastotypes.Emoji, error) { - return mastotypes.Emoji{ - Shortcode: e.Shortcode, - URL: e.ImageURL, - StaticURL: e.ImageStaticURL, - VisibleInPicker: e.VisibleInPicker, - Category: e.CategoryID, - }, nil -} - -func (c *converter) TagToMasto(t *gtsmodel.Tag) (mastotypes.Tag, error) { - tagURL := fmt.Sprintf("%s://%s/tags/%s", c.config.Protocol, c.config.Host, t.Name) - - return mastotypes.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 -} - -func (c *converter) StatusToMasto( - s *gtsmodel.Status, - targetAccount *gtsmodel.Account, - requestingAccount *gtsmodel.Account, - boostOfAccount *gtsmodel.Account, - replyToAccount *gtsmodel.Account, - reblogOfStatus *gtsmodel.Status) (*mastotypes.Status, error) { - - repliesCount, err := c.db.GetReplyCountForStatus(s) - if err != nil { - return nil, fmt.Errorf("error counting replies: %s", err) - } - - reblogsCount, err := c.db.GetReblogCountForStatus(s) - if err != nil { - return nil, fmt.Errorf("error counting reblogs: %s", err) - } - - favesCount, err := c.db.GetFaveCountForStatus(s) - if err != nil { - return nil, fmt.Errorf("error counting faves: %s", err) - } - - var faved bool - var reblogged bool - var bookmarked bool - var pinned bool - var muted bool - - // requestingAccount will be nil for public requests without auth - // But if it's not nil, we can also get information about the requestingAccount's interaction with this status - if requestingAccount != nil { - faved, err = c.db.StatusFavedBy(s, requestingAccount.ID) - if err != nil { - return nil, fmt.Errorf("error checking if requesting account has faved status: %s", err) - } - - reblogged, err = c.db.StatusRebloggedBy(s, requestingAccount.ID) - if err != nil { - return nil, fmt.Errorf("error checking if requesting account has reblogged status: %s", err) - } - - muted, err = c.db.StatusMutedBy(s, requestingAccount.ID) - if err != nil { - return nil, fmt.Errorf("error checking if requesting account has muted status: %s", err) - } - - bookmarked, err = c.db.StatusBookmarkedBy(s, requestingAccount.ID) - if err != nil { - return nil, fmt.Errorf("error checking if requesting account has bookmarked status: %s", err) - } - - pinned, err = c.db.StatusPinnedBy(s, requestingAccount.ID) - if err != nil { - return nil, fmt.Errorf("error checking if requesting account has pinned status: %s", err) - } - } - - var mastoRebloggedStatus *mastotypes.Status // TODO - - var mastoApplication *mastotypes.Application - if s.CreatedWithApplicationID != "" { - gtsApplication := &gtsmodel.Application{} - if err := c.db.GetByID(s.CreatedWithApplicationID, gtsApplication); err != nil { - return nil, fmt.Errorf("error fetching application used to create status: %s", err) - } - mastoApplication, err = c.AppToMastoPublic(gtsApplication) - if err != nil { - return nil, fmt.Errorf("error parsing application used to create status: %s", err) - } - } - - mastoTargetAccount, err := c.AccountToMastoPublic(targetAccount) - if err != nil { - return nil, fmt.Errorf("error parsing account of status author: %s", err) - } - - mastoAttachments := []mastotypes.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 { - for _, gtsAttachment := range s.GTSMediaAttachments { - mastoAttachment, err := c.AttachmentToMasto(gtsAttachment) - if err != nil { - return nil, fmt.Errorf("error converting attachment with id %s: %s", gtsAttachment.ID, err) - } - mastoAttachments = append(mastoAttachments, mastoAttachment) - } - // the status doesn't have gts attachments on it, but it does have attachment IDs - // in this case, we need to pull the gts attachments from the db to convert them into masto ones - } else { - for _, a := range s.Attachments { - gtsAttachment := &gtsmodel.MediaAttachment{} - if err := c.db.GetByID(a, gtsAttachment); err != nil { - return nil, fmt.Errorf("error getting attachment with id %s: %s", a, err) - } - mastoAttachment, err := c.AttachmentToMasto(gtsAttachment) - if err != nil { - return nil, fmt.Errorf("error converting attachment with id %s: %s", a, err) - } - mastoAttachments = append(mastoAttachments, mastoAttachment) - } - } - - mastoMentions := []mastotypes.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 { - for _, gtsMention := range s.GTSMentions { - mastoMention, err := c.MentionToMasto(gtsMention) - if err != nil { - return nil, fmt.Errorf("error converting mention with id %s: %s", gtsMention.ID, err) - } - mastoMentions = append(mastoMentions, mastoMention) - } - // the status doesn't have gts mentions on it, but it does have mention IDs - // in this case, we need to pull the gts mentions from the db to convert them into masto ones - } else { - for _, m := range s.Mentions { - gtsMention := &gtsmodel.Mention{} - if err := c.db.GetByID(m, gtsMention); err != nil { - return nil, fmt.Errorf("error getting mention with id %s: %s", m, err) - } - mastoMention, err := c.MentionToMasto(gtsMention) - if err != nil { - return nil, fmt.Errorf("error converting mention with id %s: %s", gtsMention.ID, err) - } - mastoMentions = append(mastoMentions, mastoMention) - } - } - - mastoTags := []mastotypes.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 { - for _, gtsTag := range s.GTSTags { - mastoTag, err := c.TagToMasto(gtsTag) - if err != nil { - return nil, fmt.Errorf("error converting tag with id %s: %s", gtsTag.ID, err) - } - mastoTags = append(mastoTags, mastoTag) - } - // the status doesn't have gts tags on it, but it does have tag IDs - // in this case, we need to pull the gts tags from the db to convert them into masto ones - } else { - for _, t := range s.Tags { - gtsTag := &gtsmodel.Tag{} - if err := c.db.GetByID(t, gtsTag); err != nil { - return nil, fmt.Errorf("error getting tag with id %s: %s", t, err) - } - mastoTag, err := c.TagToMasto(gtsTag) - if err != nil { - return nil, fmt.Errorf("error converting tag with id %s: %s", gtsTag.ID, err) - } - mastoTags = append(mastoTags, mastoTag) - } - } - - mastoEmojis := []mastotypes.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 { - for _, gtsEmoji := range s.GTSEmojis { - mastoEmoji, err := c.EmojiToMasto(gtsEmoji) - if err != nil { - return nil, fmt.Errorf("error converting emoji with id %s: %s", gtsEmoji.ID, err) - } - mastoEmojis = append(mastoEmojis, mastoEmoji) - } - // the status doesn't have gts emojis on it, but it does have emoji IDs - // in this case, we need to pull the gts emojis from the db to convert them into masto ones - } else { - for _, e := range s.Emojis { - gtsEmoji := &gtsmodel.Emoji{} - if err := c.db.GetByID(e, gtsEmoji); err != nil { - return nil, fmt.Errorf("error getting emoji with id %s: %s", e, err) - } - mastoEmoji, err := c.EmojiToMasto(gtsEmoji) - if err != nil { - return nil, fmt.Errorf("error converting emoji with id %s: %s", gtsEmoji.ID, err) - } - mastoEmojis = append(mastoEmojis, mastoEmoji) - } - } - - var mastoCard *mastotypes.Card - var mastoPoll *mastotypes.Poll - - return &mastotypes.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), - Language: s.Language, - URI: s.URI, - URL: s.URL, - RepliesCount: repliesCount, - ReblogsCount: reblogsCount, - FavouritesCount: favesCount, - Favourited: faved, - Reblogged: reblogged, - Muted: muted, - Bookmarked: bookmarked, - Pinned: pinned, - Content: s.Content, - Reblog: mastoRebloggedStatus, - Application: mastoApplication, - Account: mastoTargetAccount, - MediaAttachments: mastoAttachments, - Mentions: mastoMentions, - Tags: mastoTags, - Emojis: mastoEmojis, - Card: mastoCard, // TODO: implement cards - Poll: mastoPoll, // TODO: implement polls - Text: s.Text, - }, nil -} diff --git a/internal/mastotypes/mastomodel/README.md b/internal/mastotypes/mastomodel/README.md @@ -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/mastomodel/account.go b/internal/mastotypes/mastomodel/account.go @@ -1,131 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package mastotypes - -import "mime/multipart" - -// Account represents a mastodon-api Account object, as described here: https://docs.joinmastodon.org/entities/account/ -type Account struct { - // The account id - ID string `json:"id"` - // The username of the account, not including domain. - Username string `json:"username"` - // The Webfinger account URI. Equal to username for local users, or username@domain for remote users. - Acct string `json:"acct"` - // The profile's display name. - DisplayName string `json:"display_name"` - // Whether the account manually approves follow requests. - Locked bool `json:"locked"` - // Whether the account has opted into discovery features such as the profile directory. - Discoverable bool `json:"discoverable,omitempty"` - // A presentational flag. Indicates that the account may perform automated actions, may not be monitored, or identifies as a robot. - Bot bool `json:"bot"` - // When the account was created. (ISO 8601 Datetime) - CreatedAt string `json:"created_at"` - // The profile's bio / description. - Note string `json:"note"` - // The location of the user's profile page. - URL string `json:"url"` - // An image icon that is shown next to statuses and in the profile. - Avatar string `json:"avatar"` - // A static version of the avatar. Equal to avatar if its value is a static image; different if avatar is an animated GIF. - AvatarStatic string `json:"avatar_static"` - // An image banner that is shown above the profile and in profile cards. - Header string `json:"header"` - // A static version of the header. Equal to header if its value is a static image; different if header is an animated GIF. - HeaderStatic string `json:"header_static"` - // The reported followers of this profile. - FollowersCount int `json:"followers_count"` - // The reported follows of this profile. - FollowingCount int `json:"following_count"` - // How many statuses are attached to this account. - StatusesCount int `json:"statuses_count"` - // When the most recent status was posted. (ISO 8601 Datetime) - LastStatusAt string `json:"last_status_at"` - // Custom emoji entities to be used when rendering the profile. If none, an empty array will be returned. - Emojis []Emoji `json:"emojis"` - // Additional metadata attached to a profile as name-value pairs. - Fields []Field `json:"fields"` - // An extra entity returned when an account is suspended. - Suspended bool `json:"suspended,omitempty"` - // When a timed mute will expire, if applicable. (ISO 8601 Datetime) - MuteExpiresAt string `json:"mute_expires_at,omitempty"` - // An extra entity to be used with API methods to verify credentials and update credentials. - Source *Source `json:"source,omitempty"` -} - -// AccountCreateRequest represents the form submitted during a POST request to /api/v1/accounts. -// See https://docs.joinmastodon.org/methods/accounts/ -type AccountCreateRequest struct { - // Text that will be reviewed by moderators if registrations require manual approval. - Reason string `form:"reason"` - // The desired username for the account - Username string `form:"username" binding:"required"` - // The email address to be used for login - Email string `form:"email" binding:"required"` - // The password to be used for login - Password string `form:"password" binding:"required"` - // Whether the user agrees to the local rules, terms, and policies. - // These should be presented to the user in order to allow them to consent before setting this parameter to TRUE. - Agreement bool `form:"agreement" binding:"required"` - // The language of the confirmation email that will be sent - Locale string `form:"locale" binding:"required"` -} - -// UpdateCredentialsRequest represents the form submitted during a PATCH request to /api/v1/accounts/update_credentials. -// See https://docs.joinmastodon.org/methods/accounts/ -type UpdateCredentialsRequest struct { - // Whether the account should be shown in the profile directory. - Discoverable *bool `form:"discoverable"` - // Whether the account has a bot flag. - Bot *bool `form:"bot"` - // The display name to use for the profile. - DisplayName *string `form:"display_name"` - // The account bio. - Note *string `form:"note"` - // Avatar image encoded using multipart/form-data - Avatar *multipart.FileHeader `form:"avatar"` - // Header image encoded using multipart/form-data - Header *multipart.FileHeader `form:"header"` - // Whether manual approval of follow requests is required. - Locked *bool `form:"locked"` - // New Source values for this account - Source *UpdateSource `form:"source"` - // Profile metadata name and value - FieldsAttributes *[]UpdateField `form:"fields_attributes"` -} - -// UpdateSource is to be used specifically in an UpdateCredentialsRequest. -type UpdateSource struct { - // Default post privacy for authored statuses. - Privacy *string `form:"privacy"` - // Whether to mark authored statuses as sensitive by default. - Sensitive *bool `form:"sensitive"` - // Default language to use for authored statuses. (ISO 6391) - Language *string `form:"language"` -} - -// UpdateField is to be used specifically in an UpdateCredentialsRequest. -// By default, max 4 fields and 255 characters per property/value. -type UpdateField struct { - // Name of the field - Name *string `form:"name"` - // Value of the field - Value *string `form:"value"` -} diff --git a/internal/mastotypes/mastomodel/activity.go b/internal/mastotypes/mastomodel/activity.go @@ -1,31 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package mastotypes - -// Activity represents the mastodon-api Activity type. See here: https://docs.joinmastodon.org/entities/activity/ -type Activity struct { - // Midnight at the first day of the week. (UNIX Timestamp as string) - Week string `json:"week"` - // Statuses created since the week began. Integer cast to string. - Statuses string `json:"statuses"` - // User logins since the week began. Integer cast as string. - Logins string `json:"logins"` - // User registrations since the week began. Integer cast as string. - Registrations string `json:"registrations"` -} diff --git a/internal/mastotypes/mastomodel/admin.go b/internal/mastotypes/mastomodel/admin.go @@ -1,81 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package mastotypes - -// AdminAccountInfo represents the *admin* view of an account's details. See here: https://docs.joinmastodon.org/entities/admin-account/ -type AdminAccountInfo struct { - // The ID of the account in the database. - ID string `json:"id"` - // The username of the account. - Username string `json:"username"` - // The domain of the account. - Domain string `json:"domain"` - // When the account was first discovered. (ISO 8601 Datetime) - CreatedAt string `json:"created_at"` - // The email address associated with the account. - Email string `json:"email"` - // The IP address last used to login to this account. - IP string `json:"ip"` - // The locale of the account. (ISO 639 Part 1 two-letter language code) - Locale string `json:"locale"` - // Invite request text - InviteRequest string `json:"invite_request"` - // The current role of the account. - Role string `json:"role"` - // Whether the account has confirmed their email address. - Confirmed bool `json:"confirmed"` - // Whether the account is currently approved. - Approved bool `json:"approved"` - // Whether the account is currently disabled. - Disabled bool `json:"disabled"` - // Whether the account is currently silenced - Silenced bool `json:"silenced"` - // Whether the account is currently suspended. - Suspended bool `json:"suspended"` - // User-level information about the account. - Account *Account `json:"account"` - // The ID of the application that created this account. - CreatedByApplicationID string `json:"created_by_application_id,omitempty"` - // The ID of the account that invited this user - InvitedByAccountID string `json:"invited_by_account_id"` -} - -// AdminReportInfo represents the *admin* view of a report. See here: https://docs.joinmastodon.org/entities/admin-report/ -type AdminReportInfo struct { - // The ID of the report in the database. - ID string `json:"id"` - // The action taken to resolve this report. - ActionTaken string `json:"action_taken"` - // An optional reason for reporting. - Comment string `json:"comment"` - // The time the report was filed. (ISO 8601 Datetime) - CreatedAt string `json:"created_at"` - // The time of last action on this report. (ISO 8601 Datetime) - UpdatedAt string `json:"updated_at"` - // The account which filed the report. - Account *Account `json:"account"` - // The account being reported. - TargetAccount *Account `json:"target_account"` - // The account of the moderator assigned to this report. - AssignedAccount *Account `json:"assigned_account"` - // The action taken by the moderator who handled the report. - ActionTakenByAccount string `json:"action_taken_by_account"` - // Statuses attached to the report, for context. - Statuses []Status `json:"statuses"` -} diff --git a/internal/mastotypes/mastomodel/announcement.go b/internal/mastotypes/mastomodel/announcement.go @@ -1,37 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package mastotypes - -// Announcement represents an admin/moderator announcement for local users. See here: https://docs.joinmastodon.org/entities/announcement/ -type Announcement struct { - ID string `json:"id"` - Content string `json:"content"` - StartsAt string `json:"starts_at"` - EndsAt string `json:"ends_at"` - AllDay bool `json:"all_day"` - PublishedAt string `json:"published_at"` - UpdatedAt string `json:"updated_at"` - Published bool `json:"published"` - Read bool `json:"read"` - Mentions []Mention `json:"mentions"` - Statuses []Status `json:"statuses"` - Tags []Tag `json:"tags"` - Emojis []Emoji `json:"emoji"` - Reactions []AnnouncementReaction `json:"reactions"` -} diff --git a/internal/mastotypes/mastomodel/announcementreaction.go b/internal/mastotypes/mastomodel/announcementreaction.go @@ -1,33 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package mastotypes - -// AnnouncementReaction represents a user reaction to admin/moderator announcement. See here: https://docs.joinmastodon.org/entities/announcementreaction/ -type AnnouncementReaction struct { - // The emoji used for the reaction. Either a unicode emoji, or a custom emoji's shortcode. - Name string `json:"name"` - // The total number of users who have added this reaction. - Count int `json:"count"` - // Whether the authorized user has added this reaction to the announcement. - Me bool `json:"me"` - // A link to the custom emoji. - URL string `json:"url,omitempty"` - // A link to a non-animated version of the custom emoji. - StaticURL string `json:"static_url,omitempty"` -} diff --git a/internal/mastotypes/mastomodel/application.go b/internal/mastotypes/mastomodel/application.go @@ -1,55 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package mastotypes - -// 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. -// See https://docs.joinmastodon.org/methods/apps/ -type Application struct { - // The application ID in the db - ID string `json:"id,omitempty"` - // The name of your application. - Name string `json:"name"` - // The website associated with your application (url) - Website string `json:"website,omitempty"` - // Where the user should be redirected after authorization. - RedirectURI string `json:"redirect_uri,omitempty"` - // ClientID to use when obtaining an oauth token for this application (ie., in client_id parameter of https://docs.joinmastodon.org/methods/apps/) - ClientID string `json:"client_id,omitempty"` - // Client secret to use when obtaining an auth token for this application (ie., in client_secret parameter of https://docs.joinmastodon.org/methods/apps/) - ClientSecret string `json:"client_secret,omitempty"` - // Used for Push Streaming API. Returned with POST /api/v1/apps. Equivalent to https://docs.joinmastodon.org/entities/pushsubscription/#server_key - VapidKey string `json:"vapid_key,omitempty"` -} - -// ApplicationPOSTRequest 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 { - // A name for your application - ClientName string `form:"client_name" binding:"required"` - // Where the user should be redirected after authorization. - // To display the authorization code to the user instead of redirecting - // to a web page, use urn:ietf:wg:oauth:2.0:oob in this parameter. - RedirectURIs string `form:"redirect_uris" binding:"required"` - // Space separated list of scopes. If none is provided, defaults to read. - Scopes string `form:"scopes"` - // A URL to the homepage of your app - Website string `form:"website"` -} diff --git a/internal/mastotypes/mastomodel/attachment.go b/internal/mastotypes/mastomodel/attachment.go @@ -1,98 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package mastotypes - -import "mime/multipart" - -// AttachmentRequest represents the form data parameters submitted by a client during a media upload request. -// See: https://docs.joinmastodon.org/methods/statuses/media/ -type AttachmentRequest struct { - File *multipart.FileHeader `form:"file"` - Thumbnail *multipart.FileHeader `form:"thumbnail"` - Description string `form:"description"` - Focus string `form:"focus"` -} - -// Attachment represents the object returned to a client after a successful media upload request. -// See: https://docs.joinmastodon.org/methods/statuses/media/ -type Attachment struct { - // The ID of the attachment in the database. - ID string `json:"id"` - // The type of the attachment. - // unknown = unsupported or unrecognized file type. - // image = Static image. - // gifv = Looping, soundless animation. - // video = Video clip. - // audio = Audio track. - Type string `json:"type"` - // The location of the original full-size attachment. - URL string `json:"url"` - // The location of a scaled-down preview of the attachment. - PreviewURL string `json:"preview_url"` - // The location of the full-size original attachment on the remote server. - RemoteURL string `json:"remote_url,omitempty"` - // The location of a scaled-down preview of the attachment on the remote server. - PreviewRemoteURL string `json:"preview_remote_url,omitempty"` - // A shorter URL for the attachment. - TextURL string `json:"text_url,omitempty"` - // Metadata returned by Paperclip. - // May contain subtrees small and original, as well as various other top-level properties. - // More importantly, there may be another top-level focus Hash object as of 2.3.0, with coordinates can be used for smart thumbnail cropping. - // See https://docs.joinmastodon.org/methods/statuses/media/#focal-points points for more. - Meta MediaMeta `json:"meta,omitempty"` - // Alternate text that describes what is in the media attachment, to be used for the visually impaired or when media attachments do not load. - Description string `json:"description,omitempty"` - // A hash computed by the BlurHash algorithm, for generating colorful preview thumbnails when media has not been downloaded yet. - // See https://github.com/woltapp/blurhash - Blurhash string `json:"blurhash,omitempty"` -} - -// MediaMeta describes the returned media -type MediaMeta struct { - Length string `json:"length,omitempty"` - Duration float32 `json:"duration,omitempty"` - FPS uint16 `json:"fps,omitempty"` - Size string `json:"size,omitempty"` - Width int `json:"width,omitempty"` - Height int `json:"height,omitempty"` - Aspect float32 `json:"aspect,omitempty"` - AudioEncode string `json:"audio_encode,omitempty"` - AudioBitrate string `json:"audio_bitrate,omitempty"` - AudioChannels string `json:"audio_channels,omitempty"` - Original MediaDimensions `json:"original"` - Small MediaDimensions `json:"small,omitempty"` - Focus MediaFocus `json:"focus,omitempty"` -} - -// MediaFocus describes the focal point of a piece of media. It should be returned to the caller as part of MediaMeta. -type MediaFocus struct { - X float32 `json:"x"` // should be between -1 and 1 - Y float32 `json:"y"` // should be between -1 and 1 -} - -// MediaDimensions describes the physical properties of a piece of media. It should be returned to the caller as part of MediaMeta. -type MediaDimensions struct { - Width int `json:"width,omitempty"` - Height int `json:"height,omitempty"` - FrameRate string `json:"frame_rate,omitempty"` - Duration float32 `json:"duration,omitempty"` - Bitrate int `json:"bitrate,omitempty"` - Size string `json:"size,omitempty"` - Aspect float32 `json:"aspect,omitempty"` -} diff --git a/internal/mastotypes/mastomodel/card.go b/internal/mastotypes/mastomodel/card.go @@ -1,61 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package mastotypes - -// 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 { - // REQUIRED - - // Location of linked resource. - URL string `json:"url"` - // Title of linked resource. - Title string `json:"title"` - // Description of preview. - Description string `json:"description"` - // The type of the preview card. - // String (Enumerable, oneOf) - // link = Link OEmbed - // photo = Photo OEmbed - // video = Video OEmbed - // rich = iframe OEmbed. Not currently accepted, so won't show up in practice. - Type string `json:"type"` - - // OPTIONAL - - // The author of the original resource. - AuthorName string `json:"author_name"` - // A link to the author of the original resource. - AuthorURL string `json:"author_url"` - // The provider of the original resource. - ProviderName string `json:"provider_name"` - // A link to the provider of the original resource. - ProviderURL string `json:"provider_url"` - // HTML to be used for generating the preview card. - HTML string `json:"html"` - // Width of preview, in pixels. - Width int `json:"width"` - // Height of preview, in pixels. - Height int `json:"height"` - // Preview thumbnail. - Image string `json:"image"` - // Used for photo embeds, instead of custom html. - EmbedURL string `json:"embed_url"` - // A hash computed by the BlurHash algorithm, for generating colorful preview thumbnails when media has not been downloaded yet. - Blurhash string `json:"blurhash"` -} diff --git a/internal/mastotypes/mastomodel/context.go b/internal/mastotypes/mastomodel/context.go @@ -1,27 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package mastotypes - -// Context represents the tree around a given status. Used for reconstructing threads of statuses. See: https://docs.joinmastodon.org/entities/context/ -type Context struct { - // Parents in the thread. - Ancestors []Status `json:"ancestors"` - // Children in the thread. - Descendants []Status `json:"descendants"` -} diff --git a/internal/mastotypes/mastomodel/conversation.go b/internal/mastotypes/mastomodel/conversation.go @@ -1,36 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package mastotypes - -// Conversation represents a conversation with "direct message" visibility. See https://docs.joinmastodon.org/entities/conversation/ -type Conversation struct { - // REQUIRED - - // Local database ID of the conversation. - ID string `json:"id"` - // Participants in the conversation. - Accounts []Account `json:"accounts"` - // Is the conversation currently marked as unread? - Unread bool `json:"unread"` - - // OPTIONAL - - // The last status in the conversation, to be used for optional display. - LastStatus *Status `json:"last_status"` -} diff --git a/internal/mastotypes/mastomodel/emoji.go b/internal/mastotypes/mastomodel/emoji.go @@ -1,48 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package mastotypes - -import "mime/multipart" - -// Emoji represents a custom emoji. See https://docs.joinmastodon.org/entities/emoji/ -type Emoji struct { - // REQUIRED - - // The name of the custom emoji. - Shortcode string `json:"shortcode"` - // A link to the custom emoji. - URL string `json:"url"` - // A link to a static copy of the custom emoji. - StaticURL string `json:"static_url"` - // Whether this Emoji should be visible in the picker or unlisted. - VisibleInPicker bool `json:"visible_in_picker"` - - // OPTIONAL - - // Used for sorting custom emoji in the picker. - Category string `json:"category,omitempty"` -} - -// EmojiCreateRequest represents a request to create a custom emoji made through the admin API. -type EmojiCreateRequest struct { - // Desired shortcode for the emoji, without surrounding colons. This must be unique for the domain. - Shortcode string `form:"shortcode" validation:"required"` - // Image file to use for the emoji. Must be png or gif and no larger than 50kb. - Image *multipart.FileHeader `form:"image" validation:"required"` -} diff --git a/internal/mastotypes/mastomodel/error.go b/internal/mastotypes/mastomodel/error.go @@ -1,32 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package mastotypes - -// Error represents an error message returned from the API. See https://docs.joinmastodon.org/entities/error/ -type Error struct { - // REQUIRED - - // The error message. - Error string `json:"error"` - - // OPTIONAL - - // A longer description of the error, mainly provided with the OAuth API. - ErrorDescription string `json:"error_description"` -} diff --git a/internal/mastotypes/mastomodel/featuredtag.go b/internal/mastotypes/mastomodel/featuredtag.go @@ -1,33 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package mastotypes - -// FeaturedTag represents a hashtag that is featured on a profile. See https://docs.joinmastodon.org/entities/featuredtag/ -type FeaturedTag struct { - // The internal ID of the featured tag in the database. - ID string `json:"id"` - // The name of the hashtag being featured. - Name string `json:"name"` - // A link to all statuses by a user that contain this hashtag. - URL string `json:"url"` - // The number of authored statuses containing this hashtag. - StatusesCount int `json:"statuses_count"` - // The timestamp of the last authored status containing this hashtag. (ISO 8601 Datetime) - LastStatusAt string `json:"last_status_at"` -} diff --git a/internal/mastotypes/mastomodel/field.go b/internal/mastotypes/mastomodel/field.go @@ -1,33 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package mastotypes - -// Field represents a profile field as a name-value pair with optional verification. See https://docs.joinmastodon.org/entities/field/ -type Field struct { - // REQUIRED - - // The key of a given field's key-value pair. - Name string `json:"name"` - // The value associated with the name key. - Value string `json:"value"` - - // OPTIONAL - // Timestamp of when the server verified a URL value for a rel="me” link. String (ISO 8601 Datetime) if value is a verified URL - VerifiedAt string `json:"verified_at,omitempty"` -} diff --git a/internal/mastotypes/mastomodel/filter.go b/internal/mastotypes/mastomodel/filter.go @@ -1,46 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package mastotypes - -// 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: -// Define ‘word constituent character’ for your app. In the official implementation, it’s [A-Za-z0-9_] in JavaScript, and [[:word:]] in Ruby. -// Ruby uses the POSIX character class (Letter | Mark | Decimal_Number | Connector_Punctuation). -// If the phrase starts with a word character, and if the previous character before matched range is a word character, its matched range should be treated to not match. -// If the phrase ends with a word character, and if the next character after matched range is a word character, its matched range should be treated to not match. -// Please check app/javascript/mastodon/selectors/index.js and app/lib/feed_manager.rb in the Mastodon source code for more details. -type Filter struct { - // The ID of the filter in the database. - ID string `json:"id"` - // The text to be filtered. - Phrase string `json:"text"` - // The contexts in which the filter should be applied. - // Array of String (Enumerable anyOf) - // home = home timeline and lists - // notifications = notifications timeline - // public = public timelines - // thread = expanded thread of a detailed status - Context []string `json:"context"` - // Should the filter consider word boundaries? - WholeWord bool `json:"whole_word"` - // When the filter should no longer be applied (ISO 8601 Datetime), or null if the filter does not expire - ExpiresAt string `json:"expires_at,omitempty"` - // Should matching entities in home and notifications be dropped by the server? - Irreversible bool `json:"irreversible"` -} diff --git a/internal/mastotypes/mastomodel/history.go b/internal/mastotypes/mastomodel/history.go @@ -1,29 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package mastotypes - -// History represents daily usage history of a hashtag. See https://docs.joinmastodon.org/entities/history/ -type History struct { - // UNIX timestamp on midnight of the given day (string cast from integer). - Day string `json:"day"` - // The counted usage of the tag within that day (string cast from integer). - Uses string `json:"uses"` - // The total of accounts using the tag within that day (string cast from integer). - Accounts string `json:"accounts"` -} diff --git a/internal/mastotypes/mastomodel/identityproof.go b/internal/mastotypes/mastomodel/identityproof.go @@ -1,33 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package mastotypes - -// IdentityProof represents a proof from an external identity provider. See https://docs.joinmastodon.org/entities/identityproof/ -type IdentityProof struct { - // The name of the identity provider. - Provider string `json:"provider"` - // The account owner's username on the identity provider's service. - ProviderUsername string `json:"provider_username"` - // The account owner's profile URL on the identity provider. - ProfileURL string `json:"profile_url"` - // A link to a statement of identity proof, hosted by the identity provider. - ProofURL string `json:"proof_url"` - // When the identity proof was last updated. - UpdatedAt string `json:"updated_at"` -} diff --git a/internal/mastotypes/mastomodel/instance.go b/internal/mastotypes/mastomodel/instance.go @@ -1,72 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package mastotypes - -// Instance represents the software instance of Mastodon running on this domain. See https://docs.joinmastodon.org/entities/instance/ -type Instance struct { - // REQUIRED - - // The domain name of the instance. - URI string `json:"uri"` - // The title of the website. - Title string `json:"title"` - // Admin-defined description of the Mastodon site. - Description string `json:"description"` - // A shorter description defined by the admin. - ShortDescription string `json:"short_description"` - // An email that may be contacted for any inquiries. - Email string `json:"email"` - // The version of Mastodon installed on the instance. - Version string `json:"version"` - // Primary langauges of the website and its staff. - Languages []string `json:"languages"` - // Whether registrations are enabled. - Registrations bool `json:"registrations"` - // Whether registrations require moderator approval. - ApprovalRequired bool `json:"approval_required"` - // Whether invites are enabled. - InvitesEnabled bool `json:"invites_enabled"` - // URLs of interest for clients apps. - URLS *InstanceURLs `json:"urls"` - // Statistics about how much information the instance contains. - Stats *InstanceStats `json:"stats"` - - // OPTIONAL - - // Banner image for the website. - Thumbnail string `json:"thumbnail,omitempty"` - // A user that can be contacted, as an alternative to email. - ContactAccount *Account `json:"contact_account,omitempty"` -} - -// InstanceURLs represents URLs necessary for successfully connecting to the instance as a user. See https://docs.joinmastodon.org/entities/instance/ -type InstanceURLs struct { - // Websockets address for push streaming. - StreamingAPI string `json:"streaming_api"` -} - -// InstanceStats represents some public-facing stats about the instance. See https://docs.joinmastodon.org/entities/instance/ -type InstanceStats struct { - // Users registered on this instance. - UserCount int `json:"user_count"` - // Statuses authored by users on instance. - StatusCount int `json:"status_count"` - // Domains federated with this instance. - DomainCount int `json:"domain_count"` -} diff --git a/internal/mastotypes/mastomodel/list.go b/internal/mastotypes/mastomodel/list.go @@ -1,31 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package mastotypes - -// List represents a list of some users that the authenticated user follows. See https://docs.joinmastodon.org/entities/list/ -type List struct { - // The internal database ID of the list. - ID string `json:"id"` - // The user-defined title of the list. - Title string `json:"title"` - // followed = Show replies to any followed user - // list = Show replies to members of the list - // none = Show replies to no one - RepliesPolicy string `json:"replies_policy"` -} diff --git a/internal/mastotypes/mastomodel/marker.go b/internal/mastotypes/mastomodel/marker.go @@ -1,37 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package mastotypes - -// Marker represents the last read position within a user's timelines. See https://docs.joinmastodon.org/entities/marker/ -type Marker struct { - // Information about the user's position in the home timeline. - Home *TimelineMarker `json:"home"` - // Information about the user's position in their notifications. - Notifications *TimelineMarker `json:"notifications"` -} - -// TimelineMarker contains information about a user's progress through a specific timeline. See https://docs.joinmastodon.org/entities/marker/ -type TimelineMarker struct { - // The ID of the most recently viewed entity. - LastReadID string `json:"last_read_id"` - // The timestamp of when the marker was set (ISO 8601 Datetime) - UpdatedAt string `json:"updated_at"` - // Used for locking to prevent write conflicts. - Version string `json:"version"` -} diff --git a/internal/mastotypes/mastomodel/mention.go b/internal/mastotypes/mastomodel/mention.go @@ -1,31 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package mastotypes - -// Mention represents the mastodon-api mention type, as documented here: https://docs.joinmastodon.org/entities/mention/ -type Mention struct { - // The account id of the mentioned user. - ID string `json:"id"` - // The username of the mentioned user. - Username string `json:"username"` - // The location of the mentioned user's profile. - URL string `json:"url"` - // The webfinger acct: URI of the mentioned user. Equivalent to username for local users, or username@domain for remote users. - Acct string `json:"acct"` -} diff --git a/internal/mastotypes/mastomodel/notification.go b/internal/mastotypes/mastomodel/notification.go @@ -1,45 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package mastotypes - -// Notification represents a notification of an event relevant to the user. See https://docs.joinmastodon.org/entities/notification/ -type Notification struct { - // REQUIRED - - // The id of the notification in the database. - ID string `json:"id"` - // The type of event that resulted in the notification. - // follow = Someone followed you - // follow_request = Someone requested to follow you - // mention = Someone mentioned you in their status - // reblog = Someone boosted one of your statuses - // favourite = Someone favourited one of your statuses - // poll = A poll you have voted in or created has ended - // status = Someone you enabled notifications for has posted a status - Type string `json:"type"` - // The timestamp of the notification (ISO 8601 Datetime) - CreatedAt string `json:"created_at"` - // The account that performed the action that generated the notification. - Account *Account `json:"account"` - - // OPTIONAL - - // Status that was the object of the notification, e.g. in mentions, reblogs, favourites, or polls. - Status *Status `json:"status"` -} diff --git a/internal/mastotypes/mastomodel/oauth.go b/internal/mastotypes/mastomodel/oauth.go @@ -1,37 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package mastotypes - -// OAuthAuthorize represents a request sent to https://example.org/oauth/authorize -// See here: https://docs.joinmastodon.org/methods/apps/oauth/ -type OAuthAuthorize struct { - // Forces the user to re-login, which is necessary for authorizing with multiple accounts from the same instance. - ForceLogin string `form:"force_login,omitempty"` - // Should be set equal to `code`. - ResponseType string `form:"response_type"` - // Client ID, obtained during app registration. - ClientID string `form:"client_id"` - // Set a URI to redirect the user to. - // If this parameter is set to urn:ietf:wg:oauth:2.0:oob then the authorization code will be shown instead. - // Must match one of the redirect URIs declared during app registration. - RedirectURI string `form:"redirect_uri"` - // List of requested OAuth scopes, separated by spaces (or by pluses, if using query parameters). - // Must be a subset of scopes declared during app registration. If not provided, defaults to read. - Scope string `form:"scope,omitempty"` -} diff --git a/internal/mastotypes/mastomodel/poll.go b/internal/mastotypes/mastomodel/poll.go @@ -1,64 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package mastotypes - -// Poll represents the mastodon-api poll type, as described here: https://docs.joinmastodon.org/entities/poll/ -type Poll struct { - // The ID of the poll in the database. - ID string `json:"id"` - // When the poll ends. (ISO 8601 Datetime), or null if the poll does not end - ExpiresAt string `json:"expires_at"` - // Is the poll currently expired? - Expired bool `json:"expired"` - // Does the poll allow multiple-choice answers? - Multiple bool `json:"multiple"` - // How many votes have been received. - VotesCount int `json:"votes_count"` - // How many unique accounts have voted on a multiple-choice poll. Null if multiple is false. - VotersCount int `json:"voters_count,omitempty"` - // When called with a user token, has the authorized user voted? - Voted bool `json:"voted,omitempty"` - // When called with a user token, which options has the authorized user chosen? Contains an array of index values for options. - OwnVotes []int `json:"own_votes,omitempty"` - // Possible answers for the poll. - Options []PollOptions `json:"options"` - // Custom emoji to be used for rendering poll options. - Emojis []Emoji `json:"emojis"` -} - -// PollOptions represents the current vote counts for different poll options -type PollOptions struct { - // The text value of the poll option. String. - Title string `json:"title"` - // The number of received votes for this option. Number, or null if results are not published yet. - VotesCount int `json:"votes_count,omitempty"` -} - -// PollRequest represents a mastodon-api poll attached to a status POST request, as defined here: https://docs.joinmastodon.org/methods/statuses/ -// It should be used at the path https://example.org/api/v1/statuses -type PollRequest struct { - // Array of possible answers. If provided, media_ids cannot be used, and poll[expires_in] must be provided. - Options []string `form:"options"` - // Duration the poll should be open, in seconds. If provided, media_ids cannot be used, and poll[options] must be provided. - ExpiresIn int `form:"expires_in"` - // Allow multiple choices? - Multiple bool `form:"multiple"` - // Hide vote counts until the poll ends? - HideTotals bool `form:"hide_totals"` -} diff --git a/internal/mastotypes/mastomodel/preferences.go b/internal/mastotypes/mastomodel/preferences.go @@ -1,40 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package mastotypes - -// Preferences represents a user's preferences. See https://docs.joinmastodon.org/entities/preferences/ -type Preferences struct { - // Default visibility for new posts. - // public = Public post - // unlisted = Unlisted post - // private = Followers-only post - // direct = Direct post - PostingDefaultVisibility string `json:"posting:default:visibility"` - // Default sensitivity flag for new posts. - PostingDefaultSensitive bool `json:"posting:default:sensitive"` - // Default language for new posts. (ISO 639-1 language two-letter code), or null - PostingDefaultLanguage string `json:"posting:default:language,omitempty"` - // Whether media attachments should be automatically displayed or blurred/hidden. - // default = Hide media marked as sensitive - // show_all = Always show all media by default, regardless of sensitivity - // hide_all = Always hide all media by default, regardless of sensitivity - ReadingExpandMedia string `json:"reading:expand:media"` - // Whether CWs should be expanded by default. - ReadingExpandSpoilers bool `json:"reading:expand:spoilers"` -} diff --git a/internal/mastotypes/mastomodel/pushsubscription.go b/internal/mastotypes/mastomodel/pushsubscription.go @@ -1,45 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package mastotypes - -// PushSubscription represents a subscription to the push streaming server. See https://docs.joinmastodon.org/entities/pushsubscription/ -type PushSubscription struct { - // The id of the push subscription in the database. - ID string `json:"id"` - // Where push alerts will be sent to. - Endpoint string `json:"endpoint"` - // The streaming server's VAPID key. - ServerKey string `json:"server_key"` - // Which alerts should be delivered to the endpoint. - Alerts *PushSubscriptionAlerts `json:"alerts"` -} - -// PushSubscriptionAlerts represents the specific alerts that this push subscription will give. -type PushSubscriptionAlerts struct { - // Receive a push notification when someone has followed you? - Follow bool `json:"follow"` - // Receive a push notification when a status you created has been favourited by someone else? - Favourite bool `json:"favourite"` - // Receive a push notification when someone else has mentioned you in a status? - Mention bool `json:"mention"` - // Receive a push notification when a status you created has been boosted by someone else? - Reblog bool `json:"reblog"` - // Receive a push notification when a poll you voted in or created has ended? - Poll bool `json:"poll"` -} diff --git a/internal/mastotypes/mastomodel/relationship.go b/internal/mastotypes/mastomodel/relationship.go @@ -1,49 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package mastotypes - -// Relationship represents a relationship between accounts. See https://docs.joinmastodon.org/entities/relationship/ -type Relationship struct { - // The account id. - ID string `json:"id"` - // Are you following this user? - Following bool `json:"following"` - // Are you receiving this user's boosts in your home timeline? - ShowingReblogs bool `json:"showing_reblogs"` - // Have you enabled notifications for this user? - Notifying bool `json:"notifying"` - // Are you followed by this user? - FollowedBy bool `json:"followed_by"` - // Are you blocking this user? - Blocking bool `json:"blocking"` - // Is this user blocking you? - BlockedBy bool `json:"blocked_by"` - // Are you muting this user? - Muting bool `json:"muting"` - // Are you muting notifications from this user? - MutingNotifications bool `json:"muting_notifications"` - // Do you have a pending follow request for this user? - Requested bool `json:"requested"` - // Are you blocking this user's domain? - DomainBlocking bool `json:"domain_blocking"` - // Are you featuring this user on your profile? - Endorsed bool `json:"endorsed"` - // Your note on this account. - Note string `json:"note"` -} diff --git a/internal/mastotypes/mastomodel/results.go b/internal/mastotypes/mastomodel/results.go @@ -1,29 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package mastotypes - -// Results represents the results of a search. See https://docs.joinmastodon.org/entities/results/ -type Results struct { - // Accounts which match the given query - Accounts []Account `json:"accounts"` - // Statuses which match the given query - Statuses []Status `json:"statuses"` - // Hashtags which match the given query - Hashtags []Tag `json:"hashtags"` -} diff --git a/internal/mastotypes/mastomodel/scheduledstatus.go b/internal/mastotypes/mastomodel/scheduledstatus.go @@ -1,39 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package mastotypes - -// ScheduledStatus represents a status that will be published at a future scheduled date. See https://docs.joinmastodon.org/entities/scheduledstatus/ -type ScheduledStatus struct { - ID string `json:"id"` - ScheduledAt string `json:"scheduled_at"` - Params *StatusParams `json:"params"` - MediaAttachments []Attachment `json:"media_attachments"` -} - -// StatusParams represents parameters for a scheduled status. See https://docs.joinmastodon.org/entities/scheduledstatus/ -type StatusParams struct { - Text string `json:"text"` - InReplyToID string `json:"in_reply_to_id,omitempty"` - MediaIDs []string `json:"media_ids,omitempty"` - Sensitive bool `json:"sensitive,omitempty"` - SpoilerText string `json:"spoiler_text,omitempty"` - Visibility string `json:"visibility"` - ScheduledAt string `json:"scheduled_at,omitempty"` - ApplicationID string `json:"application_id"` -} diff --git a/internal/mastotypes/mastomodel/source.go b/internal/mastotypes/mastomodel/source.go @@ -1,41 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package mastotypes - -// 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. -// See https://docs.joinmastodon.org/entities/source/ -type Source struct { - // The default post privacy to be used for new statuses. - // public = Public post - // unlisted = Unlisted post - // private = Followers-only post - // direct = Direct post - Privacy Visibility `json:"privacy,omitempty"` - // Whether new statuses should be marked sensitive by default. - Sensitive bool `json:"sensitive,omitempty"` - // The default posting language for new statuses. - Language string `json:"language,omitempty"` - // Profile bio. - Note string `json:"note"` - // Metadata about the account. - Fields []Field `json:"fields"` - // The number of pending follow requests. - FollowRequestsCount int `json:"follow_requests_count,omitempty"` -} diff --git a/internal/mastotypes/mastomodel/status.go b/internal/mastotypes/mastomodel/status.go @@ -1,120 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package mastotypes - -// Status represents a mastodon-api Status type, as defined here: https://docs.joinmastodon.org/entities/status/ -type Status struct { - // ID of the status in the database. - ID string `json:"id"` - // The date when this status was created (ISO 8601 Datetime) - CreatedAt string `json:"created_at"` - // ID of the status being replied. - InReplyToID string `json:"in_reply_to_id,omitempty"` - // ID of the account being replied to. - InReplyToAccountID string `json:"in_reply_to_account_id,omitempty"` - // Is this status marked as sensitive content? - Sensitive bool `json:"sensitive"` - // Subject or summary line, below which status content is collapsed until expanded. - SpoilerText string `json:"spoiler_text,omitempty"` - // Visibility of this status. - Visibility Visibility `json:"visibility"` - // Primary language of this status. (ISO 639 Part 1 two-letter language code) - Language string `json:"language"` - // URI of the status used for federation. - URI string `json:"uri"` - // A link to the status's HTML representation. - URL string `json:"url"` - // How many replies this status has received. - RepliesCount int `json:"replies_count"` - // How many boosts this status has received. - ReblogsCount int `json:"reblogs_count"` - // How many favourites this status has received. - FavouritesCount int `json:"favourites_count"` - // Have you favourited this status? - Favourited bool `json:"favourited"` - // Have you boosted this status? - Reblogged bool `json:"reblogged"` - // Have you muted notifications for this status's conversation? - Muted bool `json:"muted"` - // Have you bookmarked this status? - Bookmarked bool `json:"bookmarked"` - // Have you pinned this status? Only appears if the status is pinnable. - Pinned bool `json:"pinned"` - // HTML-encoded status content. - Content string `json:"content"` - // The status being reblogged. - Reblog *Status `json:"reblog,omitempty"` - // The application used to post this status. - Application *Application `json:"application"` - // The account that authored this status. - Account *Account `json:"account"` - // Media that is attached to this status. - MediaAttachments []Attachment `json:"media_attachments"` - // Mentions of users within the status content. - Mentions []Mention `json:"mentions"` - // Hashtags used within the status content. - Tags []Tag `json:"tags"` - // Custom emoji to be used when rendering status content. - Emojis []Emoji `json:"emojis"` - // Preview card for links included within status content. - Card *Card `json:"card"` - // The poll attached to the status. - Poll *Poll `json:"poll"` - // Plain-text source of a status. Returned instead of content when status is deleted, - // so the user may redraft from the source text without the client having to reverse-engineer - // the original text from the HTML content. - Text string `json:"text"` -} - -// StatusCreateRequest represents a mastodon-api status POST request, as defined here: https://docs.joinmastodon.org/methods/statuses/ -// It should be used at the path https://mastodon.example/api/v1/statuses -type StatusCreateRequest struct { - // Text content of the status. If media_ids is provided, this becomes optional. Attaching a poll is optional while status is provided. - Status string `form:"status"` - // Array of Attachment ids to be attached as media. If provided, status becomes optional, and poll cannot be used. - MediaIDs []string `form:"media_ids"` - // Poll to include with this status. - Poll *PollRequest `form:"poll"` - // ID of the status being replied to, if status is a reply - InReplyToID string `form:"in_reply_to_id"` - // Mark status and attached media as sensitive? - Sensitive bool `form:"sensitive"` - // Text to be shown as a warning or subject before the actual content. Statuses are generally collapsed behind this field. - SpoilerText string `form:"spoiler_text"` - // Visibility of the posted status. Enumerable oneOf public, unlisted, private, direct. - Visibility Visibility `form:"visibility"` - // ISO 8601 Datetime at which to schedule a status. Providing this paramter will cause ScheduledStatus to be returned instead of Status. Must be at least 5 minutes in the future. - ScheduledAt string `form:"scheduled_at"` - // ISO 639 language code for this status. - Language string `form:"language"` -} - -// Visibility denotes the visibility of this status to other users -type Visibility string - -const ( - // VisibilityPublic means visible to everyone - VisibilityPublic Visibility = "public" - // VisibilityUnlisted means visible to everyone but only on home timelines or in lists - VisibilityUnlisted Visibility = "unlisted" - // VisibilityPrivate means visible to followers only - VisibilityPrivate Visibility = "private" - // VisibilityDirect means visible only to tagged recipients - VisibilityDirect Visibility = "direct" -) diff --git a/internal/mastotypes/mastomodel/tag.go b/internal/mastotypes/mastomodel/tag.go @@ -1,27 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package mastotypes - -// Tag represents a hashtag used within the content of a status. See https://docs.joinmastodon.org/entities/tag/ -type Tag struct { - // The value of the hashtag after the # sign. - Name string `json:"name"` - // A link to the hashtag on the instance. - URL string `json:"url"` -} diff --git a/internal/mastotypes/mastomodel/token.go b/internal/mastotypes/mastomodel/token.go @@ -1,31 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package mastotypes - -// Token represents an OAuth token used for authenticating with the API and performing actions.. See https://docs.joinmastodon.org/entities/token/ -type Token struct { - // An OAuth token to be used for authorization. - AccessToken string `json:"access_token"` - // The OAuth token type. Mastodon uses Bearer tokens. - TokenType string `json:"token_type"` - // The OAuth scopes granted by this token, space-separated. - Scope string `json:"scope"` - // When the token was generated. (UNIX timestamp seconds) - CreatedAt int64 `json:"created_at"` -} diff --git a/internal/mastotypes/mock_Converter.go b/internal/mastotypes/mock_Converter.go @@ -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 @@ -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 := &gtsmodel.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 := &gtsmodel.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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 := &gtsmodel.Account{} + if err := p.db.GetByID(targetAccountID, targetAccount); err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + return nil, errors.New("account not found") + } + return nil, fmt.Errorf("db error: %s", err) + } + + 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, &gtsmodel.Account{}); err != nil { + return nil, fmt.Errorf("error updating discoverable: %s", err) + } + } + + if form.Bot != nil { + if err := p.db.UpdateOneByID(authed.Account.ID, "bot", *form.Bot, &gtsmodel.Account{}); err != nil { + return nil, fmt.Errorf("error updating bot: %s", err) + } + } + + if form.DisplayName != nil { + if err := util.ValidateDisplayName(*form.DisplayName); err != nil { + return nil, err + } + if err := p.db.UpdateOneByID(authed.Account.ID, "display_name", *form.DisplayName, &gtsmodel.Account{}); err != nil { + return nil, err + } + } + + if form.Note != nil { + if err := util.ValidateNote(*form.Note); err != nil { + return nil, err + } + if err := p.db.UpdateOneByID(authed.Account.ID, "note", *form.Note, &gtsmodel.Account{}); err != nil { + return nil, err + } + } + + if form.Avatar != nil && form.Avatar.Size != 0 { + avatarInfo, err := p.updateAccountAvatar(form.Avatar, authed.Account.ID) + if err != nil { + return nil, err + } + l.Tracef("new avatar info for account %s is %+v", authed.Account.ID, avatarInfo) + } + + if form.Header != nil && form.Header.Size != 0 { + headerInfo, err := p.updateAccountHeader(form.Header, authed.Account.ID) + if err != nil { + return nil, err + } + l.Tracef("new header info for account %s is %+v", authed.Account.ID, headerInfo) + } + + if form.Locked != nil { + if err := p.db.UpdateOneByID(authed.Account.ID, "locked", *form.Locked, &gtsmodel.Account{}); err != nil { + return nil, err + } + } + + if form.Source != nil { + if form.Source.Language != nil { + if err := util.ValidateLanguage(*form.Source.Language); err != nil { + return nil, err + } + if err := p.db.UpdateOneByID(authed.Account.ID, "language", *form.Source.Language, &gtsmodel.Account{}); err != nil { + return nil, err + } + } + + if form.Source.Sensitive != nil { + if err := p.db.UpdateOneByID(authed.Account.ID, "locked", *form.Locked, &gtsmodel.Account{}); err != nil { + return nil, err + } + } + + if form.Source.Privacy != nil { + if err := util.ValidatePrivacy(*form.Source.Privacy); err != nil { + return nil, err + } + if err := p.db.UpdateOneByID(authed.Account.ID, "privacy", *form.Source.Privacy, &gtsmodel.Account{}); err != nil { + return nil, err + } + } + } + + // fetch the account with all updated values set + updatedAccount := &gtsmodel.Account{} + if err := p.db.GetByID(authed.Account.ID, updatedAccount); err != nil { + return nil, fmt.Errorf("could not fetch updated account %s: %s", authed.Account.ID, err) + } + + 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 @@ -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 @@ -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 := &gtsmodel.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 @@ -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 @@ -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 := &gtsmodel.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 := &gtsmodel.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 @@ -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 := &gtsmodel.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 := &gtsmodel.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 := &gtsmodel.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 @@ -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 <http://www.gnu.org/licenses/>. +*/ + +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 @@ -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 := &gtsmodel.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 := &gtsmodel.Status{} + repliedAccount := &gtsmodel.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 := &gtsmodel.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 @@ -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 := &gtsmodel.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 := &gtsmodel.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 = &gtsmodel.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 := &gtsmodel.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 := &gtsmodel.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 = &gtsmodel.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 := &gtsmodel.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 := &gtsmodel.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 := &gtsmodel.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 := &gtsmodel.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 = &gtsmodel.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 := &gtsmodel.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 := &gtsmodel.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 = &gtsmodel.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 @@ -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 @@ -15,7 +15,7 @@ You should have received a copy of the GNU Affero General Public License along with this program. If not, see <http://www.gnu.org/licenses/>. */ -package 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 @@ -16,6 +16,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. */ -package oauth +package oauth_test // TODO: write tests diff --git 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 -} - -// 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 +// 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) - 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 + 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(SessionAuthorizedApplication) - if ok { - parsed, ok := i.(*gtsmodel.Application) - if !ok { - return nil, errors.New("could not parse application from session context") - } - a.Application = parsed - } + srv := server.NewServer(sc, manager) + srv.SetInternalErrorHandler(func(err error) *errors.Response { + log.Errorf("internal oauth error: %s", err) + return nil + }) - 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 - } + srv.SetResponseErrorHandler(func(re *errors.Response) { + log.Errorf("internal response error: %s", re.Error) + }) - 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") + srv.SetUserAuthorizationHandler(func(w http.ResponseWriter, r *http.Request) (string, error) { + userID := r.FormValue("userid") + if userID == "" { + return "", errors.New("userid was empty") } - 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 userID, nil + }) + srv.SetClientInfoHandler(server.ClientFormHandler) + return &s{ + server: srv, + log: log, } - 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 @@ -16,6 +16,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. */ -package oauth +package oauth_test // TODO: write tests diff --git a/internal/oauth/util.go 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 @@ -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 @@ -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 <http://www.gnu.org/licenses/>. +*/ + +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 @@ -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 <http://www.gnu.org/licenses/>. +*/ + +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 @@ -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 <http://www.gnu.org/licenses/>. +*/ + +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 @@ -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 <http://www.gnu.org/licenses/>. +*/ + +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 := &gtsmodel.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 = &gtsmodel.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 @@ -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 <http://www.gnu.org/licenses/>. +*/ + +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": "<p>Developer of Mastodon and administrator of mastodon.social. I post service announcements, development updates, and personal stuff.</p>", + "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": "<a href=\"https://www.patreon.com/mastodon\" rel=\"me nofollow noopener noreferrer\" target=\"_blank\"><span class=\"invisible\">https://www.</span><span class=\"\">patreon.com/mastodon</span><span class=\"invisible\"></span></a>" + }, + { + "type": "PropertyValue", + "name": "Homepage", + "value": "<a href=\"https://zeonfederated.com\" rel=\"me nofollow noopener noreferrer\" target=\"_blank\"><span class=\"invisible\">https://</span><span class=\"\">zeonfederated.com</span><span class=\"invisible\"></span></a>" + }, + { + "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 @@ -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 <http://www.gnu.org/licenses/>. +*/ + +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 @@ -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 <http://www.gnu.org/licenses/>. +*/ + +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 @@ -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 <http://www.gnu.org/licenses/>. +*/ + +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 @@ -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 <http://www.gnu.org/licenses/>. +*/ + +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 := &gtsmodel.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 := &gtsmodel.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 @@ -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 <http://www.gnu.org/licenses/>. +*/ + +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/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go @@ -0,0 +1,505 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package typeutils + +import ( + "fmt" + "time" + + "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +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 { + return nil, err + } + + // then adding the Source object to it... + + // check pending follow requests aimed at this account + fr := []gtsmodel.FollowRequest{} + if err := c.db.GetFollowRequestsForAccountID(a.ID, &fr); err != nil { + if _, ok := err.(db.ErrNoEntries); !ok { + return nil, fmt.Errorf("error getting follow requests: %s", err) + } + } + var frc int + if fr != nil { + frc = len(fr) + } + + mastoAccount.Source = &model.Source{ + Privacy: c.VisToMasto(a.Privacy), + Sensitive: a.Sensitive, + Language: a.Language, + Note: a.Note, + Fields: mastoAccount.Fields, + FollowRequestsCount: frc, + } + + return mastoAccount, nil +} + +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 { + if _, ok := err.(db.ErrNoEntries); !ok { + return nil, fmt.Errorf("error getting followers: %s", err) + } + } + var followersCount int + if followers != nil { + followersCount = len(followers) + } + + // count following + following := []gtsmodel.Follow{} + if err := c.db.GetFollowingByAccountID(a.ID, &following); err != nil { + if _, ok := err.(db.ErrNoEntries); !ok { + return nil, fmt.Errorf("error getting following: %s", err) + } + } + var followingCount int + if following != nil { + followingCount = len(following) + } + + // count statuses + statuses := []gtsmodel.Status{} + if err := c.db.GetStatusesByAccountID(a.ID, &statuses); err != nil { + if _, ok := err.(db.ErrNoEntries); !ok { + return nil, fmt.Errorf("error getting last statuses: %s", err) + } + } + var statusesCount int + if statuses != nil { + statusesCount = len(statuses) + } + + // check when the last status was + lastStatus := &gtsmodel.Status{} + if err := c.db.GetLastStatusForAccountID(a.ID, lastStatus); err != nil { + if _, ok := err.(db.ErrNoEntries); !ok { + return nil, fmt.Errorf("error getting last status: %s", err) + } + } + var lastStatusAt string + if lastStatus != nil { + lastStatusAt = lastStatus.CreatedAt.Format(time.RFC3339) + } + + // build the avatar and header URLs + avi := &gtsmodel.MediaAttachment{} + if err := c.db.GetAvatarForAccountID(avi, a.ID); err != nil { + if _, ok := err.(db.ErrNoEntries); !ok { + return nil, fmt.Errorf("error getting avatar: %s", err) + } + } + aviURL := avi.URL + aviURLStatic := avi.Thumbnail.URL + + header := &gtsmodel.MediaAttachment{} + 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) + } + } + headerURL := header.URL + headerURLStatic := header.Thumbnail.URL + + // get the fields set on this account + fields := []model.Field{} + for _, f := range a.Fields { + mField := model.Field{ + Name: f.Name, + Value: f.Value, + } + if !f.VerifiedAt.IsZero() { + mField.VerifiedAt = f.VerifiedAt.Format(time.RFC3339) + } + fields = append(fields, mField) + } + + var acct string + if a.Domain != "" { + // this is a remote user + acct = fmt.Sprintf("%s@%s", a.Username, a.Domain) + } else { + // this is a local user + acct = a.Username + } + + return &model.Account{ + ID: a.ID, + Username: a.Username, + Acct: acct, + DisplayName: a.DisplayName, + Locked: a.Locked, + Bot: a.Bot, + CreatedAt: a.CreatedAt.Format(time.RFC3339), + Note: a.Note, + URL: a.URL, + Avatar: aviURL, + AvatarStatic: aviURLStatic, + Header: headerURL, + HeaderStatic: headerURLStatic, + FollowersCount: followersCount, + FollowingCount: followingCount, + StatusesCount: statusesCount, + LastStatusAt: lastStatusAt, + Emojis: nil, // TODO: implement this + Fields: fields, + }, nil +} + +func (c *converter) AppToMastoSensitive(a *gtsmodel.Application) (*model.Application, error) { + return &model.Application{ + ID: a.ID, + Name: a.Name, + Website: a.Website, + RedirectURI: a.RedirectURI, + ClientID: a.ClientID, + ClientSecret: a.ClientSecret, + VapidKey: a.VapidKey, + }, nil +} + +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) (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: 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: 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: model.MediaFocus{ + X: a.FileMeta.Focus.X, + Y: a.FileMeta.Focus.Y, + }, + }, + Description: a.Description, + Blurhash: a.Blurhash, + }, nil +} + +func (c *converter) MentionToMasto(m *gtsmodel.Mention) (model.Mention, error) { + target := &gtsmodel.Account{} + if err := c.db.GetByID(m.TargetAccountID, target); err != nil { + return model.Mention{}, err + } + + var local bool + if target.Domain == "" { + local = true + } + + var acct string + if local { + acct = fmt.Sprintf("@%s", target.Username) + } else { + acct = fmt.Sprintf("@%s@%s", target.Username, target.Domain) + } + + return model.Mention{ + ID: target.ID, + Username: target.Username, + URL: target.URL, + Acct: acct, + }, nil +} + +func (c *converter) EmojiToMasto(e *gtsmodel.Emoji) (model.Emoji, error) { + return model.Emoji{ + Shortcode: e.Shortcode, + URL: e.ImageURL, + StaticURL: e.ImageStaticURL, + VisibleInPicker: e.VisibleInPicker, + Category: e.CategoryID, + }, nil +} + +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 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 +} + +func (c *converter) StatusToMasto( + s *gtsmodel.Status, + targetAccount *gtsmodel.Account, + requestingAccount *gtsmodel.Account, + boostOfAccount *gtsmodel.Account, + replyToAccount *gtsmodel.Account, + reblogOfStatus *gtsmodel.Status) (*model.Status, error) { + + repliesCount, err := c.db.GetReplyCountForStatus(s) + if err != nil { + return nil, fmt.Errorf("error counting replies: %s", err) + } + + reblogsCount, err := c.db.GetReblogCountForStatus(s) + if err != nil { + return nil, fmt.Errorf("error counting reblogs: %s", err) + } + + favesCount, err := c.db.GetFaveCountForStatus(s) + if err != nil { + return nil, fmt.Errorf("error counting faves: %s", err) + } + + var faved bool + var reblogged bool + var bookmarked bool + var pinned bool + var muted bool + + // requestingAccount will be nil for public requests without auth + // But if it's not nil, we can also get information about the requestingAccount's interaction with this status + if requestingAccount != nil { + faved, err = c.db.StatusFavedBy(s, requestingAccount.ID) + if err != nil { + return nil, fmt.Errorf("error checking if requesting account has faved status: %s", err) + } + + reblogged, err = c.db.StatusRebloggedBy(s, requestingAccount.ID) + if err != nil { + return nil, fmt.Errorf("error checking if requesting account has reblogged status: %s", err) + } + + muted, err = c.db.StatusMutedBy(s, requestingAccount.ID) + if err != nil { + return nil, fmt.Errorf("error checking if requesting account has muted status: %s", err) + } + + bookmarked, err = c.db.StatusBookmarkedBy(s, requestingAccount.ID) + if err != nil { + return nil, fmt.Errorf("error checking if requesting account has bookmarked status: %s", err) + } + + pinned, err = c.db.StatusPinnedBy(s, requestingAccount.ID) + if err != nil { + return nil, fmt.Errorf("error checking if requesting account has pinned status: %s", err) + } + } + + var mastoRebloggedStatus *model.Status // TODO + + var mastoApplication *model.Application + if s.CreatedWithApplicationID != "" { + gtsApplication := &gtsmodel.Application{} + if err := c.db.GetByID(s.CreatedWithApplicationID, gtsApplication); err != nil { + return nil, fmt.Errorf("error fetching application used to create status: %s", err) + } + mastoApplication, err = c.AppToMastoPublic(gtsApplication) + if err != nil { + return nil, fmt.Errorf("error parsing application used to create status: %s", err) + } + } + + mastoTargetAccount, err := c.AccountToMastoPublic(targetAccount) + if err != nil { + return nil, fmt.Errorf("error parsing account of status author: %s", err) + } + + 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 { + for _, gtsAttachment := range s.GTSMediaAttachments { + mastoAttachment, err := c.AttachmentToMasto(gtsAttachment) + if err != nil { + return nil, fmt.Errorf("error converting attachment with id %s: %s", gtsAttachment.ID, err) + } + mastoAttachments = append(mastoAttachments, mastoAttachment) + } + // the status doesn't have gts attachments on it, but it does have attachment IDs + // in this case, we need to pull the gts attachments from the db to convert them into masto ones + } else { + for _, a := range s.Attachments { + gtsAttachment := &gtsmodel.MediaAttachment{} + if err := c.db.GetByID(a, gtsAttachment); err != nil { + return nil, fmt.Errorf("error getting attachment with id %s: %s", a, err) + } + mastoAttachment, err := c.AttachmentToMasto(gtsAttachment) + if err != nil { + return nil, fmt.Errorf("error converting attachment with id %s: %s", a, err) + } + mastoAttachments = append(mastoAttachments, mastoAttachment) + } + } + + 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 { + for _, gtsMention := range s.GTSMentions { + mastoMention, err := c.MentionToMasto(gtsMention) + if err != nil { + return nil, fmt.Errorf("error converting mention with id %s: %s", gtsMention.ID, err) + } + mastoMentions = append(mastoMentions, mastoMention) + } + // the status doesn't have gts mentions on it, but it does have mention IDs + // in this case, we need to pull the gts mentions from the db to convert them into masto ones + } else { + for _, m := range s.Mentions { + gtsMention := &gtsmodel.Mention{} + if err := c.db.GetByID(m, gtsMention); err != nil { + return nil, fmt.Errorf("error getting mention with id %s: %s", m, err) + } + mastoMention, err := c.MentionToMasto(gtsMention) + if err != nil { + return nil, fmt.Errorf("error converting mention with id %s: %s", gtsMention.ID, err) + } + mastoMentions = append(mastoMentions, mastoMention) + } + } + + 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 { + for _, gtsTag := range s.GTSTags { + mastoTag, err := c.TagToMasto(gtsTag) + if err != nil { + return nil, fmt.Errorf("error converting tag with id %s: %s", gtsTag.ID, err) + } + mastoTags = append(mastoTags, mastoTag) + } + // the status doesn't have gts tags on it, but it does have tag IDs + // in this case, we need to pull the gts tags from the db to convert them into masto ones + } else { + for _, t := range s.Tags { + gtsTag := &gtsmodel.Tag{} + if err := c.db.GetByID(t, gtsTag); err != nil { + return nil, fmt.Errorf("error getting tag with id %s: %s", t, err) + } + mastoTag, err := c.TagToMasto(gtsTag) + if err != nil { + return nil, fmt.Errorf("error converting tag with id %s: %s", gtsTag.ID, err) + } + mastoTags = append(mastoTags, mastoTag) + } + } + + 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 { + for _, gtsEmoji := range s.GTSEmojis { + mastoEmoji, err := c.EmojiToMasto(gtsEmoji) + if err != nil { + return nil, fmt.Errorf("error converting emoji with id %s: %s", gtsEmoji.ID, err) + } + mastoEmojis = append(mastoEmojis, mastoEmoji) + } + // the status doesn't have gts emojis on it, but it does have emoji IDs + // in this case, we need to pull the gts emojis from the db to convert them into masto ones + } else { + for _, e := range s.Emojis { + gtsEmoji := &gtsmodel.Emoji{} + if err := c.db.GetByID(e, gtsEmoji); err != nil { + return nil, fmt.Errorf("error getting emoji with id %s: %s", e, err) + } + mastoEmoji, err := c.EmojiToMasto(gtsEmoji) + if err != nil { + return nil, fmt.Errorf("error converting emoji with id %s: %s", gtsEmoji.ID, err) + } + mastoEmojis = append(mastoEmojis, mastoEmoji) + } + } + + var mastoCard *model.Card + var mastoPoll *model.Poll + + 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: c.VisToMasto(s.Visibility), + Language: s.Language, + URI: s.URI, + URL: s.URL, + RepliesCount: repliesCount, + ReblogsCount: reblogsCount, + FavouritesCount: favesCount, + Favourited: faved, + Reblogged: reblogged, + Muted: muted, + Bookmarked: bookmarked, + Pinned: pinned, + Content: s.Content, + Reblog: mastoRebloggedStatus, + Application: mastoApplication, + Account: mastoTargetAccount, + MediaAttachments: mastoAttachments, + Mentions: mastoMentions, + Tags: mastoTags, + Emojis: mastoEmojis, + Card: mastoCard, // TODO: implement cards + Poll: mastoPoll, // TODO: implement polls + 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 @@ -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 <http://www.gnu.org/licenses/>. -*/ - -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 @@ -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/status.go @@ -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 <http://www.gnu.org/licenses/>. -*/ - -package util - -import ( - "strings" -) - -// DeriveMentions takes a plaintext (ie., not html-formatted) status, -// and applies a regex to it to return a deduplicated list of accounts -// mentioned in that status. -// -// It will look for fully-qualified account names in the form "@user@example.org". -// or the form "@username" for local users. -// The case of the returned mentions will be lowered, for consistency. -func DeriveMentions(status string) []string { - mentionedAccounts := []string{} - for _, m := range mentionRegex.FindAllStringSubmatch(status, -1) { - mentionedAccounts = append(mentionedAccounts, m[1]) - } - return Lower(Unique(mentionedAccounts)) -} - -// DeriveHashtags takes a plaintext (ie., not html-formatted) status, -// and applies a regex to it to return a deduplicated list of hashtags -// used in that status, without the leading #. The case of the returned -// tags will be lowered, for consistency. -func DeriveHashtags(status string) []string { - tags := []string{} - for _, m := range hashtagRegex.FindAllStringSubmatch(status, -1) { - tags = append(tags, m[1]) - } - return Lower(Unique(tags)) -} - -// DeriveEmojis takes a plaintext (ie., not html-formatted) status, -// and applies a regex to it to return a deduplicated list of emojis -// used in that status, without the surround ::. The case of the returned -// emojis will be lowered, for consistency. -func DeriveEmojis(status string) []string { - emojis := []string{} - for _, m := range emojiRegex.FindAllStringSubmatch(status, -1) { - emojis = append(emojis, m[1]) - } - return Lower(Unique(emojis)) -} - -// 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 { - if _, value := keys[entry]; !value { - keys[entry] = true - list = append(list, entry) - } - } - return list -} - -// 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)) - } - return new -} - -// HTMLFormat takes a plaintext formatted status string, and converts it into -// a nice HTML-formatted string. -// -// This includes: -// - Replacing line-breaks with <p> -// - Replacing URLs with hrefs. -// - Replacing mentions with links to that account's URL as stored in the database. -func HTMLFormat(status string) string { - // TODO: write proper HTML formatting logic for a status - return status -} diff --git a/internal/util/status_test.go b/internal/util/status_test.go @@ -1,105 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package util - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/suite" -) - -type StatusTestSuite struct { - suite.Suite -} - -func (suite *StatusTestSuite) TestDeriveMentionsOK() { - statusText := `@dumpsterqueer@example.org testing testing - - is this thing on? - - @someone_else@testing.best-horse.com can you confirm? @hello@test.lgbt - - @thisisalocaluser ! @NORWILL@THIS.one!! - - here is a duplicate mention: @hello@test.lgbt - ` - - menchies := 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]) - assert.Equal(suite.T(), "@hello@test.lgbt", menchies[2]) - assert.Equal(suite.T(), "@thisisalocaluser", menchies[3]) -} - -func (suite *StatusTestSuite) TestDeriveMentionsEmpty() { - statusText := `` - menchies := DeriveMentions(statusText) - assert.Len(suite.T(), menchies, 0) -} - -func (suite *StatusTestSuite) TestDeriveHashtagsOK() { - statusText := `#testing123 #also testing - -# testing this one shouldn't work - - #thisshouldwork - -#ThisShouldAlsoWork #not_this_though - -#111111 thisalsoshouldn'twork#### ##` - - tags := DeriveHashtags(statusText) - assert.Len(suite.T(), tags, 5) - assert.Equal(suite.T(), "testing123", tags[0]) - assert.Equal(suite.T(), "also", tags[1]) - assert.Equal(suite.T(), "thisshouldwork", tags[2]) - assert.Equal(suite.T(), "thisshouldalsowork", tags[3]) - assert.Equal(suite.T(), "111111", tags[4]) -} - -func (suite *StatusTestSuite) TestDeriveEmojiOK() { - statusText := `:test: :another: - -Here's some normal text with an :emoji: at the end - -:spaces shouldnt work: - -:emoji1::emoji2: - -:anotheremoji:emoji2: -:anotheremoji::anotheremoji::anotheremoji::anotheremoji: -:underscores_ok_too: -` - - tags := DeriveEmojis(statusText) - assert.Len(suite.T(), tags, 7) - assert.Equal(suite.T(), "test", tags[0]) - assert.Equal(suite.T(), "another", tags[1]) - assert.Equal(suite.T(), "emoji", tags[2]) - assert.Equal(suite.T(), "emoji1", tags[3]) - assert.Equal(suite.T(), "emoji2", tags[4]) - assert.Equal(suite.T(), "anotheremoji", tags[5]) - assert.Equal(suite.T(), "underscores_ok_too", tags[6]) -} - -func TestStatusTestSuite(t *testing.T) { - suite.Run(t, new(StatusTestSuite)) -} diff --git a/internal/util/statustools.go b/internal/util/statustools.go @@ -0,0 +1,96 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package util + +import ( + "strings" +) + +// DeriveMentions takes a plaintext (ie., not html-formatted) status, +// and applies a regex to it to return a deduplicated list of accounts +// mentioned in that status. +// +// It will look for fully-qualified account names in the form "@user@example.org". +// or the form "@username" for local users. +// The case of the returned mentions will be lowered, for consistency. +func DeriveMentions(status string) []string { + mentionedAccounts := []string{} + for _, m := range mentionFinderRegex.FindAllStringSubmatch(status, -1) { + mentionedAccounts = append(mentionedAccounts, m[1]) + } + return lower(unique(mentionedAccounts)) +} + +// DeriveHashtags takes a plaintext (ie., not html-formatted) status, +// and applies a regex to it to return a deduplicated list of hashtags +// used in that status, without the leading #. The case of the returned +// tags will be lowered, for consistency. +func DeriveHashtags(status string) []string { + tags := []string{} + for _, m := range hashtagFinderRegex.FindAllStringSubmatch(status, -1) { + tags = append(tags, m[1]) + } + return lower(unique(tags)) +} + +// DeriveEmojis takes a plaintext (ie., not html-formatted) status, +// and applies a regex to it to return a deduplicated list of emojis +// used in that status, without the surround ::. The case of the returned +// emojis will be lowered, for consistency. +func DeriveEmojis(status string) []string { + emojis := []string{} + for _, m := range emojiFinderRegex.FindAllStringSubmatch(status, -1) { + emojis = append(emojis, m[1]) + } + return lower(unique(emojis)) +} + +// 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 { + if _, value := keys[entry]; !value { + keys[entry] = true + list = append(list, entry) + } + } + return list +} + +// 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)) + } + return new +} + +// HTMLFormat takes a plaintext formatted status string, and converts it into +// a nice HTML-formatted string. +// +// This includes: +// - Replacing line-breaks with <p> +// - Replacing URLs with hrefs. +// - Replacing mentions with links to that account's URL as stored in the database. +func HTMLFormat(status string) string { + // TODO: write proper HTML formatting logic for a status + return status +} diff --git a/internal/util/statustools_test.go b/internal/util/statustools_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 <http://www.gnu.org/licenses/>. +*/ + +package util_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +type StatusTestSuite struct { + suite.Suite +} + +func (suite *StatusTestSuite) TestDeriveMentionsOK() { + statusText := `@dumpsterqueer@example.org testing testing + + is this thing on? + + @someone_else@testing.best-horse.com can you confirm? @hello@test.lgbt + + @thisisalocaluser ! @NORWILL@THIS.one!! + + here is a duplicate mention: @hello@test.lgbt + ` + + 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]) + assert.Equal(suite.T(), "@hello@test.lgbt", menchies[2]) + assert.Equal(suite.T(), "@thisisalocaluser", menchies[3]) +} + +func (suite *StatusTestSuite) TestDeriveMentionsEmpty() { + statusText := `` + menchies := util.DeriveMentions(statusText) + assert.Len(suite.T(), menchies, 0) +} + +func (suite *StatusTestSuite) TestDeriveHashtagsOK() { + statusText := `#testing123 #also testing + +# testing this one shouldn't work + + #thisshouldwork + +#ThisShouldAlsoWork #not_this_though + +#111111 thisalsoshouldn'twork#### ##` + + tags := util.DeriveHashtags(statusText) + assert.Len(suite.T(), tags, 5) + assert.Equal(suite.T(), "testing123", tags[0]) + assert.Equal(suite.T(), "also", tags[1]) + assert.Equal(suite.T(), "thisshouldwork", tags[2]) + assert.Equal(suite.T(), "thisshouldalsowork", tags[3]) + assert.Equal(suite.T(), "111111", tags[4]) +} + +func (suite *StatusTestSuite) TestDeriveEmojiOK() { + statusText := `:test: :another: + +Here's some normal text with an :emoji: at the end + +:spaces shouldnt work: + +:emoji1::emoji2: + +:anotheremoji:emoji2: +:anotheremoji::anotheremoji::anotheremoji::anotheremoji: +:underscores_ok_too: +` + + tags := util.DeriveEmojis(statusText) + assert.Len(suite.T(), tags, 7) + assert.Equal(suite.T(), "test", tags[0]) + assert.Equal(suite.T(), "another", tags[1]) + assert.Equal(suite.T(), "emoji", tags[2]) + assert.Equal(suite.T(), "emoji1", tags[3]) + assert.Equal(suite.T(), "emoji2", tags[4]) + assert.Equal(suite.T(), "anotheremoji", tags[5]) + assert.Equal(suite.T(), "underscores_ok_too", tags[6]) +} + +func TestStatusTestSuite(t *testing.T) { + suite.Run(t, new(StatusTestSuite)) +} diff --git a/internal/util/uri.go 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 <http://www.gnu.org/licenses/>. +*/ + +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 @@ -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 @@ -16,7 +16,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. */ -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 @@ -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 @@ -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/distributor.go b/testrig/distributor.go @@ -1,26 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package testrig - -import "github.com/superseriousbusiness/gotosocial/internal/distributor" - -// NewTestDistributor returns a Distributor suitable for testing purposes -func NewTestDistributor() distributor.Distributor { - return distributor.New(NewTestLog()) -} diff --git a/testrig/federator.go 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 <http://www.gnu.org/licenses/>. +*/ + +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/mastoconverter.go b/testrig/mastoconverter.go @@ -1,29 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package testrig - -import ( - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/mastotypes" -) - -// 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) -} diff --git a/testrig/media/test-jpeg.jpg b/testrig/media/test-jpeg.jpg Binary files differ. diff --git a/testrig/processor.go 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 <http://www.gnu.org/licenses/>. +*/ + +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 @@ -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 @@ -0,0 +1,73 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package 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/typeconverter.go b/testrig/typeconverter.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 <http://www.gnu.org/licenses/>. +*/ + +package testrig + +import ( + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" +) + +// 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 @@ -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 +}