gtsocial-umbx

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

commit 941893a774c83802afdc4cc76e1d30c59b6c5585
parent 560ff1209dae89ba7b916d3808c54b81ecc82410
Author: tobi <31960611+tsmethurst@users.noreply.github.com>
Date:   Mon,  2 Jan 2023 13:10:50 +0100

[chore] The Big Middleware and API Refactor (tm) (#1250)

* interim commit: start refactoring middlewares into package under router

* another interim commit, this is becoming a big job

* another fucking massive interim commit

* refactor bookmarks to new style

* ambassador, wiz zeze commits you are spoiling uz

* she compiles, we're getting there

* we're just normal men; we're just innocent men

* apiutil

* whoopsie

* i'm glad noone reads commit msgs haha :blob_sweat:

* use that weirdo go-bytesize library for maxMultipartMemory

* fix media module paths
Diffstat:
MCONTRIBUTING.md | 4+++-
Mcmd/gotosocial/action/server/server.go | 159+++++++++++++++++++++++++++++++------------------------------------------------
Mcmd/gotosocial/action/testrig/testrig.go | 148+++++++++++++++++++++++++++++++------------------------------------------------
Mdocs/api/ratelimiting.md | 12+++++++++---
Mdocs/api/swagger.yaml | 10+++++-----
Mexample/config.yaml | 12+++++++-----
Ainternal/api/activitypub.go | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/activitypub/emoji/emoji.go | 47+++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/activitypub/emoji/emojiget.go | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/activitypub/emoji/emojiget_test.go | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/activitypub/users/common.go | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/activitypub/users/followers.go | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/activitypub/users/following.go | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/activitypub/users/inboxpost.go | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/activitypub/users/inboxpost_test.go | 500+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/activitypub/users/outboxget.go | 145+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/activitypub/users/outboxget_test.go | 216+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/activitypub/users/publickeyget.go | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/activitypub/users/repliesget.go | 166+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/activitypub/users/repliesget_test.go | 239+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/activitypub/users/statusget.go | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/activitypub/users/statusget_test.go | 162+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/activitypub/users/user.go | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/activitypub/users/user_test.go | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/activitypub/users/userget.go | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/activitypub/users/userget_test.go | 177+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dinternal/api/apimodule.go | 37-------------------------------------
Ainternal/api/auth.go | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/auth/auth.go | 117+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/auth/auth_test.go | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/auth/authorize.go | 335+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/auth/authorize_test.go | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/auth/callback.go | 317+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/auth/oob.go | 113+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/auth/signin.go | 145+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/auth/token.go | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rinternal/api/client/auth/token_test.go -> internal/api/auth/token_test.go | 0
Ainternal/api/client.go | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dinternal/api/client/account/account.go | 141-------------------------------------------------------------------------------
Dinternal/api/client/account/account_test.go | 127-------------------------------------------------------------------------------
Dinternal/api/client/account/accountcreate.go | 150-------------------------------------------------------------------------------
Dinternal/api/client/account/accountcreate_test.go | 19-------------------
Dinternal/api/client/account/accountdelete.go | 95-------------------------------------------------------------------------------
Dinternal/api/client/account/accountdelete_test.go | 101-------------------------------------------------------------------------------
Dinternal/api/client/account/accountget.go | 95-------------------------------------------------------------------------------
Dinternal/api/client/account/accountupdate.go | 216-------------------------------------------------------------------------------
Dinternal/api/client/account/accountupdate_test.go | 452-------------------------------------------------------------------------------
Dinternal/api/client/account/accountverify.go | 78------------------------------------------------------------------------------
Dinternal/api/client/account/accountverify_test.go | 91-------------------------------------------------------------------------------
Dinternal/api/client/account/block.go | 95-------------------------------------------------------------------------------
Dinternal/api/client/account/block_test.go | 74--------------------------------------------------------------------------
Dinternal/api/client/account/follow.go | 124-------------------------------------------------------------------------------
Dinternal/api/client/account/follow_test.go | 75---------------------------------------------------------------------------
Dinternal/api/client/account/followers.go | 98-------------------------------------------------------------------------------
Dinternal/api/client/account/following.go | 98-------------------------------------------------------------------------------
Dinternal/api/client/account/relationships.go | 93-------------------------------------------------------------------------------
Dinternal/api/client/account/statuses.go | 246-------------------------------------------------------------------------------
Dinternal/api/client/account/statuses_test.go | 123-------------------------------------------------------------------------------
Dinternal/api/client/account/unblock.go | 96-------------------------------------------------------------------------------
Dinternal/api/client/account/unfollow.go | 96-------------------------------------------------------------------------------
Ainternal/api/client/accounts/account_test.go | 127+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/accounts/accountcreate.go | 150+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/accounts/accountcreate_test.go | 19+++++++++++++++++++
Ainternal/api/client/accounts/accountdelete.go | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/accounts/accountdelete_test.go | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/accounts/accountget.go | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/accounts/accounts.go | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/accounts/accountupdate.go | 216+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/accounts/accountupdate_test.go | 452+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/accounts/accountverify.go | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/accounts/accountverify_test.go | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/accounts/block.go | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/accounts/block_test.go | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/accounts/follow.go | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/accounts/follow_test.go | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/accounts/followers.go | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/accounts/following.go | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/accounts/relationships.go | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/accounts/statuses.go | 246+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/accounts/statuses_test.go | 123+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/accounts/unblock.go | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/accounts/unfollow.go | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/api/client/admin/accountaction.go | 18+++++++++---------
Minternal/api/client/admin/admin.go | 41++++++++++++++++++-----------------------
Minternal/api/client/admin/admin_test.go | 2+-
Minternal/api/client/admin/domainblockcreate.go | 26+++++++++++++-------------
Minternal/api/client/admin/domainblockdelete.go | 14+++++++-------
Minternal/api/client/admin/domainblockget.go | 16++++++++--------
Minternal/api/client/admin/domainblocksget.go | 14+++++++-------
Minternal/api/client/admin/emojicategoriesget.go | 12++++++------
Minternal/api/client/admin/emojicreate.go | 22+++++++++++-----------
Minternal/api/client/admin/emojidelete.go | 14+++++++-------
Minternal/api/client/admin/emojiget.go | 14+++++++-------
Minternal/api/client/admin/emojisget.go | 16++++++++--------
Minternal/api/client/admin/emojiupdate.go | 36++++++++++++++++++------------------
Minternal/api/client/admin/mediacleanup.go | 14+++++++-------
Minternal/api/client/admin/mediarefetch.go | 8++++----
Dinternal/api/client/app/app.go | 48------------------------------------------------
Dinternal/api/client/app/app_test.go | 21---------------------
Dinternal/api/client/app/appcreate.go | 126-------------------------------------------------------------------------------
Ainternal/api/client/apps/appcreate.go | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/apps/apps.go | 43+++++++++++++++++++++++++++++++++++++++++++
Dinternal/api/client/auth/auth.go | 105-------------------------------------------------------------------------------
Dinternal/api/client/auth/auth_test.go | 139-------------------------------------------------------------------------------
Dinternal/api/client/auth/authorize.go | 335-------------------------------------------------------------------------------
Dinternal/api/client/auth/authorize_test.go | 118-------------------------------------------------------------------------------
Dinternal/api/client/auth/callback.go | 311-------------------------------------------------------------------------------
Dinternal/api/client/auth/oob.go | 111-------------------------------------------------------------------------------
Dinternal/api/client/auth/signin.go | 145-------------------------------------------------------------------------------
Dinternal/api/client/auth/token.go | 115-------------------------------------------------------------------------------
Dinternal/api/client/auth/util.go | 31-------------------------------
Minternal/api/client/blocks/blocks.go | 17++++++-----------
Minternal/api/client/blocks/blocksget.go | 12++++++------
Minternal/api/client/bookmarks/bookmarks.go | 13++++---------
Minternal/api/client/bookmarks/bookmarks_test.go | 10+++++-----
Minternal/api/client/bookmarks/bookmarksget.go | 14+++++++-------
Ainternal/api/client/customemojis/customemojis.go | 45+++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/customemojis/customemojisget.go | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dinternal/api/client/emoji/emoji.go | 50--------------------------------------------------
Dinternal/api/client/emoji/emojisget.go | 58----------------------------------------------------------
Minternal/api/client/favourites/favourites.go | 17++++++-----------
Minternal/api/client/favourites/favourites_test.go | 2+-
Minternal/api/client/favourites/favouritesget.go | 12++++++------
Dinternal/api/client/fileserver/fileserver.go | 64----------------------------------------------------------------
Dinternal/api/client/fileserver/fileserver_test.go | 109-------------------------------------------------------------------------------
Dinternal/api/client/fileserver/servefile.go | 135-------------------------------------------------------------------------------
Dinternal/api/client/fileserver/servefile_test.go | 272-------------------------------------------------------------------------------
Dinternal/api/client/filter/filter.go | 50--------------------------------------------------
Dinternal/api/client/filter/filtersget.go | 25-------------------------
Ainternal/api/client/filters/filter.go | 45+++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/filters/filtersget.go | 25+++++++++++++++++++++++++
Dinternal/api/client/followrequest/authorize.go | 98-------------------------------------------------------------------------------
Dinternal/api/client/followrequest/authorize_test.go | 115-------------------------------------------------------------------------------
Dinternal/api/client/followrequest/followrequest.go | 61-------------------------------------------------------------
Dinternal/api/client/followrequest/followrequest_test.go | 122-------------------------------------------------------------------------------
Dinternal/api/client/followrequest/get.go | 93-------------------------------------------------------------------------------
Dinternal/api/client/followrequest/get_test.go | 78------------------------------------------------------------------------------
Dinternal/api/client/followrequest/reject.go | 96-------------------------------------------------------------------------------
Dinternal/api/client/followrequest/reject_test.go | 87-------------------------------------------------------------------------------
Ainternal/api/client/followrequests/authorize.go | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/followrequests/authorize_test.go | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/followrequests/followrequest.go | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/followrequests/followrequest_test.go | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/followrequests/get.go | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/followrequests/get_test.go | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/followrequests/reject.go | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/followrequests/reject_test.go | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/api/client/instance/instance.go | 21++++++++-------------
Minternal/api/client/instance/instance_test.go | 2+-
Minternal/api/client/instance/instanceget.go | 8++++----
Minternal/api/client/instance/instancepatch.go | 22+++++++++++-----------
Minternal/api/client/instance/instancepeersget.go | 12++++++------
Dinternal/api/client/list/list.go | 50--------------------------------------------------
Dinternal/api/client/list/listsgets.go | 25-------------------------
Ainternal/api/client/lists/list.go | 45+++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/lists/listsgets.go | 44++++++++++++++++++++++++++++++++++++++++++++
Minternal/api/client/media/media.go | 27++++++++++++---------------
Minternal/api/client/media/mediacreate.go | 34+++++++++++++++++-----------------
Minternal/api/client/media/mediacreate_test.go | 53++++++++++++++++-------------------------------------
Minternal/api/client/media/mediaget.go | 18++++++++++++------
Minternal/api/client/media/mediaupdate.go | 28+++++++++++++++++-----------
Minternal/api/client/media/mediaupdate_test.go | 31+++++++++++--------------------
Dinternal/api/client/notification/notification.go | 66------------------------------------------------------------------
Dinternal/api/client/notification/notificationsclear.go | 80-------------------------------------------------------------------------------
Dinternal/api/client/notification/notificationsget.go | 159-------------------------------------------------------------------------------
Ainternal/api/client/notifications/notifications.go | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/notifications/notificationsclear.go | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/notifications/notificationsget.go | 159+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/api/client/search/search.go | 23+++++++++--------------
Minternal/api/client/search/search_test.go | 2+-
Minternal/api/client/search/searchget.go | 26+++++++++++++-------------
Dinternal/api/client/status/status.go | 123-------------------------------------------------------------------------------
Dinternal/api/client/status/status_test.go | 98-------------------------------------------------------------------------------
Dinternal/api/client/status/statusbookmark.go | 98-------------------------------------------------------------------------------
Dinternal/api/client/status/statusbookmark_test.go | 83-------------------------------------------------------------------------------
Dinternal/api/client/status/statusboost.go | 101-------------------------------------------------------------------------------
Dinternal/api/client/status/statusboost_test.go | 247-------------------------------------------------------------------------------
Dinternal/api/client/status/statusboostedby.go | 89-------------------------------------------------------------------------------
Dinternal/api/client/status/statusboostedby_test.go | 112-------------------------------------------------------------------------------
Dinternal/api/client/status/statuscontext.go | 100-------------------------------------------------------------------------------
Dinternal/api/client/status/statuscreate.go | 172-------------------------------------------------------------------------------
Dinternal/api/client/status/statuscreate_test.go | 398-------------------------------------------------------------------------------
Dinternal/api/client/status/statusdelete.go | 100-------------------------------------------------------------------------------
Dinternal/api/client/status/statusdelete_test.go | 91-------------------------------------------------------------------------------
Dinternal/api/client/status/statusfave.go | 97-------------------------------------------------------------------------------
Dinternal/api/client/status/statusfave_test.go | 131-------------------------------------------------------------------------------
Dinternal/api/client/status/statusfavedby.go | 98-------------------------------------------------------------------------------
Dinternal/api/client/status/statusfavedby_test.go | 88-------------------------------------------------------------------------------
Dinternal/api/client/status/statusget.go | 97-------------------------------------------------------------------------------
Dinternal/api/client/status/statusget_test.go | 33---------------------------------
Dinternal/api/client/status/statusunbookmark.go | 98-------------------------------------------------------------------------------
Dinternal/api/client/status/statusunbookmark_test.go | 78------------------------------------------------------------------------------
Dinternal/api/client/status/statusunboost.go | 98-------------------------------------------------------------------------------
Dinternal/api/client/status/statusunfave.go | 97-------------------------------------------------------------------------------
Dinternal/api/client/status/statusunfave_test.go | 143-------------------------------------------------------------------------------
Ainternal/api/client/statuses/status.go | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/statuses/status_test.go | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/statuses/statusbookmark.go | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/statuses/statusbookmark_test.go | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/statuses/statusboost.go | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/statuses/statusboost_test.go | 247+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/statuses/statusboostedby.go | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/statuses/statusboostedby_test.go | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/statuses/statuscontext.go | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/statuses/statuscreate.go | 172+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/statuses/statuscreate_test.go | 398+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/statuses/statusdelete.go | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/statuses/statusdelete_test.go | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/statuses/statusfave.go | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/statuses/statusfave_test.go | 132+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/statuses/statusfavedby.go | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/statuses/statusfavedby_test.go | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/statuses/statusget.go | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/statuses/statusget_test.go | 33+++++++++++++++++++++++++++++++++
Ainternal/api/client/statuses/statusunbookmark.go | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/statuses/statusunbookmark_test.go | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/statuses/statusunboost.go | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/statuses/statusunfave.go | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/statuses/statusunfave_test.go | 143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/api/client/streaming/stream.go | 60+++++++++++++++++++++++++++++++++++++++++++-----------------
Minternal/api/client/streaming/streaming.go | 19+++++++------------
Minternal/api/client/streaming/streaming_test.go | 2+-
Dinternal/api/client/timeline/home.go | 176-------------------------------------------------------------------------------
Dinternal/api/client/timeline/public.go | 187-------------------------------------------------------------------------------
Dinternal/api/client/timeline/timeline.go | 65-----------------------------------------------------------------
Ainternal/api/client/timelines/home.go | 176+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/timelines/public.go | 187+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/timelines/timeline.go | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/api/client/user/passwordchange.go | 20++++++++++----------
Minternal/api/client/user/user.go | 17++++++-----------
Minternal/api/client/user/user_test.go | 2+-
Dinternal/api/errorhandling.go | 132-------------------------------------------------------------------------------
Ainternal/api/fileserver.go | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/fileserver/fileserver.go | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/fileserver/fileserver_test.go | 109+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/fileserver/servefile.go | 130+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/fileserver/servefile_test.go | 272+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dinternal/api/mime.go | 36------------------------------------
Dinternal/api/negotiate.go | 104-------------------------------------------------------------------------------
Ainternal/api/nodeinfo.go | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/nodeinfo/nodeinfo.go | 46++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/nodeinfo/nodeinfoget.go | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dinternal/api/s2s/emoji/emoji.go | 53-----------------------------------------------------
Dinternal/api/s2s/emoji/emojiget.go | 74--------------------------------------------------------------------------
Dinternal/api/s2s/emoji/emojiget_test.go | 138-------------------------------------------------------------------------------
Dinternal/api/s2s/nodeinfo/nodeinfo.go | 53-----------------------------------------------------
Dinternal/api/s2s/nodeinfo/nodeinfoget.go | 66------------------------------------------------------------------
Dinternal/api/s2s/nodeinfo/wellknownget.go | 60------------------------------------------------------------
Dinternal/api/s2s/user/common.go | 81-------------------------------------------------------------------------------
Dinternal/api/s2s/user/followers.go | 67-------------------------------------------------------------------
Dinternal/api/s2s/user/following.go | 67-------------------------------------------------------------------
Dinternal/api/s2s/user/inboxpost.go | 51---------------------------------------------------
Dinternal/api/s2s/user/inboxpost_test.go | 500-------------------------------------------------------------------------------
Dinternal/api/s2s/user/outboxget.go | 145-------------------------------------------------------------------------------
Dinternal/api/s2s/user/outboxget_test.go | 216-------------------------------------------------------------------------------
Dinternal/api/s2s/user/publickeyget.go | 71-----------------------------------------------------------------------
Dinternal/api/s2s/user/repliesget.go | 166-------------------------------------------------------------------------------
Dinternal/api/s2s/user/repliesget_test.go | 239-------------------------------------------------------------------------------
Dinternal/api/s2s/user/statusget.go | 75---------------------------------------------------------------------------
Dinternal/api/s2s/user/statusget_test.go | 162-------------------------------------------------------------------------------
Dinternal/api/s2s/user/user.go | 89-------------------------------------------------------------------------------
Dinternal/api/s2s/user/user_test.go | 103-------------------------------------------------------------------------------
Dinternal/api/s2s/user/userget.go | 75---------------------------------------------------------------------------
Dinternal/api/s2s/user/userget_test.go | 177-------------------------------------------------------------------------------
Dinternal/api/s2s/webfinger/webfinger.go | 50--------------------------------------------------
Dinternal/api/s2s/webfinger/webfinger_test.go | 137-------------------------------------------------------------------------------
Dinternal/api/s2s/webfinger/webfingerget.go | 102-------------------------------------------------------------------------------
Dinternal/api/s2s/webfinger/webfingerget_test.go | 171-------------------------------------------------------------------------------
Dinternal/api/security/extraheaders.go | 26--------------------------
Dinternal/api/security/flocblock.go | 31-------------------------------
Dinternal/api/security/ratelimit.go | 70----------------------------------------------------------------------
Dinternal/api/security/robots.go | 57---------------------------------------------------------
Dinternal/api/security/security.go | 65-----------------------------------------------------------------
Dinternal/api/security/signaturecheck.go | 51---------------------------------------------------
Dinternal/api/security/tokencheck.go | 120-------------------------------------------------------------------------------
Dinternal/api/security/useragentblock.go | 35-----------------------------------
Ainternal/api/util/errorhandling.go | 132+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/util/mime.go | 36++++++++++++++++++++++++++++++++++++
Ainternal/api/util/negotiate.go | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/util/signaturectx.go | 41+++++++++++++++++++++++++++++++++++++++++
Ainternal/api/wellknown.go | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/wellknown/nodeinfo/nodeinfo.go | 47+++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/wellknown/nodeinfo/nodeinfoget.go | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/wellknown/webfinger/webfinger.go | 46++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/wellknown/webfinger/webfinger_test.go | 134+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/wellknown/webfinger/webfingerget.go | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/wellknown/webfinger/webfingerget_test.go | 171+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/config/defaults.go | 2+-
Minternal/db/db.go | 2+-
Ainternal/middleware/cachecontrol.go | 35+++++++++++++++++++++++++++++++++++
Ainternal/middleware/cors.go | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/middleware/extraheaders.go | 37+++++++++++++++++++++++++++++++++++++
Ainternal/middleware/gzip.go | 30++++++++++++++++++++++++++++++
Ainternal/middleware/logger.go | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/middleware/ratelimit.go | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/middleware/session.go | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/middleware/session_test.go | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/middleware/signaturecheck.go | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/middleware/tokencheck.go | 140+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/middleware/useragent.go | 39+++++++++++++++++++++++++++++++++++++++
Minternal/oidc/idp.go | 8--------
Minternal/processing/federation.go | 8++++----
Minternal/processing/federation/federation.go | 4++--
Minternal/processing/federation/getnodeinfo.go | 5++---
Minternal/processing/fromclientapi_test.go | 4++--
Minternal/processing/fromfederator_test.go | 8++++----
Minternal/processing/oauth.go | 6++++++
Minternal/processing/processor.go | 6++++--
Minternal/processing/status/create_test.go | 52++++++++++++++++++++++++++--------------------------
Minternal/processing/status/util.go | 7+++----
Minternal/processing/status/util_test.go | 62+++++++++++++++++++++++++++++++-------------------------------
Minternal/router/attach.go | 23++++++-----------------
Dinternal/router/cors.go | 87-------------------------------------------------------------------------------
Dinternal/router/gzip.go | 30------------------------------
Dinternal/router/logger.go | 96-------------------------------------------------------------------------------
Minternal/router/router.go | 67+++++++++++++++++++++++++++++--------------------------------------
Dinternal/router/session.go | 111-------------------------------------------------------------------------------
Dinternal/router/session_test.go | 95-------------------------------------------------------------------------------
Minternal/router/template.go | 16++++++++--------
Minternal/text/emojify.go | 6+++---
Minternal/transport/deliver.go | 4++--
Minternal/transport/dereference.go | 4++--
Minternal/transport/derefinstance.go | 8++++----
Minternal/transport/finger.go | 4++--
Minternal/typeutils/converter.go | 38+++++++++++++++++++-------------------
Minternal/typeutils/frontendtointernal.go | 14+++++++-------
Minternal/typeutils/internaltofrontend.go | 142++++++++++++++++++++++++++++++++++++++++----------------------------------------
Minternal/typeutils/internaltorss.go | 4++--
Minternal/web/assets.go | 21+++------------------
Minternal/web/base.go | 4++--
Minternal/web/confirmemail.go | 8++++----
Minternal/web/customcss.go | 14+++++++-------
Minternal/web/profile.go | 22+++++++++++-----------
Minternal/web/robots.go | 42++++++++++++++++++++++++++++++++++++++++--
Minternal/web/rss.go | 18+++++++++---------
Minternal/web/settings-panel.go | 4++--
Minternal/web/thread.go | 26+++++++++++++-------------
Minternal/web/web.go | 101+++++++++++++++++++++++++++++++++++--------------------------------------------
Mtestrig/router.go | 2+-
339 files changed, 15093 insertions(+), 14952 deletions(-)

diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md @@ -251,7 +251,7 @@ Most of the crucial business logic of the application is found inside the variou `internal/ap` - ActivityPub utility functions and interfaces. -`internal/api` - Models, routers, and utilities for the client and federated (s2s) APIs. This is where routes are attached to the router, and where you want to be if you're adding a route. +`internal/api` - Models, routers, and utilities for the client and federated (ActivityPub) APIs. This is where routes are attached to the router, and where you want to be if you're adding a route. `internal/concurrency` - Worker models used by the processor and other queues. @@ -283,6 +283,8 @@ Most of the crucial business logic of the application is found inside the variou `internal/messages` - Models for wrapping worker messages. +`internal/middleware` - Gin Gonic router middlewares: http signature checking, cache control, token checks, etc. + `internal/netutil` - HTTP / net request validation code. `internal/oauth` - Wrapper code/interfaces for OAuth server implementation. diff --git a/cmd/gotosocial/action/server/server.go b/cmd/gotosocial/action/server/server.go @@ -20,37 +20,20 @@ package server import ( "context" + "errors" "fmt" + "net/http" "os" "os/signal" "syscall" + "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/cmd/gotosocial/action" "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/blocks" - "github.com/superseriousbusiness/gotosocial/internal/api/client/bookmarks" - "github.com/superseriousbusiness/gotosocial/internal/api/client/emoji" - "github.com/superseriousbusiness/gotosocial/internal/api/client/favourites" - "github.com/superseriousbusiness/gotosocial/internal/api/client/fileserver" - "github.com/superseriousbusiness/gotosocial/internal/api/client/filter" - "github.com/superseriousbusiness/gotosocial/internal/api/client/followrequest" - "github.com/superseriousbusiness/gotosocial/internal/api/client/instance" - "github.com/superseriousbusiness/gotosocial/internal/api/client/list" - mediaModule "github.com/superseriousbusiness/gotosocial/internal/api/client/media" - "github.com/superseriousbusiness/gotosocial/internal/api/client/notification" - "github.com/superseriousbusiness/gotosocial/internal/api/client/search" - "github.com/superseriousbusiness/gotosocial/internal/api/client/status" - "github.com/superseriousbusiness/gotosocial/internal/api/client/streaming" - "github.com/superseriousbusiness/gotosocial/internal/api/client/timeline" - userClient "github.com/superseriousbusiness/gotosocial/internal/api/client/user" - "github.com/superseriousbusiness/gotosocial/internal/api/s2s/nodeinfo" - "github.com/superseriousbusiness/gotosocial/internal/api/s2s/user" - "github.com/superseriousbusiness/gotosocial/internal/api/s2s/webfinger" - "github.com/superseriousbusiness/gotosocial/internal/api/security" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/middleware" + "github.com/superseriousbusiness/gotosocial/internal/concurrency" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db/bundb" @@ -106,11 +89,6 @@ var Start action.GTSAction = func(ctx context.Context) error { federatingDB := federatingdb.New(dbService, fedWorker) - router, err := router.New(ctx, dbService) - if err != nil { - return fmt.Errorf("error creating router: %s", err) - } - // build converters and util typeConverter := typeutils.NewConverter(dbService) @@ -148,85 +126,72 @@ var Start action.GTSAction = func(ctx context.Context) error { } } - // create and start the message processor using the other services we've created so far + // create the message processor using the other services we've created so far processor := processing.NewProcessor(typeConverter, federator, oauthServer, mediaManager, storage, dbService, emailSender, clientWorker, fedWorker) if err := processor.Start(); err != nil { - return fmt.Errorf("error starting processor: %s", err) + return fmt.Errorf("error creating processor: %s", err) } - idp, err := oidc.NewIDP(ctx) + /* + HTTP router initialization + */ + + router, err := router.New(ctx) if err != nil { - return fmt.Errorf("error creating oidc idp: %s", err) + return fmt.Errorf("error creating router: %s", err) } - // build web module - webModule := web.New(processor) - - // build client api modules - authModule := auth.New(dbService, idp, processor) - accountModule := account.New(processor) - instanceModule := instance.New(processor) - appsModule := app.New(processor) - followRequestsModule := followrequest.New(processor) - webfingerModule := webfinger.New(processor) - nodeInfoModule := nodeinfo.New(processor) - usersModule := user.New(processor) - timelineModule := timeline.New(processor) - notificationModule := notification.New(processor) - searchModule := search.New(processor) - filtersModule := filter.New(processor) - emojiModule := emoji.New(processor) - listsModule := list.New(processor) - mm := mediaModule.New(processor) - fileServerModule := fileserver.New(processor) - adminModule := admin.New(processor) - statusModule := status.New(processor) - bookmarksModule := bookmarks.New(processor) - securityModule := security.New(dbService, oauthServer) - streamingModule := streaming.New(processor) - favouritesModule := favourites.New(processor) - blocksModule := blocks.New(processor) - userClientModule := userClient.New(processor) - - apis := []api.ClientModule{ - // modules with middleware go first - securityModule, - authModule, - - // now the web module - webModule, - - // now everything else - accountModule, - instanceModule, - appsModule, - followRequestsModule, - mm, - fileServerModule, - adminModule, - statusModule, - bookmarksModule, - webfingerModule, - nodeInfoModule, - usersModule, - timelineModule, - notificationModule, - searchModule, - filtersModule, - emojiModule, - listsModule, - streamingModule, - favouritesModule, - blocksModule, - userClientModule, + // attach global middlewares which are used for every request + router.AttachGlobalMiddleware( + middleware.Logger(), + middleware.UserAgent(), + middleware.CORS(), + middleware.ExtraHeaders(), + ) + + // attach global no route / 404 handler to the router + router.AttachNoRouteHandler(func(c *gin.Context) { + apiutil.ErrorHandler(c, gtserror.NewErrorNotFound(errors.New(http.StatusText(http.StatusNotFound))), processor.InstanceGet) + }) + + // build router modules + var idp oidc.IDP + if config.GetOIDCEnabled() { + idp, err = oidc.NewIDP(ctx) + if err != nil { + return fmt.Errorf("error creating oidc idp: %w", err) + } } - for _, m := range apis { - if err := m.Route(router); err != nil { - return fmt.Errorf("routing error: %s", err) - } + routerSession, err := dbService.GetSession(ctx) + if err != nil { + return fmt.Errorf("error retrieving router session for session middleware: %w", err) } + sessionName, err := middleware.SessionName() + if err != nil { + return fmt.Errorf("error generating session name for session middleware: %w", err) + } + + var ( + authModule = api.NewAuth(dbService, processor, idp, routerSession, sessionName) // auth/oauth paths + clientModule = api.NewClient(dbService, processor) // api client endpoints + fileserverModule = api.NewFileserver(processor) // fileserver endpoints + wellKnownModule = api.NewWellKnown(processor) // .well-known endpoints + nodeInfoModule = api.NewNodeInfo(processor) // nodeinfo endpoint + activityPubModule = api.NewActivityPub(dbService, processor) // ActivityPub endpoints + webModule = web.New(processor) // web pages + user profiles + settings panels etc + ) + + // these should be routed in order + authModule.Route(router) + clientModule.Route(router) + fileserverModule.Route(router) + wellKnownModule.Route(router) + nodeInfoModule.Route(router) + activityPubModule.Route(router) + webModule.Route(router) + gts, err := gotosocial.NewServer(dbService, router, federator, mediaManager) if err != nil { return fmt.Errorf("error creating gotosocial service: %s", err) diff --git a/cmd/gotosocial/action/testrig/testrig.go b/cmd/gotosocial/action/testrig/testrig.go @@ -21,6 +21,7 @@ package testrig import ( "bytes" "context" + "errors" "fmt" "io" "net/http" @@ -28,35 +29,17 @@ import ( "os/signal" "syscall" + "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/cmd/gotosocial/action" "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/blocks" - "github.com/superseriousbusiness/gotosocial/internal/api/client/emoji" - "github.com/superseriousbusiness/gotosocial/internal/api/client/favourites" - "github.com/superseriousbusiness/gotosocial/internal/api/client/fileserver" - "github.com/superseriousbusiness/gotosocial/internal/api/client/filter" - "github.com/superseriousbusiness/gotosocial/internal/api/client/followrequest" - "github.com/superseriousbusiness/gotosocial/internal/api/client/instance" - "github.com/superseriousbusiness/gotosocial/internal/api/client/list" - mediaModule "github.com/superseriousbusiness/gotosocial/internal/api/client/media" - "github.com/superseriousbusiness/gotosocial/internal/api/client/notification" - "github.com/superseriousbusiness/gotosocial/internal/api/client/search" - "github.com/superseriousbusiness/gotosocial/internal/api/client/status" - "github.com/superseriousbusiness/gotosocial/internal/api/client/streaming" - "github.com/superseriousbusiness/gotosocial/internal/api/client/timeline" - userClient "github.com/superseriousbusiness/gotosocial/internal/api/client/user" - "github.com/superseriousbusiness/gotosocial/internal/api/s2s/nodeinfo" - "github.com/superseriousbusiness/gotosocial/internal/api/s2s/user" - "github.com/superseriousbusiness/gotosocial/internal/api/s2s/webfinger" - "github.com/superseriousbusiness/gotosocial/internal/api/security" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/concurrency" + "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/gotosocial" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/messages" + "github.com/superseriousbusiness/gotosocial/internal/middleware" "github.com/superseriousbusiness/gotosocial/internal/oidc" "github.com/superseriousbusiness/gotosocial/internal/storage" "github.com/superseriousbusiness/gotosocial/internal/web" @@ -70,7 +53,6 @@ var Start action.GTSAction = func(ctx context.Context) error { dbService := testrig.NewTestDB() testrig.StandardDBSetup(dbService, nil) - router := testrig.NewTestRouter(dbService) var storageBackend *storage.Driver if os.Getenv("GTS_STORAGE_BACKEND") == "s3" { storageBackend, _ = storage.NewS3Storage() @@ -84,7 +66,6 @@ var Start action.GTSAction = func(ctx context.Context) error { fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) // build backend handlers - oauthServer := testrig.NewTestOauthServer(dbService) transportController := testrig.NewTestTransportController(testrig.NewMockHTTPClient(func(req *http.Request) (*http.Response, error) { r := io.NopCloser(bytes.NewReader([]byte{})) return &http.Response{ @@ -102,77 +83,64 @@ var Start action.GTSAction = func(ctx context.Context) error { return fmt.Errorf("error starting processor: %s", err) } - idp, err := oidc.NewIDP(ctx) - if err != nil { - return fmt.Errorf("error creating oidc idp: %s", err) + /* + HTTP router initialization + */ + + router := testrig.NewTestRouter(dbService) + + // attach global middlewares which are used for every request + router.AttachGlobalMiddleware( + middleware.Logger(), + middleware.UserAgent(), + middleware.CORS(), + middleware.ExtraHeaders(), + ) + + // attach global no route / 404 handler to the router + router.AttachNoRouteHandler(func(c *gin.Context) { + apiutil.ErrorHandler(c, gtserror.NewErrorNotFound(errors.New(http.StatusText(http.StatusNotFound))), processor.InstanceGet) + }) + + // build router modules + var idp oidc.IDP + var err error + if config.GetOIDCEnabled() { + idp, err = oidc.NewIDP(ctx) + if err != nil { + return fmt.Errorf("error creating oidc idp: %w", err) + } } - // build web module - webModule := web.New(processor) - - // build client api modules - authModule := auth.New(dbService, idp, processor) - accountModule := account.New(processor) - instanceModule := instance.New(processor) - appsModule := app.New(processor) - followRequestsModule := followrequest.New(processor) - webfingerModule := webfinger.New(processor) - nodeInfoModule := nodeinfo.New(processor) - usersModule := user.New(processor) - timelineModule := timeline.New(processor) - notificationModule := notification.New(processor) - searchModule := search.New(processor) - filtersModule := filter.New(processor) - emojiModule := emoji.New(processor) - listsModule := list.New(processor) - mm := mediaModule.New(processor) - fileServerModule := fileserver.New(processor) - adminModule := admin.New(processor) - statusModule := status.New(processor) - securityModule := security.New(dbService, oauthServer) - streamingModule := streaming.New(processor) - favouritesModule := favourites.New(processor) - blocksModule := blocks.New(processor) - userClientModule := userClient.New(processor) - - apis := []api.ClientModule{ - // modules with middleware go first - securityModule, - authModule, - - // now the web module - webModule, - - // now everything else - accountModule, - instanceModule, - appsModule, - followRequestsModule, - mm, - fileServerModule, - adminModule, - statusModule, - webfingerModule, - nodeInfoModule, - usersModule, - timelineModule, - notificationModule, - searchModule, - filtersModule, - emojiModule, - listsModule, - streamingModule, - favouritesModule, - blocksModule, - userClientModule, + routerSession, err := dbService.GetSession(ctx) + if err != nil { + return fmt.Errorf("error retrieving router session for session middleware: %w", err) } - for _, m := range apis { - if err := m.Route(router); err != nil { - return fmt.Errorf("routing error: %s", err) - } + sessionName, err := middleware.SessionName() + if err != nil { + return fmt.Errorf("error generating session name for session middleware: %w", err) } + var ( + authModule = api.NewAuth(dbService, processor, idp, routerSession, sessionName) // auth/oauth paths + clientModule = api.NewClient(dbService, processor) // api client endpoints + fileserverModule = api.NewFileserver(processor) // fileserver endpoints + wellKnownModule = api.NewWellKnown(processor) // .well-known endpoints + nodeInfoModule = api.NewNodeInfo(processor) // nodeinfo endpoint + activityPubModule = api.NewActivityPub(dbService, processor) // ActivityPub endpoints + webModule = web.New(processor) // web pages + user profiles + settings panels etc + ) + + // these should be routed in order + authModule.Route(router) + clientModule.Route(router) + fileserverModule.Route(router) + wellKnownModule.Route(router) + nodeInfoModule.Route(router) + activityPubModule.Route(router) + webModule.Route(router) + gts, err := gotosocial.NewServer(dbService, router, federator, mediaManager) if err != nil { return fmt.Errorf("error creating gotosocial service: %s", err) diff --git a/docs/api/ratelimiting.md b/docs/api/ratelimiting.md @@ -1,10 +1,16 @@ # Rate Limit -To mitigate abuse + scraping of your instance, an IP-based HTTP rate limit is in place. +To mitigate abuse + scraping of your instance, IP-based HTTP rate limiting is in place. -This rate limit applies not just to the API, but to all requests (web, federation, etc). +There are separate rate limiters configured for different groupings of endpoints. In other words, being rate limited for one part of the API doesn't necessarily mean you will be rate limited for other parts. Each entry in the following list has a separate rate limiter: -By default, a maximum of 1000 requests in a 5 minute time window are allowed. +- `/users/*` and `/emoji/*` - ActivityPub (s2s) endpoints. +- `/auth/*` and `/oauth/*` - Sign in + OAUTH token requests. +- `/fileserver/*` - Media attachments, emojis, etc. +- `/nodeinfo/*` - NodeInfo endpoint(s). +- `/.well-known/*` - webfinger + nodeinfo requests. + +By default, each rate limiter allows a maximum of 300 requests in a 5 minute time window: 1 request per second per client IP address. Every response will include the current status of the rate limit with the following headers: diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml @@ -1914,7 +1914,7 @@ definitions: title: SwaggerCollection represents an activitypub collection. type: object x-go-name: SwaggerCollection - x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/s2s/user + x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/activitypub/users swaggerCollectionPage: properties: id: @@ -1949,7 +1949,7 @@ definitions: title: SwaggerCollectionPage represents one page of a collection. type: object x-go-name: SwaggerCollectionPage - x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/s2s/user + x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/activitypub/users tag: properties: name: @@ -2049,9 +2049,9 @@ paths: description: "" schema: $ref: '#/definitions/wellKnownResponse' - summary: Directs callers to /nodeinfo/2.0. + summary: Returns a well-known response which redirects callers to `/nodeinfo/2.0`. tags: - - nodeinfo + - .well-known /.well-known/webfinger: get: description: |- @@ -2074,7 +2074,7 @@ paths: $ref: '#/definitions/wellKnownResponse' summary: Handles webfinger account lookup requests. tags: - - webfinger + - .well-known /api/{api_version}/media: post: consumes: diff --git a/example/config.yaml b/example/config.yaml @@ -641,9 +641,11 @@ syslog-address: "localhost:514" # Default: "lax" advanced-cookies-samesite: "lax" -# Int. Amount of requests to permit from a single IP address within a span of 5 minutes. -# If this amount is exceeded, a 429 HTTP error code will be returned. -# See https://docs.gotosocial.org/en/latest/api/swagger/#rate-limit. +# Int. Amount of requests to permit per router grouping from a single IP address within +# a span of 5 minutes. If this amount is exceeded, a 429 HTTP error code will be returned. +# +# Router groupings and rate limit headers are described here: +# https://docs.gotosocial.org/en/latest/api/swagger/#rate-limit. # # If you find yourself adjusting this limit because it's regularly being exceeded, # you should first verify that your settings for `trusted-proxies` (above) are correct. @@ -655,5 +657,5 @@ advanced-cookies-samesite: "lax" # If you set this to 0 or less, rate limiting will be disabled entirely. # # Examples: [1000, 500, 0] -# Default: 1000 -advanced-rate-limit-requests: 1000 +# Default: 300 +advanced-rate-limit-requests: 300 diff --git a/internal/api/activitypub.go b/internal/api/activitypub.go @@ -0,0 +1,66 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 ( + "context" + "net/url" + + "github.com/superseriousbusiness/gotosocial/internal/api/activitypub/emoji" + "github.com/superseriousbusiness/gotosocial/internal/api/activitypub/users" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/middleware" + "github.com/superseriousbusiness/gotosocial/internal/processing" + "github.com/superseriousbusiness/gotosocial/internal/router" +) + +type ActivityPub struct { + emoji *emoji.Module + users *users.Module + + isURIBlocked func(context.Context, *url.URL) (bool, db.Error) +} + +func (a *ActivityPub) Route(r router.Router) { + // create groupings for the 'emoji' and 'users' prefixes + emojiGroup := r.AttachGroup("emoji") + usersGroup := r.AttachGroup("users") + + // instantiate + attach shared, non-global middlewares to both of these groups + var ( + rateLimitMiddleware = middleware.RateLimit() // nolint:contextcheck + signatureCheckMiddleware = middleware.SignatureCheck(a.isURIBlocked) + gzipMiddleware = middleware.Gzip() + cacheControlMiddleware = middleware.CacheControl("no-store") + ) + emojiGroup.Use(rateLimitMiddleware, signatureCheckMiddleware, gzipMiddleware, cacheControlMiddleware) + usersGroup.Use(rateLimitMiddleware, signatureCheckMiddleware, gzipMiddleware, cacheControlMiddleware) + + a.emoji.Route(emojiGroup.Handle) + a.users.Route(usersGroup.Handle) +} + +func NewActivityPub(db db.DB, p processing.Processor) *ActivityPub { + return &ActivityPub{ + emoji: emoji.New(p), + users: users.New(p), + + isURIBlocked: db.IsURIBlocked, + } +} diff --git a/internal/api/activitypub/emoji/emoji.go b/internal/api/activitypub/emoji/emoji.go @@ -0,0 +1,47 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 emoji + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/processing" +) + +const ( + // EmojiIDKey is for emoji IDs + EmojiIDKey = "id" + // EmojiBasePath is the base path for serving AP Emojis, minus the "emoji" prefix + EmojiWithIDPath = "/:" + EmojiIDKey +) + +type Module struct { + processor processing.Processor +} + +func New(processor processing.Processor) *Module { + return &Module{ + processor: processor, + } +} + +func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) { + attachHandler(http.MethodGet, EmojiWithIDPath, m.EmojiGetHandler) +} diff --git a/internal/api/activitypub/emoji/emojiget.go b/internal/api/activitypub/emoji/emojiget.go @@ -0,0 +1,59 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 emoji + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" +) + +func (m *Module) EmojiGetHandler(c *gin.Context) { + requestedEmojiID := strings.ToUpper(c.Param(EmojiIDKey)) + if requestedEmojiID == "" { + err := errors.New("no emoji id specified in request") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + format, err := apiutil.NegotiateAccept(c, apiutil.ActivityPubAcceptHeaders...) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + resp, errWithCode := m.processor.GetFediEmoji(apiutil.TransferSignatureContext(c), requestedEmojiID, c.Request.URL) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + b, err := json.Marshal(resp) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) + return + } + + c.Data(http.StatusOK, format, b) +} diff --git a/internal/api/activitypub/emoji/emojiget_test.go b/internal/api/activitypub/emoji/emojiget_test.go @@ -0,0 +1,137 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 emoji_test + +import ( + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/activitypub/emoji" + "github.com/superseriousbusiness/gotosocial/internal/concurrency" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/email" + "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/messages" + "github.com/superseriousbusiness/gotosocial/internal/middleware" + "github.com/superseriousbusiness/gotosocial/internal/processing" + "github.com/superseriousbusiness/gotosocial/internal/storage" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type EmojiGetTestSuite struct { + suite.Suite + db db.DB + tc typeutils.TypeConverter + mediaManager media.Manager + federator federation.Federator + emailSender email.Sender + processor processing.Processor + storage *storage.Driver + + testEmojis map[string]*gtsmodel.Emoji + testAccounts map[string]*gtsmodel.Account + + emojiModule *emoji.Module + + signatureCheck gin.HandlerFunc +} + +func (suite *EmojiGetTestSuite) SetupSuite() { + suite.testAccounts = testrig.NewTestAccounts() + suite.testEmojis = testrig.NewTestEmojis() +} + +func (suite *EmojiGetTestSuite) SetupTest() { + testrig.InitTestConfig() + testrig.InitTestLog() + + clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) + fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) + + suite.db = testrig.NewTestDB() + suite.tc = testrig.NewTestTypeConverter(suite.db) + suite.storage = testrig.NewInMemoryStorage() + suite.mediaManager = testrig.NewTestMediaManager(suite.db, suite.storage) + suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker), suite.storage, suite.mediaManager, fedWorker) + suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil) + suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, suite.mediaManager, clientWorker, fedWorker) + suite.emojiModule = emoji.New(suite.processor) + testrig.StandardDBSetup(suite.db, suite.testAccounts) + testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") + + suite.signatureCheck = middleware.SignatureCheck(suite.db.IsURIBlocked) + + suite.NoError(suite.processor.Start()) +} + +func (suite *EmojiGetTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) +} + +func (suite *EmojiGetTestSuite) TestGetEmoji() { + // the dereference we're gonna use + derefRequests := testrig.NewTestDereferenceRequests(suite.testAccounts) + signedRequest := derefRequests["foss_satan_dereference_emoji"] + targetEmoji := suite.testEmojis["rainbow"] + + // setup request + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Request = httptest.NewRequest(http.MethodGet, targetEmoji.URI, nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/activity+json") + ctx.Request.Header.Set("Signature", signedRequest.SignatureHeader) + ctx.Request.Header.Set("Date", signedRequest.DateHeader) + + // we need to pass the context through signature check first to set appropriate values on it + suite.signatureCheck(ctx) + + // 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: emoji.EmojiIDKey, + Value: targetEmoji.ID, + }, + } + + // trigger the function being tested + suite.emojiModule.EmojiGetHandler(ctx) + + // check response + suite.EqualValues(http.StatusOK, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + suite.NoError(err) + + suite.Contains(string(b), `"icon":{"mediaType":"image/png","type":"Image","url":"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png"},"id":"http://localhost:8080/emoji/01F8MH9H8E4VG3KDYJR9EGPXCQ","name":":rainbow:","type":"Emoji"`) +} + +func TestEmojiGetTestSuite(t *testing.T) { + suite.Run(t, new(EmojiGetTestSuite)) +} diff --git a/internal/api/activitypub/users/common.go b/internal/api/activitypub/users/common.go @@ -0,0 +1,57 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 users + +// SwaggerCollection represents an activitypub collection. +// swagger:model swaggerCollection +type SwaggerCollection struct { + // ActivityStreams context. + // example: https://www.w3.org/ns/activitystreams + Context string `json:"@context"` + // ActivityStreams ID. + // example: https://example.org/users/some_user/statuses/106717595988259568/replies + ID string `json:"id"` + // ActivityStreams type. + // example: Collection + Type string `json:"type"` + // ActivityStreams first property. + First SwaggerCollectionPage `json:"first"` + // ActivityStreams last property. + Last SwaggerCollectionPage `json:"last,omitempty"` +} + +// SwaggerCollectionPage represents one page of a collection. +// swagger:model swaggerCollectionPage +type SwaggerCollectionPage struct { + // ActivityStreams ID. + // example: https://example.org/users/some_user/statuses/106717595988259568/replies?page=true + ID string `json:"id"` + // ActivityStreams type. + // example: CollectionPage + Type string `json:"type"` + // Link to the next page. + // example: https://example.org/users/some_user/statuses/106717595988259568/replies?only_other_accounts=true&page=true + Next string `json:"next"` + // Collection this page belongs to. + // example: https://example.org/users/some_user/statuses/106717595988259568/replies + PartOf string `json:"partOf"` + // Items on this page. + // example: ["https://example.org/users/some_other_user/statuses/086417595981111564", "https://another.example.com/users/another_user/statuses/01FCN8XDV3YG7B4R42QA6YQZ9R"] + Items []string `json:"items"` +} diff --git a/internal/api/activitypub/users/followers.go b/internal/api/activitypub/users/followers.go @@ -0,0 +1,67 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 users + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" +) + +// FollowersGETHandler returns a collection of URIs for followers of the target user, formatted so that other AP servers can understand it. +func (m *Module) FollowersGETHandler(c *gin.Context) { + // usernames on our instance are always lowercase + requestedUsername := strings.ToLower(c.Param(UsernameKey)) + if requestedUsername == "" { + err := errors.New("no username specified in request") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + format, err := apiutil.NegotiateAccept(c, apiutil.HTMLOrActivityPubHeaders...) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + if format == string(apiutil.TextHTML) { + // redirect to the user's profile + c.Redirect(http.StatusSeeOther, "/@"+requestedUsername) + return + } + + resp, errWithCode := m.processor.GetFediFollowers(apiutil.TransferSignatureContext(c), requestedUsername, c.Request.URL) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + b, err := json.Marshal(resp) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) + return + } + + c.Data(http.StatusOK, format, b) +} diff --git a/internal/api/activitypub/users/following.go b/internal/api/activitypub/users/following.go @@ -0,0 +1,67 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 users + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" +) + +// FollowingGETHandler returns a collection of URIs for accounts that the target user follows, formatted so that other AP servers can understand it. +func (m *Module) FollowingGETHandler(c *gin.Context) { + // usernames on our instance are always lowercase + requestedUsername := strings.ToLower(c.Param(UsernameKey)) + if requestedUsername == "" { + err := errors.New("no username specified in request") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + format, err := apiutil.NegotiateAccept(c, apiutil.HTMLOrActivityPubHeaders...) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + if format == string(apiutil.TextHTML) { + // redirect to the user's profile + c.Redirect(http.StatusSeeOther, "/@"+requestedUsername) + return + } + + resp, errWithCode := m.processor.GetFediFollowing(apiutil.TransferSignatureContext(c), requestedUsername, c.Request.URL) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + b, err := json.Marshal(resp) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) + return + } + + c.Data(http.StatusOK, format, b) +} diff --git a/internal/api/activitypub/users/inboxpost.go b/internal/api/activitypub/users/inboxpost.go @@ -0,0 +1,51 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 users + +import ( + "errors" + "strings" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" //nolint:typecheck +) + +// InboxPOSTHandler deals with incoming POST requests to an actor's inbox. +// Eg., POST to https://example.org/users/whatever/inbox. +func (m *Module) InboxPOSTHandler(c *gin.Context) { + // usernames on our instance are always lowercase + requestedUsername := strings.ToLower(c.Param(UsernameKey)) + if requestedUsername == "" { + err := errors.New("no username specified in request") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + if posted, err := m.processor.InboxPost(apiutil.TransferSignatureContext(c), c.Writer, c.Request); err != nil { + if withCode, ok := err.(gtserror.WithCode); ok { + apiutil.ErrorHandler(c, withCode, m.processor.InstanceGet) + } else { + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + } + } else if !posted { + err := errors.New("unable to process request") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + } +} diff --git a/internal/api/activitypub/users/inboxpost_test.go b/internal/api/activitypub/users/inboxpost_test.go @@ -0,0 +1,500 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 users_test + +import ( + "bytes" + "context" + "encoding/json" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/activity/pub" + "github.com/superseriousbusiness/activity/streams" + "github.com/superseriousbusiness/gotosocial/internal/api/activitypub/users" + "github.com/superseriousbusiness/gotosocial/internal/concurrency" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" + "github.com/superseriousbusiness/gotosocial/internal/messages" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type InboxPostTestSuite struct { + UserStandardTestSuite +} + +func (suite *InboxPostTestSuite) TestPostBlock() { + blockingAccount := suite.testAccounts["remote_account_1"] + blockedAccount := suite.testAccounts["local_account_1"] + blockURI := testrig.URLMustParse("http://fossbros-anonymous.io/users/foss_satan/blocks/01FG9C441MCTW3R2W117V2PQK3") + + block := streams.NewActivityStreamsBlock() + + // set the actor property to the block-ing account's URI + actorProp := streams.NewActivityStreamsActorProperty() + actorIRI := testrig.URLMustParse(blockingAccount.URI) + actorProp.AppendIRI(actorIRI) + block.SetActivityStreamsActor(actorProp) + + // set the ID property to the blocks's URI + idProp := streams.NewJSONLDIdProperty() + idProp.Set(blockURI) + block.SetJSONLDId(idProp) + + // set the object property to the target account's URI + objectProp := streams.NewActivityStreamsObjectProperty() + targetIRI := testrig.URLMustParse(blockedAccount.URI) + objectProp.AppendIRI(targetIRI) + block.SetActivityStreamsObject(objectProp) + + // set the TO property to the target account's IRI + toProp := streams.NewActivityStreamsToProperty() + toIRI := testrig.URLMustParse(blockedAccount.URI) + toProp.AppendIRI(toIRI) + block.SetActivityStreamsTo(toProp) + + targetURI := testrig.URLMustParse(blockedAccount.InboxURI) + + signature, digestHeader, dateHeader := testrig.GetSignatureForActivity(block, blockingAccount.PublicKeyURI, blockingAccount.PrivateKey, targetURI) + bodyI, err := streams.Serialize(block) + suite.NoError(err) + + bodyJson, err := json.Marshal(bodyI) + suite.NoError(err) + body := bytes.NewReader(bodyJson) + + clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) + fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) + + tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker) + federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager, fedWorker) + emailSender := testrig.NewEmailSender("../../../../web/template/", nil) + processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager, clientWorker, fedWorker) + userModule := users.New(processor) + suite.NoError(processor.Start()) + + // setup request + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Request = httptest.NewRequest(http.MethodPost, targetURI.String(), body) // the endpoint we're hitting + ctx.Request.Header.Set("Signature", signature) + ctx.Request.Header.Set("Date", dateHeader) + ctx.Request.Header.Set("Digest", digestHeader) + ctx.Request.Header.Set("Content-Type", "application/activity+json") + + // we need to pass the context through signature check first to set appropriate values on it + suite.signatureCheck(ctx) + + // 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: users.UsernameKey, + Value: blockedAccount.Username, + }, + } + + // trigger the function being tested + userModule.InboxPOSTHandler(ctx) + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + suite.NoError(err) + suite.Empty(b) + + // there should be a block in the database now between the accounts + dbBlock, err := suite.db.GetBlock(context.Background(), blockingAccount.ID, blockedAccount.ID) + suite.NoError(err) + suite.NotNil(dbBlock) + suite.WithinDuration(time.Now(), dbBlock.CreatedAt, 30*time.Second) + suite.WithinDuration(time.Now(), dbBlock.UpdatedAt, 30*time.Second) + suite.Equal("http://fossbros-anonymous.io/users/foss_satan/blocks/01FG9C441MCTW3R2W117V2PQK3", dbBlock.URI) +} + +// TestPostUnblock verifies that a remote account with a block targeting one of our instance users should be able to undo that block. +func (suite *InboxPostTestSuite) TestPostUnblock() { + blockingAccount := suite.testAccounts["remote_account_1"] + blockedAccount := suite.testAccounts["local_account_1"] + + // first put a block in the database so we have something to undo + blockURI := "http://fossbros-anonymous.io/users/foss_satan/blocks/01FG9C441MCTW3R2W117V2PQK3" + dbBlockID, err := id.NewRandomULID() + suite.NoError(err) + + dbBlock := &gtsmodel.Block{ + ID: dbBlockID, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + URI: blockURI, + AccountID: blockingAccount.ID, + TargetAccountID: blockedAccount.ID, + } + + err = suite.db.PutBlock(context.Background(), dbBlock) + suite.NoError(err) + + asBlock, err := suite.tc.BlockToAS(context.Background(), dbBlock) + suite.NoError(err) + + targetAccountURI := testrig.URLMustParse(blockedAccount.URI) + + // create an Undo and set the appropriate actor on it + undo := streams.NewActivityStreamsUndo() + undo.SetActivityStreamsActor(asBlock.GetActivityStreamsActor()) + + // Set the block as the 'object' property. + undoObject := streams.NewActivityStreamsObjectProperty() + undoObject.AppendActivityStreamsBlock(asBlock) + undo.SetActivityStreamsObject(undoObject) + + // Set the To of the undo as the target of the block + undoTo := streams.NewActivityStreamsToProperty() + undoTo.AppendIRI(targetAccountURI) + undo.SetActivityStreamsTo(undoTo) + + undoID := streams.NewJSONLDIdProperty() + undoID.SetIRI(testrig.URLMustParse("http://fossbros-anonymous.io/72cc96a3-f742-4daf-b9f5-3407667260c5")) + undo.SetJSONLDId(undoID) + + targetURI := testrig.URLMustParse(blockedAccount.InboxURI) + + signature, digestHeader, dateHeader := testrig.GetSignatureForActivity(undo, blockingAccount.PublicKeyURI, blockingAccount.PrivateKey, targetURI) + bodyI, err := streams.Serialize(undo) + suite.NoError(err) + + bodyJson, err := json.Marshal(bodyI) + suite.NoError(err) + body := bytes.NewReader(bodyJson) + + clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) + fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) + + tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker) + federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager, fedWorker) + emailSender := testrig.NewEmailSender("../../../../web/template/", nil) + processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager, clientWorker, fedWorker) + userModule := users.New(processor) + suite.NoError(processor.Start()) + + // setup request + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Request = httptest.NewRequest(http.MethodPost, targetURI.String(), body) // the endpoint we're hitting + ctx.Request.Header.Set("Signature", signature) + ctx.Request.Header.Set("Date", dateHeader) + ctx.Request.Header.Set("Digest", digestHeader) + ctx.Request.Header.Set("Content-Type", "application/activity+json") + + // we need to pass the context through signature check first to set appropriate values on it + suite.signatureCheck(ctx) + + // 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: users.UsernameKey, + Value: blockedAccount.Username, + }, + } + + // trigger the function being tested + userModule.InboxPOSTHandler(ctx) + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + suite.NoError(err) + suite.Empty(b) + suite.Equal(http.StatusOK, result.StatusCode) + + // the block should be undone + block, err := suite.db.GetBlock(context.Background(), blockingAccount.ID, blockedAccount.ID) + suite.ErrorIs(err, db.ErrNoEntries) + suite.Nil(block) +} + +func (suite *InboxPostTestSuite) TestPostUpdate() { + updatedAccount := *suite.testAccounts["remote_account_1"] + updatedAccount.DisplayName = "updated display name!" + + // ad an emoji to the account; because we're serializing this remote + // account from our own instance, we need to cheat a bit to get the emoji + // to work properly, just for this test + testEmoji := &gtsmodel.Emoji{} + *testEmoji = *testrig.NewTestEmojis()["yell"] + testEmoji.ImageURL = testEmoji.ImageRemoteURL // <- here's the cheat + updatedAccount.Emojis = []*gtsmodel.Emoji{testEmoji} + + asAccount, err := suite.tc.AccountToAS(context.Background(), &updatedAccount) + suite.NoError(err) + + receivingAccount := suite.testAccounts["local_account_1"] + + // create an update + update := streams.NewActivityStreamsUpdate() + + // set the appropriate actor on it + updateActor := streams.NewActivityStreamsActorProperty() + updateActor.AppendIRI(testrig.URLMustParse(updatedAccount.URI)) + update.SetActivityStreamsActor(updateActor) + + // Set the account as the 'object' property. + updateObject := streams.NewActivityStreamsObjectProperty() + updateObject.AppendActivityStreamsPerson(asAccount) + update.SetActivityStreamsObject(updateObject) + + // Set the To of the update as public + updateTo := streams.NewActivityStreamsToProperty() + updateTo.AppendIRI(testrig.URLMustParse(pub.PublicActivityPubIRI)) + update.SetActivityStreamsTo(updateTo) + + // set the cc of the update to the receivingAccount + updateCC := streams.NewActivityStreamsCcProperty() + updateCC.AppendIRI(testrig.URLMustParse(receivingAccount.URI)) + update.SetActivityStreamsCc(updateCC) + + // set some random-ass ID for the activity + undoID := streams.NewJSONLDIdProperty() + undoID.SetIRI(testrig.URLMustParse("http://fossbros-anonymous.io/d360613a-dc8d-4563-8f0b-b6161caf0f2b")) + update.SetJSONLDId(undoID) + + targetURI := testrig.URLMustParse(receivingAccount.InboxURI) + + signature, digestHeader, dateHeader := testrig.GetSignatureForActivity(update, updatedAccount.PublicKeyURI, updatedAccount.PrivateKey, targetURI) + bodyI, err := streams.Serialize(update) + suite.NoError(err) + + bodyJson, err := json.Marshal(bodyI) + suite.NoError(err) + body := bytes.NewReader(bodyJson) + + clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) + fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) + + tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker) + federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager, fedWorker) + emailSender := testrig.NewEmailSender("../../../../web/template/", nil) + processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager, clientWorker, fedWorker) + userModule := users.New(processor) + suite.NoError(processor.Start()) + + // setup request + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Request = httptest.NewRequest(http.MethodPost, targetURI.String(), body) // the endpoint we're hitting + ctx.Request.Header.Set("Signature", signature) + ctx.Request.Header.Set("Date", dateHeader) + ctx.Request.Header.Set("Digest", digestHeader) + ctx.Request.Header.Set("Content-Type", "application/activity+json") + + // we need to pass the context through signature check first to set appropriate values on it + suite.signatureCheck(ctx) + + // 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: users.UsernameKey, + Value: receivingAccount.Username, + }, + } + + // trigger the function being tested + userModule.InboxPOSTHandler(ctx) + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + suite.NoError(err) + suite.Empty(b) + suite.Equal(http.StatusOK, result.StatusCode) + + // account should be changed in the database now + var dbUpdatedAccount *gtsmodel.Account + + if !testrig.WaitFor(func() bool { + // displayName should be updated + dbUpdatedAccount, _ = suite.db.GetAccountByID(context.Background(), updatedAccount.ID) + return dbUpdatedAccount.DisplayName == "updated display name!" + }) { + suite.FailNow("timed out waiting for account update") + } + + // emojis should be updated + suite.Contains(dbUpdatedAccount.EmojiIDs, testEmoji.ID) + + // account should be freshly webfingered + suite.WithinDuration(time.Now(), dbUpdatedAccount.LastWebfingeredAt, 10*time.Second) + + // everything else should be the same as it was before + suite.EqualValues(updatedAccount.Username, dbUpdatedAccount.Username) + suite.EqualValues(updatedAccount.Domain, dbUpdatedAccount.Domain) + suite.EqualValues(updatedAccount.AvatarMediaAttachmentID, dbUpdatedAccount.AvatarMediaAttachmentID) + suite.EqualValues(updatedAccount.AvatarMediaAttachment, dbUpdatedAccount.AvatarMediaAttachment) + suite.EqualValues(updatedAccount.AvatarRemoteURL, dbUpdatedAccount.AvatarRemoteURL) + suite.EqualValues(updatedAccount.HeaderMediaAttachmentID, dbUpdatedAccount.HeaderMediaAttachmentID) + suite.EqualValues(updatedAccount.HeaderMediaAttachment, dbUpdatedAccount.HeaderMediaAttachment) + suite.EqualValues(updatedAccount.HeaderRemoteURL, dbUpdatedAccount.HeaderRemoteURL) + suite.EqualValues(updatedAccount.Note, dbUpdatedAccount.Note) + suite.EqualValues(updatedAccount.Memorial, dbUpdatedAccount.Memorial) + suite.EqualValues(updatedAccount.AlsoKnownAs, dbUpdatedAccount.AlsoKnownAs) + suite.EqualValues(updatedAccount.MovedToAccountID, dbUpdatedAccount.MovedToAccountID) + suite.EqualValues(updatedAccount.Bot, dbUpdatedAccount.Bot) + suite.EqualValues(updatedAccount.Reason, dbUpdatedAccount.Reason) + suite.EqualValues(updatedAccount.Locked, dbUpdatedAccount.Locked) + suite.EqualValues(updatedAccount.Discoverable, dbUpdatedAccount.Discoverable) + suite.EqualValues(updatedAccount.Privacy, dbUpdatedAccount.Privacy) + suite.EqualValues(updatedAccount.Sensitive, dbUpdatedAccount.Sensitive) + suite.EqualValues(updatedAccount.Language, dbUpdatedAccount.Language) + suite.EqualValues(updatedAccount.URI, dbUpdatedAccount.URI) + suite.EqualValues(updatedAccount.URL, dbUpdatedAccount.URL) + suite.EqualValues(updatedAccount.InboxURI, dbUpdatedAccount.InboxURI) + suite.EqualValues(updatedAccount.OutboxURI, dbUpdatedAccount.OutboxURI) + suite.EqualValues(updatedAccount.FollowingURI, dbUpdatedAccount.FollowingURI) + suite.EqualValues(updatedAccount.FollowersURI, dbUpdatedAccount.FollowersURI) + suite.EqualValues(updatedAccount.FeaturedCollectionURI, dbUpdatedAccount.FeaturedCollectionURI) + suite.EqualValues(updatedAccount.ActorType, dbUpdatedAccount.ActorType) + suite.EqualValues(updatedAccount.PublicKey, dbUpdatedAccount.PublicKey) + suite.EqualValues(updatedAccount.PublicKeyURI, dbUpdatedAccount.PublicKeyURI) + suite.EqualValues(updatedAccount.SensitizedAt, dbUpdatedAccount.SensitizedAt) + suite.EqualValues(updatedAccount.SilencedAt, dbUpdatedAccount.SilencedAt) + suite.EqualValues(updatedAccount.SuspendedAt, dbUpdatedAccount.SuspendedAt) + suite.EqualValues(updatedAccount.HideCollections, dbUpdatedAccount.HideCollections) + suite.EqualValues(updatedAccount.SuspensionOrigin, dbUpdatedAccount.SuspensionOrigin) +} + +func (suite *InboxPostTestSuite) TestPostDelete() { + deletedAccount := *suite.testAccounts["remote_account_1"] + receivingAccount := suite.testAccounts["local_account_1"] + + // create a delete + delete := streams.NewActivityStreamsDelete() + + // set the appropriate actor on it + deleteActor := streams.NewActivityStreamsActorProperty() + deleteActor.AppendIRI(testrig.URLMustParse(deletedAccount.URI)) + delete.SetActivityStreamsActor(deleteActor) + + // Set the account iri as the 'object' property. + deleteObject := streams.NewActivityStreamsObjectProperty() + deleteObject.AppendIRI(testrig.URLMustParse(deletedAccount.URI)) + delete.SetActivityStreamsObject(deleteObject) + + // Set the To of the delete as public + deleteTo := streams.NewActivityStreamsToProperty() + deleteTo.AppendIRI(testrig.URLMustParse(pub.PublicActivityPubIRI)) + delete.SetActivityStreamsTo(deleteTo) + + // set some random-ass ID for the activity + deleteID := streams.NewJSONLDIdProperty() + deleteID.SetIRI(testrig.URLMustParse("http://fossbros-anonymous.io/d360613a-dc8d-4563-8f0b-b6161caf0f2b")) + delete.SetJSONLDId(deleteID) + + targetURI := testrig.URLMustParse(receivingAccount.InboxURI) + + signature, digestHeader, dateHeader := testrig.GetSignatureForActivity(delete, deletedAccount.PublicKeyURI, deletedAccount.PrivateKey, targetURI) + bodyI, err := streams.Serialize(delete) + suite.NoError(err) + + bodyJson, err := json.Marshal(bodyI) + suite.NoError(err) + body := bytes.NewReader(bodyJson) + + clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) + fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) + + tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker) + federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager, fedWorker) + emailSender := testrig.NewEmailSender("../../../../web/template/", nil) + processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager, clientWorker, fedWorker) + suite.NoError(processor.Start()) + userModule := users.New(processor) + + // setup request + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Request = httptest.NewRequest(http.MethodPost, targetURI.String(), body) // the endpoint we're hitting + ctx.Request.Header.Set("Signature", signature) + ctx.Request.Header.Set("Date", dateHeader) + ctx.Request.Header.Set("Digest", digestHeader) + ctx.Request.Header.Set("Content-Type", "application/activity+json") + + // we need to pass the context through signature check first to set appropriate values on it + suite.signatureCheck(ctx) + + // 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: users.UsernameKey, + Value: receivingAccount.Username, + }, + } + + // trigger the function being tested + userModule.InboxPOSTHandler(ctx) + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + suite.NoError(err) + suite.Empty(b) + suite.Equal(http.StatusOK, result.StatusCode) + + if !testrig.WaitFor(func() bool { + // local account 2 blocked foss_satan, that block should be gone now + testBlock := suite.testBlocks["local_account_2_block_remote_account_1"] + dbBlock := &gtsmodel.Block{} + err = suite.db.GetByID(ctx, testBlock.ID, dbBlock) + return suite.ErrorIs(err, db.ErrNoEntries) + }) { + suite.FailNow("timed out waiting for block to be removed") + } + + // no statuses from foss satan should be left in the database + dbStatuses, err := suite.db.GetAccountStatuses(ctx, deletedAccount.ID, 0, false, false, "", "", false, false, false) + suite.ErrorIs(err, db.ErrNoEntries) + suite.Empty(dbStatuses) + + dbAccount, err := suite.db.GetAccountByID(ctx, deletedAccount.ID) + suite.NoError(err) + + suite.Empty(dbAccount.Note) + suite.Empty(dbAccount.DisplayName) + suite.Empty(dbAccount.AvatarMediaAttachmentID) + suite.Empty(dbAccount.AvatarRemoteURL) + suite.Empty(dbAccount.HeaderMediaAttachmentID) + suite.Empty(dbAccount.HeaderRemoteURL) + suite.Empty(dbAccount.Reason) + suite.Empty(dbAccount.Fields) + suite.True(*dbAccount.HideCollections) + suite.False(*dbAccount.Discoverable) + suite.WithinDuration(time.Now(), dbAccount.SuspendedAt, 30*time.Second) + suite.Equal(dbAccount.ID, dbAccount.SuspensionOrigin) +} + +func TestInboxPostTestSuite(t *testing.T) { + suite.Run(t, &InboxPostTestSuite{}) +} diff --git a/internal/api/activitypub/users/outboxget.go b/internal/api/activitypub/users/outboxget.go @@ -0,0 +1,145 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 users + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" +) + +// OutboxGETHandler swagger:operation GET /users/{username}/outbox s2sOutboxGet +// +// Get the public outbox collection for an actor. +// +// Note that the response will be a Collection with a page as `first`, as shown below, if `page` is `false`. +// +// If `page` is `true`, then the response will be a single `CollectionPage` without the wrapping `Collection`. +// +// HTTP signature is required on the request. +// +// --- +// tags: +// - s2s/federation +// +// produces: +// - application/activity+json +// +// parameters: +// - +// name: username +// type: string +// description: Username of the account. +// in: path +// required: true +// - +// name: page +// type: boolean +// description: Return response as a CollectionPage. +// in: query +// default: false +// - +// name: min_id +// type: string +// description: Minimum ID of the next status, used for paging. +// in: query +// - +// name: max_id +// type: string +// description: Maximum ID of the next status, used for paging. +// in: query +// +// responses: +// '200': +// in: body +// schema: +// "$ref": "#/definitions/swaggerCollection" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '404': +// description: not found +func (m *Module) OutboxGETHandler(c *gin.Context) { + // usernames on our instance are always lowercase + requestedUsername := strings.ToLower(c.Param(UsernameKey)) + if requestedUsername == "" { + err := errors.New("no username specified in request") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + format, err := apiutil.NegotiateAccept(c, apiutil.HTMLOrActivityPubHeaders...) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + if format == string(apiutil.TextHTML) { + // redirect to the user's profile + c.Redirect(http.StatusSeeOther, "/@"+requestedUsername) + return + } + + var page bool + if pageString := c.Query(PageKey); pageString != "" { + i, err := strconv.ParseBool(pageString) + if err != nil { + err := fmt.Errorf("error parsing %s: %s", PageKey, err) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + page = i + } + + minID := "" + minIDString := c.Query(MinIDKey) + if minIDString != "" { + minID = minIDString + } + + maxID := "" + maxIDString := c.Query(MaxIDKey) + if maxIDString != "" { + maxID = maxIDString + } + + resp, errWithCode := m.processor.GetFediOutbox(apiutil.TransferSignatureContext(c), requestedUsername, page, maxID, minID, c.Request.URL) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + b, err := json.Marshal(resp) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) + return + } + + c.Data(http.StatusOK, format, b) +} diff --git a/internal/api/activitypub/users/outboxget_test.go b/internal/api/activitypub/users/outboxget_test.go @@ -0,0 +1,216 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 users_test + +import ( + "context" + "encoding/json" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/activity/streams" + "github.com/superseriousbusiness/activity/streams/vocab" + "github.com/superseriousbusiness/gotosocial/internal/api/activitypub/users" + "github.com/superseriousbusiness/gotosocial/internal/concurrency" + "github.com/superseriousbusiness/gotosocial/internal/messages" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type OutboxGetTestSuite struct { + UserStandardTestSuite +} + +func (suite *OutboxGetTestSuite) TestGetOutbox() { + // the dereference we're gonna use + derefRequests := testrig.NewTestDereferenceRequests(suite.testAccounts) + signedRequest := derefRequests["foss_satan_dereference_zork_outbox"] + targetAccount := suite.testAccounts["local_account_1"] + + // setup request + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Request = httptest.NewRequest(http.MethodGet, targetAccount.OutboxURI, nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/activity+json") + ctx.Request.Header.Set("Signature", signedRequest.SignatureHeader) + ctx.Request.Header.Set("Date", signedRequest.DateHeader) + + // we need to pass the context through signature check first to set appropriate values on it + suite.signatureCheck(ctx) + + // 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: users.UsernameKey, + Value: targetAccount.Username, + }, + } + + // trigger the function being tested + suite.userModule.OutboxGETHandler(ctx) + + // check response + suite.EqualValues(http.StatusOK, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + suite.NoError(err) + suite.Equal(`{"@context":"https://www.w3.org/ns/activitystreams","first":"http://localhost:8080/users/the_mighty_zork/outbox?page=true","id":"http://localhost:8080/users/the_mighty_zork/outbox","type":"OrderedCollection"}`, string(b)) + + m := make(map[string]interface{}) + err = json.Unmarshal(b, &m) + suite.NoError(err) + + t, err := streams.ToType(context.Background(), m) + suite.NoError(err) + + _, ok := t.(vocab.ActivityStreamsOrderedCollection) + suite.True(ok) +} + +func (suite *OutboxGetTestSuite) TestGetOutboxFirstPage() { + // the dereference we're gonna use + derefRequests := testrig.NewTestDereferenceRequests(suite.testAccounts) + signedRequest := derefRequests["foss_satan_dereference_zork_outbox_first"] + targetAccount := suite.testAccounts["local_account_1"] + + clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) + fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) + + tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker) + federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager, fedWorker) + emailSender := testrig.NewEmailSender("../../../../web/template/", nil) + processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager, clientWorker, fedWorker) + userModule := users.New(processor) + suite.NoError(processor.Start()) + + // setup request + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Request = httptest.NewRequest(http.MethodGet, targetAccount.OutboxURI+"?page=true", nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/activity+json") + ctx.Request.Header.Set("Signature", signedRequest.SignatureHeader) + ctx.Request.Header.Set("Date", signedRequest.DateHeader) + + // we need to pass the context through signature check first to set appropriate values on it + suite.signatureCheck(ctx) + + // 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: users.UsernameKey, + Value: targetAccount.Username, + }, + } + + // trigger the function being tested + userModule.OutboxGETHandler(ctx) + + // check response + suite.EqualValues(http.StatusOK, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + suite.NoError(err) + suite.Equal(`{"@context":"https://www.w3.org/ns/activitystreams","id":"http://localhost:8080/users/the_mighty_zork/outbox?page=true","next":"http://localhost:8080/users/the_mighty_zork/outbox?page=true\u0026max_id=01F8MHAMCHF6Y650WCRSCP4WMY","orderedItems":{"actor":"http://localhost:8080/users/the_mighty_zork","cc":"http://localhost:8080/users/the_mighty_zork/followers","id":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/activity","object":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY","published":"2021-10-20T10:40:37Z","to":"https://www.w3.org/ns/activitystreams#Public","type":"Create"},"partOf":"http://localhost:8080/users/the_mighty_zork/outbox","prev":"http://localhost:8080/users/the_mighty_zork/outbox?page=true\u0026min_id=01F8MHAMCHF6Y650WCRSCP4WMY","type":"OrderedCollectionPage"}`, string(b)) + + m := make(map[string]interface{}) + err = json.Unmarshal(b, &m) + suite.NoError(err) + + t, err := streams.ToType(context.Background(), m) + suite.NoError(err) + + _, ok := t.(vocab.ActivityStreamsOrderedCollectionPage) + suite.True(ok) +} + +func (suite *OutboxGetTestSuite) TestGetOutboxNextPage() { + // the dereference we're gonna use + derefRequests := testrig.NewTestDereferenceRequests(suite.testAccounts) + signedRequest := derefRequests["foss_satan_dereference_zork_outbox_next"] + targetAccount := suite.testAccounts["local_account_1"] + + clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) + fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) + + tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker) + federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager, fedWorker) + emailSender := testrig.NewEmailSender("../../../../web/template/", nil) + processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager, clientWorker, fedWorker) + userModule := users.New(processor) + suite.NoError(processor.Start()) + + // setup request + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Request = httptest.NewRequest(http.MethodGet, targetAccount.OutboxURI+"?page=true&max_id=01F8MHAMCHF6Y650WCRSCP4WMY", nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/activity+json") + ctx.Request.Header.Set("Signature", signedRequest.SignatureHeader) + ctx.Request.Header.Set("Date", signedRequest.DateHeader) + + // we need to pass the context through signature check first to set appropriate values on it + suite.signatureCheck(ctx) + + // 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: users.UsernameKey, + Value: targetAccount.Username, + }, + gin.Param{ + Key: users.MaxIDKey, + Value: "01F8MHAMCHF6Y650WCRSCP4WMY", + }, + } + + // trigger the function being tested + userModule.OutboxGETHandler(ctx) + + // check response + suite.EqualValues(http.StatusOK, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + suite.NoError(err) + suite.Equal(`{"@context":"https://www.w3.org/ns/activitystreams","id":"http://localhost:8080/users/the_mighty_zork/outbox?page=true\u0026maxID=01F8MHAMCHF6Y650WCRSCP4WMY","orderedItems":[],"partOf":"http://localhost:8080/users/the_mighty_zork/outbox","type":"OrderedCollectionPage"}`, string(b)) + + m := make(map[string]interface{}) + err = json.Unmarshal(b, &m) + suite.NoError(err) + + t, err := streams.ToType(context.Background(), m) + suite.NoError(err) + + _, ok := t.(vocab.ActivityStreamsOrderedCollectionPage) + suite.True(ok) +} + +func TestOutboxGetTestSuite(t *testing.T) { + suite.Run(t, new(OutboxGetTestSuite)) +} diff --git a/internal/api/activitypub/users/publickeyget.go b/internal/api/activitypub/users/publickeyget.go @@ -0,0 +1,71 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 users + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" +) + +// PublicKeyGETHandler should be served at eg https://example.org/users/:username/main-key. +// +// The goal here is to return a MINIMAL activitypub representation of an account +// in the form of a vocab.ActivityStreamsPerson. The account will only contain the id, +// public key, username, and type of the account. +func (m *Module) PublicKeyGETHandler(c *gin.Context) { + // usernames on our instance are always lowercase + requestedUsername := strings.ToLower(c.Param(UsernameKey)) + if requestedUsername == "" { + err := errors.New("no username specified in request") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + format, err := apiutil.NegotiateAccept(c, apiutil.HTMLOrActivityPubHeaders...) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + if format == string(apiutil.TextHTML) { + // redirect to the user's profile + c.Redirect(http.StatusSeeOther, "/@"+requestedUsername) + return + } + + resp, errWithCode := m.processor.GetFediUser(apiutil.TransferSignatureContext(c), requestedUsername, c.Request.URL) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + b, err := json.Marshal(resp) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) + return + } + + c.Data(http.StatusOK, format, b) +} diff --git a/internal/api/activitypub/users/repliesget.go b/internal/api/activitypub/users/repliesget.go @@ -0,0 +1,166 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 users + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" +) + +// StatusRepliesGETHandler swagger:operation GET /users/{username}/statuses/{status}/replies s2sRepliesGet +// +// Get the replies collection for a status. +// +// Note that the response will be a Collection with a page as `first`, as shown below, if `page` is `false`. +// +// If `page` is `true`, then the response will be a single `CollectionPage` without the wrapping `Collection`. +// +// HTTP signature is required on the request. +// +// --- +// tags: +// - s2s/federation +// +// produces: +// - application/activity+json +// +// parameters: +// - +// name: username +// type: string +// description: Username of the account. +// in: path +// required: true +// - +// name: status +// type: string +// description: ID of the status. +// in: path +// required: true +// - +// name: page +// type: boolean +// description: Return response as a CollectionPage. +// in: query +// default: false +// - +// name: only_other_accounts +// type: boolean +// description: Return replies only from accounts other than the status owner. +// in: query +// default: false +// - +// name: min_id +// type: string +// description: Minimum ID of the next status, used for paging. +// in: query +// +// responses: +// '200': +// in: body +// schema: +// "$ref": "#/definitions/swaggerCollection" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '404': +// description: not found +func (m *Module) StatusRepliesGETHandler(c *gin.Context) { + // usernames on our instance are always lowercase + requestedUsername := strings.ToLower(c.Param(UsernameKey)) + if requestedUsername == "" { + err := errors.New("no username specified in request") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + // status IDs on our instance are always uppercase + requestedStatusID := strings.ToUpper(c.Param(StatusIDKey)) + if requestedStatusID == "" { + err := errors.New("no status id specified in request") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + format, err := apiutil.NegotiateAccept(c, apiutil.HTMLOrActivityPubHeaders...) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + if format == string(apiutil.TextHTML) { + // redirect to the status + c.Redirect(http.StatusSeeOther, "/@"+requestedUsername+"/statuses/"+requestedStatusID) + return + } + + var page bool + if pageString := c.Query(PageKey); pageString != "" { + i, err := strconv.ParseBool(pageString) + if err != nil { + err := fmt.Errorf("error parsing %s: %s", PageKey, err) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + page = i + } + + onlyOtherAccounts := false + onlyOtherAccountsString := c.Query(OnlyOtherAccountsKey) + if onlyOtherAccountsString != "" { + i, err := strconv.ParseBool(onlyOtherAccountsString) + if err != nil { + err := fmt.Errorf("error parsing %s: %s", OnlyOtherAccountsKey, err) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + onlyOtherAccounts = i + } + + minID := "" + minIDString := c.Query(MinIDKey) + if minIDString != "" { + minID = minIDString + } + + resp, errWithCode := m.processor.GetFediStatusReplies(apiutil.TransferSignatureContext(c), requestedUsername, requestedStatusID, page, onlyOtherAccounts, minID, c.Request.URL) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + b, err := json.Marshal(resp) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) + return + } + + c.Data(http.StatusOK, format, b) +} diff --git a/internal/api/activitypub/users/repliesget_test.go b/internal/api/activitypub/users/repliesget_test.go @@ -0,0 +1,239 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 users_test + +import ( + "context" + "encoding/json" + "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/activity/streams" + "github.com/superseriousbusiness/activity/streams/vocab" + "github.com/superseriousbusiness/gotosocial/internal/api/activitypub/users" + "github.com/superseriousbusiness/gotosocial/internal/concurrency" + "github.com/superseriousbusiness/gotosocial/internal/messages" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type RepliesGetTestSuite struct { + UserStandardTestSuite +} + +func (suite *RepliesGetTestSuite) TestGetReplies() { + // the dereference we're gonna use + derefRequests := testrig.NewTestDereferenceRequests(suite.testAccounts) + signedRequest := derefRequests["foss_satan_dereference_local_account_1_status_1_replies"] + targetAccount := suite.testAccounts["local_account_1"] + targetStatus := suite.testStatuses["local_account_1_status_1"] + + // setup request + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Request = httptest.NewRequest(http.MethodGet, targetStatus.URI+"/replies", nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/activity+json") + ctx.Request.Header.Set("Signature", signedRequest.SignatureHeader) + ctx.Request.Header.Set("Date", signedRequest.DateHeader) + + // we need to pass the context through signature check first to set appropriate values on it + suite.signatureCheck(ctx) + + // 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: users.UsernameKey, + Value: targetAccount.Username, + }, + gin.Param{ + Key: users.StatusIDKey, + Value: targetStatus.ID, + }, + } + + // trigger the function being tested + suite.userModule.StatusRepliesGETHandler(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) + assert.Equal(suite.T(), `{"@context":"https://www.w3.org/ns/activitystreams","first":{"id":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies?page=true","next":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies?only_other_accounts=false\u0026page=true","partOf":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies","type":"CollectionPage"},"id":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies","type":"Collection"}`, string(b)) + + // should be a Collection + 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) + + _, ok := t.(vocab.ActivityStreamsCollection) + assert.True(suite.T(), ok) +} + +func (suite *RepliesGetTestSuite) TestGetRepliesNext() { + // the dereference we're gonna use + derefRequests := testrig.NewTestDereferenceRequests(suite.testAccounts) + signedRequest := derefRequests["foss_satan_dereference_local_account_1_status_1_replies_next"] + targetAccount := suite.testAccounts["local_account_1"] + targetStatus := suite.testStatuses["local_account_1_status_1"] + + clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) + fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) + + tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker) + federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager, fedWorker) + emailSender := testrig.NewEmailSender("../../../../web/template/", nil) + processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager, clientWorker, fedWorker) + userModule := users.New(processor) + suite.NoError(processor.Start()) + + // setup request + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Request = httptest.NewRequest(http.MethodGet, targetStatus.URI+"/replies?only_other_accounts=false&page=true", nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/activity+json") + ctx.Request.Header.Set("Signature", signedRequest.SignatureHeader) + ctx.Request.Header.Set("Date", signedRequest.DateHeader) + + // we need to pass the context through signature check first to set appropriate values on it + suite.signatureCheck(ctx) + + // 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: users.UsernameKey, + Value: targetAccount.Username, + }, + gin.Param{ + Key: users.StatusIDKey, + Value: targetStatus.ID, + }, + } + + // trigger the function being tested + userModule.StatusRepliesGETHandler(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) + + assert.Equal(suite.T(), `{"@context":"https://www.w3.org/ns/activitystreams","id":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies?page=true\u0026only_other_accounts=false","items":"http://localhost:8080/users/admin/statuses/01FF25D5Q0DH7CHD57CTRS6WK0","next":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies?only_other_accounts=false\u0026page=true\u0026min_id=01FF25D5Q0DH7CHD57CTRS6WK0","partOf":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies","type":"CollectionPage"}`, string(b)) + + // should be a Collection + 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) + + page, ok := t.(vocab.ActivityStreamsCollectionPage) + assert.True(suite.T(), ok) + + assert.Equal(suite.T(), page.GetActivityStreamsItems().Len(), 1) +} + +func (suite *RepliesGetTestSuite) TestGetRepliesLast() { + // the dereference we're gonna use + derefRequests := testrig.NewTestDereferenceRequests(suite.testAccounts) + signedRequest := derefRequests["foss_satan_dereference_local_account_1_status_1_replies_last"] + targetAccount := suite.testAccounts["local_account_1"] + targetStatus := suite.testStatuses["local_account_1_status_1"] + + clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) + fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) + + tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker) + federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager, fedWorker) + emailSender := testrig.NewEmailSender("../../../../web/template/", nil) + processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager, clientWorker, fedWorker) + userModule := users.New(processor) + suite.NoError(processor.Start()) + + // setup request + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Request = httptest.NewRequest(http.MethodGet, targetStatus.URI+"/replies?only_other_accounts=false&page=true&min_id=01FF25D5Q0DH7CHD57CTRS6WK0", nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/activity+json") + ctx.Request.Header.Set("Signature", signedRequest.SignatureHeader) + ctx.Request.Header.Set("Date", signedRequest.DateHeader) + + // we need to pass the context through signature check first to set appropriate values on it + suite.signatureCheck(ctx) + + // 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: users.UsernameKey, + Value: targetAccount.Username, + }, + gin.Param{ + Key: users.StatusIDKey, + Value: targetStatus.ID, + }, + } + + // trigger the function being tested + userModule.StatusRepliesGETHandler(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)) + assert.Equal(suite.T(), `{"@context":"https://www.w3.org/ns/activitystreams","id":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies?page=true\u0026only_other_accounts=false\u0026min_id=01FF25D5Q0DH7CHD57CTRS6WK0","items":[],"next":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies?only_other_accounts=false\u0026page=true","partOf":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies","type":"CollectionPage"}`, string(b)) + + // should be a Collection + 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) + + page, ok := t.(vocab.ActivityStreamsCollectionPage) + assert.True(suite.T(), ok) + + assert.Equal(suite.T(), page.GetActivityStreamsItems().Len(), 0) +} + +func TestRepliesGetTestSuite(t *testing.T) { + suite.Run(t, new(RepliesGetTestSuite)) +} diff --git a/internal/api/activitypub/users/statusget.go b/internal/api/activitypub/users/statusget.go @@ -0,0 +1,75 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 users + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" +) + +// StatusGETHandler serves the target status as an activitystreams NOTE so that other AP servers can parse it. +func (m *Module) StatusGETHandler(c *gin.Context) { + // usernames on our instance are always lowercase + requestedUsername := strings.ToLower(c.Param(UsernameKey)) + if requestedUsername == "" { + err := errors.New("no username specified in request") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + // status IDs on our instance are always uppercase + requestedStatusID := strings.ToUpper(c.Param(StatusIDKey)) + if requestedStatusID == "" { + err := errors.New("no status id specified in request") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + format, err := apiutil.NegotiateAccept(c, apiutil.HTMLOrActivityPubHeaders...) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + if format == string(apiutil.TextHTML) { + // redirect to the status + c.Redirect(http.StatusSeeOther, "/@"+requestedUsername+"/statuses/"+requestedStatusID) + return + } + + resp, errWithCode := m.processor.GetFediStatus(apiutil.TransferSignatureContext(c), requestedUsername, requestedStatusID, c.Request.URL) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + b, err := json.Marshal(resp) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) + return + } + + c.Data(http.StatusOK, format, b) +} diff --git a/internal/api/activitypub/users/statusget_test.go b/internal/api/activitypub/users/statusget_test.go @@ -0,0 +1,162 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 users_test + +import ( + "context" + "encoding/json" + "io/ioutil" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/activity/streams" + "github.com/superseriousbusiness/activity/streams/vocab" + "github.com/superseriousbusiness/gotosocial/internal/api/activitypub/users" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type StatusGetTestSuite struct { + UserStandardTestSuite +} + +func (suite *StatusGetTestSuite) TestGetStatus() { + // the dereference we're gonna use + derefRequests := testrig.NewTestDereferenceRequests(suite.testAccounts) + signedRequest := derefRequests["foss_satan_dereference_local_account_1_status_1"] + targetAccount := suite.testAccounts["local_account_1"] + targetStatus := suite.testStatuses["local_account_1_status_1"] + + // setup request + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Request = httptest.NewRequest(http.MethodGet, targetStatus.URI, nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/activity+json") + ctx.Request.Header.Set("Signature", signedRequest.SignatureHeader) + ctx.Request.Header.Set("Date", signedRequest.DateHeader) + + // we need to pass the context through signature check first to set appropriate values on it + suite.signatureCheck(ctx) + + // 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: users.UsernameKey, + Value: targetAccount.Username, + }, + gin.Param{ + Key: users.StatusIDKey, + Value: targetStatus.ID, + }, + } + + // trigger the function being tested + suite.userModule.StatusGETHandler(ctx) + + // check response + suite.EqualValues(http.StatusOK, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + suite.NoError(err) + + // should be a Note + m := make(map[string]interface{}) + err = json.Unmarshal(b, &m) + suite.NoError(err) + + t, err := streams.ToType(context.Background(), m) + suite.NoError(err) + + note, ok := t.(vocab.ActivityStreamsNote) + suite.True(ok) + + // convert note to status + a, err := suite.tc.ASStatusToStatus(context.Background(), note) + suite.NoError(err) + suite.EqualValues(targetStatus.Content, a.Content) +} + +func (suite *StatusGetTestSuite) TestGetStatusLowercase() { + // the dereference we're gonna use + derefRequests := testrig.NewTestDereferenceRequests(suite.testAccounts) + signedRequest := derefRequests["foss_satan_dereference_local_account_1_status_1_lowercase"] + targetAccount := suite.testAccounts["local_account_1"] + targetStatus := suite.testStatuses["local_account_1_status_1"] + + // setup request + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Request = httptest.NewRequest(http.MethodGet, strings.ToLower(targetStatus.URI), nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/activity+json") + ctx.Request.Header.Set("Signature", signedRequest.SignatureHeader) + ctx.Request.Header.Set("Date", signedRequest.DateHeader) + + // we need to pass the context through signature check first to set appropriate values on it + suite.signatureCheck(ctx) + + // 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: users.UsernameKey, + Value: strings.ToLower(targetAccount.Username), + }, + gin.Param{ + Key: users.StatusIDKey, + Value: strings.ToLower(targetStatus.ID), + }, + } + + // trigger the function being tested + suite.userModule.StatusGETHandler(ctx) + + // check response + suite.EqualValues(http.StatusOK, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + suite.NoError(err) + + // should be a Note + m := make(map[string]interface{}) + err = json.Unmarshal(b, &m) + suite.NoError(err) + + t, err := streams.ToType(context.Background(), m) + suite.NoError(err) + + note, ok := t.(vocab.ActivityStreamsNote) + suite.True(ok) + + // convert note to status + a, err := suite.tc.ASStatusToStatus(context.Background(), note) + suite.NoError(err) + suite.EqualValues(targetStatus.Content, a.Content) +} + +func TestStatusGetTestSuite(t *testing.T) { + suite.Run(t, new(StatusGetTestSuite)) +} diff --git a/internal/api/activitypub/users/user.go b/internal/api/activitypub/users/user.go @@ -0,0 +1,80 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 users + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/processing" + "github.com/superseriousbusiness/gotosocial/internal/uris" +) + +const ( + // UsernameKey is for account usernames. + UsernameKey = "username" + // StatusIDKey is for status IDs + StatusIDKey = "status" + // OnlyOtherAccountsKey is for filtering status responses. + OnlyOtherAccountsKey = "only_other_accounts" + // MinIDKey is for filtering status responses. + MinIDKey = "min_id" + // MaxIDKey is for filtering status responses. + MaxIDKey = "max_id" + // PageKey is for filtering status responses. + PageKey = "page" + + // BasePath is the base path for serving AP 'users' requests, minus the 'users' prefix. + BasePath = "/:" + UsernameKey + // PublicKeyPath is a path to a user's public key, for serving bare minimum AP representations. + PublicKeyPath = BasePath + "/" + uris.PublicKeyPath + // InboxPath is for serving POST requests to a user's inbox with the given username key. + InboxPath = BasePath + "/" + uris.InboxPath + // OutboxPath is for serving GET requests to a user's outbox with the given username key. + OutboxPath = BasePath + "/" + uris.OutboxPath + // FollowersPath is for serving GET request's to a user's followers list, with the given username key. + FollowersPath = BasePath + "/" + uris.FollowersPath + // FollowingPath is for serving GET request's to a user's following list, with the given username key. + FollowingPath = BasePath + "/" + uris.FollowingPath + // StatusPath is for serving GET requests to a particular status by a user, with the given username key and status ID + StatusPath = BasePath + "/" + uris.StatusesPath + "/:" + StatusIDKey + // StatusRepliesPath is for serving the replies collection of a status. + StatusRepliesPath = StatusPath + "/replies" +) + +type Module struct { + processor processing.Processor +} + +func New(processor processing.Processor) *Module { + return &Module{ + processor: processor, + } +} + +func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) { + attachHandler(http.MethodGet, BasePath, m.UsersGETHandler) + attachHandler(http.MethodPost, InboxPath, m.InboxPOSTHandler) + attachHandler(http.MethodGet, FollowersPath, m.FollowersGETHandler) + attachHandler(http.MethodGet, FollowingPath, m.FollowingGETHandler) + attachHandler(http.MethodGet, StatusPath, m.StatusGETHandler) + attachHandler(http.MethodGet, PublicKeyPath, m.PublicKeyGETHandler) + attachHandler(http.MethodGet, StatusRepliesPath, m.StatusRepliesGETHandler) + attachHandler(http.MethodGet, OutboxPath, m.OutboxGETHandler) +} diff --git a/internal/api/activitypub/users/user_test.go b/internal/api/activitypub/users/user_test.go @@ -0,0 +1,103 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 users_test + +import ( + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/activitypub/users" + "github.com/superseriousbusiness/gotosocial/internal/concurrency" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/email" + "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/messages" + "github.com/superseriousbusiness/gotosocial/internal/middleware" + "github.com/superseriousbusiness/gotosocial/internal/processing" + "github.com/superseriousbusiness/gotosocial/internal/storage" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type UserStandardTestSuite struct { + // standard suite interfaces + suite.Suite + db db.DB + tc typeutils.TypeConverter + mediaManager media.Manager + federator federation.Federator + emailSender email.Sender + processor processing.Processor + storage *storage.Driver + + // standard suite models + testTokens map[string]*gtsmodel.Token + testClients map[string]*gtsmodel.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 + testBlocks map[string]*gtsmodel.Block + + // module being tested + userModule *users.Module + + signatureCheck gin.HandlerFunc +} + +func (suite *UserStandardTestSuite) 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() + suite.testBlocks = testrig.NewTestBlocks() +} + +func (suite *UserStandardTestSuite) SetupTest() { + testrig.InitTestConfig() + testrig.InitTestLog() + + clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) + fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) + + suite.db = testrig.NewTestDB() + suite.tc = testrig.NewTestTypeConverter(suite.db) + suite.storage = testrig.NewInMemoryStorage() + suite.mediaManager = testrig.NewTestMediaManager(suite.db, suite.storage) + suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker), suite.storage, suite.mediaManager, fedWorker) + suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil) + suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, suite.mediaManager, clientWorker, fedWorker) + suite.userModule = users.New(suite.processor) + testrig.StandardDBSetup(suite.db, suite.testAccounts) + testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") + + suite.signatureCheck = middleware.SignatureCheck(suite.db.IsURIBlocked) + + suite.NoError(suite.processor.Start()) +} + +func (suite *UserStandardTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) +} diff --git a/internal/api/activitypub/users/userget.go b/internal/api/activitypub/users/userget.go @@ -0,0 +1,75 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 users + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" +) + +// 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) { + // usernames on our instance are always lowercase + requestedUsername := strings.ToLower(c.Param(UsernameKey)) + if requestedUsername == "" { + err := errors.New("no username specified in request") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + format, err := apiutil.NegotiateAccept(c, apiutil.HTMLOrActivityPubHeaders...) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + if format == string(apiutil.TextHTML) { + // redirect to the user's profile + c.Redirect(http.StatusSeeOther, "/@"+requestedUsername) + return + } + + resp, errWithCode := m.processor.GetFediUser(apiutil.TransferSignatureContext(c), requestedUsername, c.Request.URL) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + b, err := json.Marshal(resp) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) + return + } + + c.Data(http.StatusOK, format, b) +} diff --git a/internal/api/activitypub/users/userget_test.go b/internal/api/activitypub/users/userget_test.go @@ -0,0 +1,177 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 users_test + +import ( + "context" + "encoding/json" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/activity/streams" + "github.com/superseriousbusiness/activity/streams/vocab" + "github.com/superseriousbusiness/gotosocial/internal/api/activitypub/users" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type UserGetTestSuite struct { + UserStandardTestSuite +} + +func (suite *UserGetTestSuite) TestGetUser() { + // the dereference we're gonna use + derefRequests := testrig.NewTestDereferenceRequests(suite.testAccounts) + signedRequest := derefRequests["foss_satan_dereference_zork"] + targetAccount := suite.testAccounts["local_account_1"] + + // setup request + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Request = httptest.NewRequest(http.MethodGet, targetAccount.URI, nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/activity+json") + ctx.Request.Header.Set("Signature", signedRequest.SignatureHeader) + ctx.Request.Header.Set("Date", signedRequest.DateHeader) + + // we need to pass the context through signature check first to set appropriate values on it + suite.signatureCheck(ctx) + + // 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: users.UsernameKey, + Value: targetAccount.Username, + }, + } + + // trigger the function being tested + suite.userModule.UsersGETHandler(ctx) + + // check response + suite.EqualValues(http.StatusOK, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + suite.NoError(err) + + // should be a Person + m := make(map[string]interface{}) + err = json.Unmarshal(b, &m) + suite.NoError(err) + + t, err := streams.ToType(context.Background(), m) + suite.NoError(err) + + person, ok := t.(vocab.ActivityStreamsPerson) + suite.True(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(context.Background(), person, "", false) + suite.NoError(err) + suite.EqualValues(targetAccount.Username, a.Username) +} + +// TestGetUserPublicKeyDeleted checks whether the public key of a deleted account can still be dereferenced. +// This is needed by remote instances for authenticating delete requests and stuff like that. +func (suite *UserGetTestSuite) TestGetUserPublicKeyDeleted() { + userModule := users.New(suite.processor) + targetAccount := suite.testAccounts["local_account_1"] + + // first delete the account, as though zork had deleted himself + authed := &oauth.Auth{ + Application: suite.testApplications["local_account_1"], + User: suite.testUsers["local_account_1"], + Account: suite.testAccounts["local_account_1"], + } + suite.processor.AccountDeleteLocal(context.Background(), authed, &apimodel.AccountDeleteRequest{ + Password: "password", + DeleteOriginID: targetAccount.ID, + }) + + // wait for the account delete to be processed + if !testrig.WaitFor(func() bool { + a, _ := suite.db.GetAccountByID(context.Background(), targetAccount.ID) + return !a.SuspendedAt.IsZero() + }) { + suite.FailNow("delete of account timed out") + } + + // the dereference we're gonna use + derefRequests := testrig.NewTestDereferenceRequests(suite.testAccounts) + signedRequest := derefRequests["foss_satan_dereference_zork_public_key"] + + // setup request + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Request = httptest.NewRequest(http.MethodGet, targetAccount.PublicKeyURI, nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/activity+json") + ctx.Request.Header.Set("Signature", signedRequest.SignatureHeader) + ctx.Request.Header.Set("Date", signedRequest.DateHeader) + + // we need to pass the context through signature check first to set appropriate values on it + suite.signatureCheck(ctx) + + // 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: users.UsernameKey, + Value: targetAccount.Username, + }, + } + + // 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) + suite.NoError(err) + + // should be a Person + m := make(map[string]interface{}) + err = json.Unmarshal(b, &m) + suite.NoError(err) + + t, err := streams.ToType(context.Background(), m) + suite.NoError(err) + + person, ok := t.(vocab.ActivityStreamsPerson) + suite.True(ok) + + // convert person to account + a, err := suite.tc.ASRepresentationToAccount(context.Background(), person, "", false) + suite.NoError(err) + suite.EqualValues(targetAccount.Username, a.Username) +} + +func TestUserGetTestSuite(t *testing.T) { + suite.Run(t, new(UserGetTestSuite)) +} diff --git a/internal/api/apimodule.go b/internal/api/apimodule.go @@ -1,37 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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/auth.go b/internal/api/auth.go @@ -0,0 +1,64 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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/api/auth" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/middleware" + "github.com/superseriousbusiness/gotosocial/internal/oidc" + "github.com/superseriousbusiness/gotosocial/internal/processing" + "github.com/superseriousbusiness/gotosocial/internal/router" +) + +type Auth struct { + routerSession *gtsmodel.RouterSession + sessionName string + + auth *auth.Module +} + +// Route attaches 'auth' and 'oauth' groups to the given router. +func (a *Auth) Route(r router.Router) { + // create groupings for the 'auth' and 'oauth' prefixes + authGroup := r.AttachGroup("auth") + oauthGroup := r.AttachGroup("oauth") + + // instantiate + attach shared, non-global middlewares to both of these groups + var ( + rateLimitMiddleware = middleware.RateLimit() // nolint:contextcheck + gzipMiddleware = middleware.Gzip() + cacheControlMiddleware = middleware.CacheControl("private", "max-age=120") + sessionMiddleware = middleware.Session(a.sessionName, a.routerSession.Auth, a.routerSession.Crypt) + ) + authGroup.Use(rateLimitMiddleware, gzipMiddleware, cacheControlMiddleware, sessionMiddleware) + oauthGroup.Use(rateLimitMiddleware, gzipMiddleware, cacheControlMiddleware, sessionMiddleware) + + a.auth.RouteAuth(authGroup.Handle) + a.auth.RouteOauth(oauthGroup.Handle) +} + +func NewAuth(db db.DB, p processing.Processor, idp oidc.IDP, routerSession *gtsmodel.RouterSession, sessionName string) *Auth { + return &Auth{ + routerSession: routerSession, + sessionName: sessionName, + auth: auth.New(db, p, idp), + } +} diff --git a/internal/api/auth/auth.go b/internal/api/auth/auth.go @@ -0,0 +1,117 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/oidc" + "github.com/superseriousbusiness/gotosocial/internal/processing" +) + +const ( + /* + paths prefixed with 'auth' + */ + + // AuthSignInPath is the API path for users to sign in through + AuthSignInPath = "/sign_in" + // AuthCheckYourEmailPath users land here after registering a new account, instructs them to confirm their email + AuthCheckYourEmailPath = "/check_your_email" + // AuthWaitForApprovalPath users land here after confirming their email + // but before an admin approves their account (if such is required) + AuthWaitForApprovalPath = "/wait_for_approval" + // AuthAccountDisabledPath users land here when their account is suspended by an admin + AuthAccountDisabledPath = "/account_disabled" + // AuthCallbackPath is the API path for receiving callback tokens from external OIDC providers + AuthCallbackPath = "/callback" + + /* + paths prefixed with 'oauth' + */ + + // OauthTokenPath is the API path to use for granting token requests to users with valid credentials + OauthTokenPath = "/token" // #nosec G101 else we get a hardcoded credentials warning + // OauthAuthorizePath is the API path for authorization requests (eg., authorize this app to act on my behalf as a user) + OauthAuthorizePath = "/authorize" + // OauthFinalizePath is the API path for completing user registration with additional user details + OauthFinalizePath = "/finalize" + // OauthOobTokenPath is the path for serving an html representation of an oob token page. + OauthOobTokenPath = "/oob" // #nosec G101 else we get a hardcoded credentials warning + + /* + params / session keys + */ + + callbackStateParam = "state" + callbackCodeParam = "code" + sessionUserID = "userid" + sessionClientID = "client_id" + sessionRedirectURI = "redirect_uri" + sessionForceLogin = "force_login" + sessionResponseType = "response_type" + sessionScope = "scope" + sessionInternalState = "internal_state" + sessionClientState = "client_state" + sessionClaims = "claims" + sessionAppID = "app_id" +) + +type Module struct { + db db.DB + processor processing.Processor + idp oidc.IDP +} + +// New returns an Auth module which provides both 'oauth' and 'auth' endpoints. +// +// It is safe to pass a nil idp if oidc is disabled. +func New(db db.DB, processor processing.Processor, idp oidc.IDP) *Module { + return &Module{ + db: db, + processor: processor, + idp: idp, + } +} + +// RouteAuth routes all paths that should have an 'auth' prefix +func (m *Module) RouteAuth(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) { + attachHandler(http.MethodGet, AuthSignInPath, m.SignInGETHandler) + attachHandler(http.MethodPost, AuthSignInPath, m.SignInPOSTHandler) + attachHandler(http.MethodGet, AuthCallbackPath, m.CallbackGETHandler) +} + +// RouteOauth routes all paths that should have an 'oauth' prefix +func (m *Module) RouteOauth(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) { + attachHandler(http.MethodPost, OauthTokenPath, m.TokenPOSTHandler) + attachHandler(http.MethodGet, OauthAuthorizePath, m.AuthorizeGETHandler) + attachHandler(http.MethodPost, OauthAuthorizePath, m.AuthorizePOSTHandler) + attachHandler(http.MethodPost, OauthFinalizePath, m.FinalizePOSTHandler) + attachHandler(http.MethodGet, OauthOobTokenPath, m.OobHandler) +} + +func (m *Module) clearSession(s sessions.Session) { + s.Clear() + if err := s.Save(); err != nil { + panic(err) + } +} diff --git a/internal/api/auth/auth_test.go b/internal/api/auth/auth_test.go @@ -0,0 +1,129 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 ( + "bytes" + "fmt" + "net/http/httptest" + + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/memstore" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/auth" + "github.com/superseriousbusiness/gotosocial/internal/concurrency" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/email" + "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/messages" + "github.com/superseriousbusiness/gotosocial/internal/middleware" + "github.com/superseriousbusiness/gotosocial/internal/oidc" + "github.com/superseriousbusiness/gotosocial/internal/processing" + "github.com/superseriousbusiness/gotosocial/internal/storage" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type AuthStandardTestSuite struct { + suite.Suite + db db.DB + storage *storage.Driver + mediaManager media.Manager + federator federation.Federator + processor processing.Processor + emailSender email.Sender + idp oidc.IDP + + // standard suite models + testTokens map[string]*gtsmodel.Token + testClients map[string]*gtsmodel.Client + testApplications map[string]*gtsmodel.Application + testUsers map[string]*gtsmodel.User + testAccounts map[string]*gtsmodel.Account + + // module being tested + authModule *auth.Module +} + +const ( + sessionUserID = "userid" + sessionClientID = "client_id" +) + +func (suite *AuthStandardTestSuite) SetupSuite() { + suite.testTokens = testrig.NewTestTokens() + suite.testClients = testrig.NewTestClients() + suite.testApplications = testrig.NewTestApplications() + suite.testUsers = testrig.NewTestUsers() + suite.testAccounts = testrig.NewTestAccounts() +} + +func (suite *AuthStandardTestSuite) SetupTest() { + testrig.InitTestConfig() + testrig.InitTestLog() + + fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) + clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) + + suite.db = testrig.NewTestDB() + suite.storage = testrig.NewInMemoryStorage() + suite.mediaManager = testrig.NewTestMediaManager(suite.db, suite.storage) + suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../testrig/media"), suite.db, fedWorker), suite.storage, suite.mediaManager, fedWorker) + suite.emailSender = testrig.NewEmailSender("../../../web/template/", nil) + suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, suite.mediaManager, clientWorker, fedWorker) + suite.authModule = auth.New(suite.db, suite.processor, suite.idp) + testrig.StandardDBSetup(suite.db, suite.testAccounts) +} + +func (suite *AuthStandardTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) +} + +func (suite *AuthStandardTestSuite) newContext(requestMethod string, requestPath string, requestBody []byte, bodyContentType string) (*gin.Context, *httptest.ResponseRecorder) { + // create the recorder and gin test context + recorder := httptest.NewRecorder() + ctx, engine := testrig.CreateGinTestContext(recorder, nil) + + // load templates into the engine + testrig.ConfigureTemplatesWithGin(engine, "../../../web/template") + + // create the request + protocol := config.GetProtocol() + host := config.GetHost() + baseURI := fmt.Sprintf("%s://%s", protocol, host) + requestURI := fmt.Sprintf("%s/%s", baseURI, requestPath) + + ctx.Request = httptest.NewRequest(requestMethod, requestURI, bytes.NewReader(requestBody)) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "text/html") + + if bodyContentType != "" { + ctx.Request.Header.Set("Content-Type", bodyContentType) + } + + // trigger the session middleware on the context + store := memstore.NewStore(make([]byte, 32), make([]byte, 32)) + store.Options(middleware.SessionOptions()) + sessionMiddleware := sessions.Sessions("gotosocial-localhost", store) + sessionMiddleware(ctx) + + return ctx, recorder +} diff --git a/internal/api/auth/authorize.go b/internal/api/auth/authorize.go @@ -0,0 +1,335 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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/google/uuid" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// 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. +func (m *Module) AuthorizeGETHandler(c *gin.Context) { + s := sessions.Default(c) + + if _, err := apiutil.NegotiateAccept(c, apiutil.HTMLAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + // 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(sessionUserID).(string) + if !ok || userID == "" { + form := &apimodel.OAuthAuthorize{} + if err := c.ShouldBind(form); err != nil { + m.clearSession(s) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGet) + return + } + + if errWithCode := saveAuthFormToSession(s, form); errWithCode != nil { + m.clearSession(s) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + c.Redirect(http.StatusSeeOther, "/auth"+AuthSignInPath) + return + } + + // use session information to validate app, user, and account for this request + clientID, ok := s.Get(sessionClientID).(string) + if !ok || clientID == "" { + m.clearSession(s) + err := fmt.Errorf("key %s was not found in session", sessionClientID) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGet) + return + } + + app := &gtsmodel.Application{} + if err := m.db.GetWhere(c.Request.Context(), []db.Where{{Key: sessionClientID, Value: clientID}}, app); err != nil { + m.clearSession(s) + safe := fmt.Sprintf("application for %s %s could not be retrieved", sessionClientID, clientID) + var errWithCode gtserror.WithCode + if err == db.ErrNoEntries { + errWithCode = gtserror.NewErrorBadRequest(err, safe, oauth.HelpfulAdvice) + } else { + errWithCode = gtserror.NewErrorInternalError(err, safe, oauth.HelpfulAdvice) + } + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + user, err := m.db.GetUserByID(c.Request.Context(), userID) + if err != nil { + m.clearSession(s) + safe := fmt.Sprintf("user with id %s could not be retrieved", userID) + var errWithCode gtserror.WithCode + if err == db.ErrNoEntries { + errWithCode = gtserror.NewErrorBadRequest(err, safe, oauth.HelpfulAdvice) + } else { + errWithCode = gtserror.NewErrorInternalError(err, safe, oauth.HelpfulAdvice) + } + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + acct, err := m.db.GetAccountByID(c.Request.Context(), user.AccountID) + if err != nil { + m.clearSession(s) + safe := fmt.Sprintf("account with id %s could not be retrieved", user.AccountID) + var errWithCode gtserror.WithCode + if err == db.ErrNoEntries { + errWithCode = gtserror.NewErrorBadRequest(err, safe, oauth.HelpfulAdvice) + } else { + errWithCode = gtserror.NewErrorInternalError(err, safe, oauth.HelpfulAdvice) + } + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + if ensureUserIsAuthorizedOrRedirect(c, user, acct) { + return + } + + // Finally we should also get the redirect and scope of this particular request, as stored in the session. + redirect, ok := s.Get(sessionRedirectURI).(string) + if !ok || redirect == "" { + m.clearSession(s) + err := fmt.Errorf("key %s was not found in session", sessionRedirectURI) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGet) + return + } + + scope, ok := s.Get(sessionScope).(string) + if !ok || scope == "" { + m.clearSession(s) + err := fmt.Errorf("key %s was not found in session", sessionScope) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGet) + return + } + + instance, errWithCode := m.processor.InstanceGet(c.Request.Context(), config.GetHost()) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + 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 + c.HTML(http.StatusOK, "authorize.tmpl", gin.H{ + "appname": app.Name, + "appwebsite": app.Website, + "redirect": redirect, + "scope": scope, + "user": acct.Username, + "instance": instance, + }) +} + +// 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. +func (m *Module) AuthorizePOSTHandler(c *gin.Context) { + s := sessions.Default(c) + + // 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. + errs := []string{} + + forceLogin, ok := s.Get(sessionForceLogin).(string) + if !ok { + forceLogin = "false" + } + + responseType, ok := s.Get(sessionResponseType).(string) + if !ok || responseType == "" { + errs = append(errs, fmt.Sprintf("key %s was not found in session", sessionResponseType)) + } + + clientID, ok := s.Get(sessionClientID).(string) + if !ok || clientID == "" { + errs = append(errs, fmt.Sprintf("key %s was not found in session", sessionClientID)) + } + + redirectURI, ok := s.Get(sessionRedirectURI).(string) + if !ok || redirectURI == "" { + errs = append(errs, fmt.Sprintf("key %s was not found in session", sessionRedirectURI)) + } + + scope, ok := s.Get(sessionScope).(string) + if !ok { + errs = append(errs, fmt.Sprintf("key %s was not found in session", sessionScope)) + } + + var clientState string + if s, ok := s.Get(sessionClientState).(string); ok { + clientState = s + } + + userID, ok := s.Get(sessionUserID).(string) + if !ok { + errs = append(errs, fmt.Sprintf("key %s was not found in session", sessionUserID)) + } + + if len(errs) != 0 { + errs = append(errs, oauth.HelpfulAdvice) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(errors.New("one or more missing keys on session during AuthorizePOSTHandler"), errs...), m.processor.InstanceGet) + return + } + + user, err := m.db.GetUserByID(c.Request.Context(), userID) + if err != nil { + m.clearSession(s) + safe := fmt.Sprintf("user with id %s could not be retrieved", userID) + var errWithCode gtserror.WithCode + if err == db.ErrNoEntries { + errWithCode = gtserror.NewErrorBadRequest(err, safe, oauth.HelpfulAdvice) + } else { + errWithCode = gtserror.NewErrorInternalError(err, safe, oauth.HelpfulAdvice) + } + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + acct, err := m.db.GetAccountByID(c.Request.Context(), user.AccountID) + if err != nil { + m.clearSession(s) + safe := fmt.Sprintf("account with id %s could not be retrieved", user.AccountID) + var errWithCode gtserror.WithCode + if err == db.ErrNoEntries { + errWithCode = gtserror.NewErrorBadRequest(err, safe, oauth.HelpfulAdvice) + } else { + errWithCode = gtserror.NewErrorInternalError(err, safe, oauth.HelpfulAdvice) + } + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + if ensureUserIsAuthorizedOrRedirect(c, user, acct) { + return + } + + if redirectURI != oauth.OOBURI { + // we're done with the session now, so just clear it out + m.clearSession(s) + } + + // we have to set the values on the request form + // so that they're picked up by the oauth server + c.Request.Form = url.Values{ + sessionForceLogin: {forceLogin}, + sessionResponseType: {responseType}, + sessionClientID: {clientID}, + sessionRedirectURI: {redirectURI}, + sessionScope: {scope}, + sessionUserID: {userID}, + } + + if clientState != "" { + c.Request.Form.Set("state", clientState) + } + + if errWithCode := m.processor.OAuthHandleAuthorizeRequest(c.Writer, c.Request); errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + } +} + +// saveAuthFormToSession checks the given OAuthAuthorize form, +// and stores the values in the form into the session. +func saveAuthFormToSession(s sessions.Session, form *apimodel.OAuthAuthorize) gtserror.WithCode { + if form == nil { + err := errors.New("OAuthAuthorize form was nil") + return gtserror.NewErrorBadRequest(err, err.Error(), oauth.HelpfulAdvice) + } + + if form.ResponseType == "" { + err := errors.New("field response_type was not set on OAuthAuthorize form") + return gtserror.NewErrorBadRequest(err, err.Error(), oauth.HelpfulAdvice) + } + + if form.ClientID == "" { + err := errors.New("field client_id was not set on OAuthAuthorize form") + return gtserror.NewErrorBadRequest(err, err.Error(), oauth.HelpfulAdvice) + } + + if form.RedirectURI == "" { + err := errors.New("field redirect_uri was not set on OAuthAuthorize form") + return gtserror.NewErrorBadRequest(err, err.Error(), oauth.HelpfulAdvice) + } + + // 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(sessionForceLogin, form.ForceLogin) + s.Set(sessionResponseType, form.ResponseType) + s.Set(sessionClientID, form.ClientID) + s.Set(sessionRedirectURI, form.RedirectURI) + s.Set(sessionScope, form.Scope) + s.Set(sessionInternalState, uuid.NewString()) + s.Set(sessionClientState, form.State) + + if err := s.Save(); err != nil { + err := fmt.Errorf("error saving form values onto session: %s", err) + return gtserror.NewErrorInternalError(err, oauth.HelpfulAdvice) + } + + return nil +} + +func ensureUserIsAuthorizedOrRedirect(ctx *gin.Context, user *gtsmodel.User, account *gtsmodel.Account) (redirected bool) { + if user.ConfirmedAt.IsZero() { + ctx.Redirect(http.StatusSeeOther, "/auth"+AuthCheckYourEmailPath) + redirected = true + return + } + + if !*user.Approved { + ctx.Redirect(http.StatusSeeOther, "/auth"+AuthWaitForApprovalPath) + redirected = true + return + } + + if *user.Disabled || !account.SuspendedAt.IsZero() { + ctx.Redirect(http.StatusSeeOther, "/auth"+AuthAccountDisabledPath) + redirected = true + return + } + + return +} diff --git a/internal/api/auth/authorize_test.go b/internal/api/auth/authorize_test.go @@ -0,0 +1,118 @@ +package auth_test + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/gin-contrib/sessions" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/auth" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type AuthAuthorizeTestSuite struct { + AuthStandardTestSuite +} + +type authorizeHandlerTestCase struct { + description string + mutateUserAccount func(*gtsmodel.User, *gtsmodel.Account) []string + expectedStatusCode int + expectedLocationHeader string +} + +func (suite *AuthAuthorizeTestSuite) TestAccountAuthorizeHandler() { + tests := []authorizeHandlerTestCase{ + { + description: "user has their email unconfirmed", + mutateUserAccount: func(user *gtsmodel.User, account *gtsmodel.Account) []string { + user.ConfirmedAt = time.Time{} + return []string{"confirmed_at"} + }, + expectedStatusCode: http.StatusSeeOther, + expectedLocationHeader: "/auth" + auth.AuthCheckYourEmailPath, + }, + { + description: "user has their email confirmed but is not approved", + mutateUserAccount: func(user *gtsmodel.User, account *gtsmodel.Account) []string { + user.ConfirmedAt = time.Now() + user.Email = user.UnconfirmedEmail + return []string{"confirmed_at", "email"} + }, + expectedStatusCode: http.StatusSeeOther, + expectedLocationHeader: "/auth" + auth.AuthWaitForApprovalPath, + }, + { + description: "user has their email confirmed and is approved, but User entity has been disabled", + mutateUserAccount: func(user *gtsmodel.User, account *gtsmodel.Account) []string { + user.ConfirmedAt = time.Now() + user.Email = user.UnconfirmedEmail + user.Approved = testrig.TrueBool() + user.Disabled = testrig.TrueBool() + return []string{"confirmed_at", "email", "approved", "disabled"} + }, + expectedStatusCode: http.StatusSeeOther, + expectedLocationHeader: "/auth" + auth.AuthAccountDisabledPath, + }, + { + description: "user has their email confirmed and is approved, but Account entity has been suspended", + mutateUserAccount: func(user *gtsmodel.User, account *gtsmodel.Account) []string { + user.ConfirmedAt = time.Now() + user.Email = user.UnconfirmedEmail + user.Approved = testrig.TrueBool() + user.Disabled = testrig.FalseBool() + account.SuspendedAt = time.Now() + return []string{"confirmed_at", "email", "approved", "disabled"} + }, + expectedStatusCode: http.StatusSeeOther, + expectedLocationHeader: "/auth" + auth.AuthAccountDisabledPath, + }, + } + + doTest := func(testCase authorizeHandlerTestCase) { + ctx, recorder := suite.newContext(http.MethodGet, auth.OauthAuthorizePath, nil, "") + + user := &gtsmodel.User{} + account := &gtsmodel.Account{} + + *user = *suite.testUsers["unconfirmed_account"] + *account = *suite.testAccounts["unconfirmed_account"] + + testSession := sessions.Default(ctx) + testSession.Set(sessionUserID, user.ID) + testSession.Set(sessionClientID, suite.testApplications["application_1"].ClientID) + if err := testSession.Save(); err != nil { + panic(fmt.Errorf("failed on case %s: %w", testCase.description, err)) + } + + columns := testCase.mutateUserAccount(user, account) + + testCase.description = fmt.Sprintf("%s, %t, %s", user.Email, *user.Disabled, account.SuspendedAt) + + err := suite.db.UpdateUser(context.Background(), user, columns...) + suite.NoError(err) + err = suite.db.UpdateAccount(context.Background(), account) + suite.NoError(err) + + // call the handler + suite.authModule.AuthorizeGETHandler(ctx) + + // 1. we should have a redirect + suite.Equal(testCase.expectedStatusCode, recorder.Code, fmt.Sprintf("failed on case: %s", testCase.description)) + + // 2. we should have a redirect to the check your email path, as this user has not confirmed their email yet. + suite.Equal(testCase.expectedLocationHeader, recorder.Header().Get("Location"), fmt.Sprintf("failed on case: %s", testCase.description)) + } + + for _, testCase := range tests { + doTest(testCase) + } +} + +func TestAccountUpdateTestSuite(t *testing.T) { + suite.Run(t, new(AuthAuthorizeTestSuite)) +} diff --git a/internal/api/auth/callback.go b/internal/api/auth/callback.go @@ -0,0 +1,317 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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" + "errors" + "fmt" + "net" + "net/http" + "strings" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/oidc" + "github.com/superseriousbusiness/gotosocial/internal/validate" +) + +// extraInfo wraps a form-submitted username and transmitted name +type extraInfo struct { + Username string `form:"username"` + Name string `form:"name"` // note that this is only used for re-rendering the page in case of an error +} + +// CallbackGETHandler parses a token from an external auth provider. +func (m *Module) CallbackGETHandler(c *gin.Context) { + if !config.GetOIDCEnabled() { + err := errors.New("oidc is not enabled for this server") + apiutil.ErrorHandler(c, gtserror.NewErrorNotFound(err, err.Error()), m.processor.InstanceGet) + return + } + + s := sessions.Default(c) + + // check the query vs session state parameter to mitigate csrf + // https://auth0.com/docs/secure/attack-protection/state-parameters + + returnedInternalState := c.Query(callbackStateParam) + if returnedInternalState == "" { + m.clearSession(s) + err := fmt.Errorf("%s parameter not found on callback query", callbackStateParam) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + savedInternalStateI := s.Get(sessionInternalState) + savedInternalState, ok := savedInternalStateI.(string) + if !ok { + m.clearSession(s) + err := fmt.Errorf("key %s was not found in session", sessionInternalState) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + if returnedInternalState != savedInternalState { + m.clearSession(s) + err := errors.New("mismatch between callback state and saved state") + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + + // retrieve stored claims using code + code := c.Query(callbackCodeParam) + if code == "" { + m.clearSession(s) + err := fmt.Errorf("%s parameter not found on callback query", callbackCodeParam) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + claims, errWithCode := m.idp.HandleCallback(c.Request.Context(), code) + if errWithCode != nil { + m.clearSession(s) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + 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(sessionClientID).(string) + if !ok || clientID == "" { + m.clearSession(s) + err := fmt.Errorf("key %s was not found in session", sessionClientID) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGet) + return + } + + app := &gtsmodel.Application{} + if err := m.db.GetWhere(c.Request.Context(), []db.Where{{Key: sessionClientID, Value: clientID}}, app); err != nil { + m.clearSession(s) + safe := fmt.Sprintf("application for %s %s could not be retrieved", sessionClientID, clientID) + var errWithCode gtserror.WithCode + if err == db.ErrNoEntries { + errWithCode = gtserror.NewErrorBadRequest(err, safe, oauth.HelpfulAdvice) + } else { + errWithCode = gtserror.NewErrorInternalError(err, safe, oauth.HelpfulAdvice) + } + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + user, errWithCode := m.fetchUserForClaims(c.Request.Context(), claims, net.IP(c.ClientIP()), app.ID) + if errWithCode != nil { + m.clearSession(s) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + if user == nil { + // no user exists yet - let's ask them for their preferred username + instance, errWithCode := m.processor.InstanceGet(c.Request.Context(), config.GetHost()) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + // store the claims in the session - that way we know the user is authenticated when processing the form later + s.Set(sessionClaims, claims) + s.Set(sessionAppID, app.ID) + if err := s.Save(); err != nil { + m.clearSession(s) + apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) + return + } + c.HTML(http.StatusOK, "finalize.tmpl", gin.H{ + "instance": instance, + "name": claims.Name, + "preferredUsername": claims.PreferredUsername, + }) + return + } + s.Set(sessionUserID, user.ID) + if err := s.Save(); err != nil { + m.clearSession(s) + apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) + return + } + c.Redirect(http.StatusFound, "/oauth"+OauthAuthorizePath) +} + +// FinalizePOSTHandler registers the user after additional data has been provided +func (m *Module) FinalizePOSTHandler(c *gin.Context) { + s := sessions.Default(c) + + form := &extraInfo{} + if err := c.ShouldBind(form); err != nil { + m.clearSession(s) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGet) + return + } + + // since we have multiple possible validation error, `validationError` is a shorthand for rendering them + validationError := func(err error) { + instance, errWithCode := m.processor.InstanceGet(c.Request.Context(), config.GetHost()) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + c.HTML(http.StatusOK, "finalize.tmpl", gin.H{ + "instance": instance, + "name": form.Name, + "preferredUsername": form.Username, + "error": err, + }) + } + + // check if the username conforms to the spec + if err := validate.Username(form.Username); err != nil { + validationError(err) + return + } + + // see if the username is still available + usernameAvailable, err := m.db.IsUsernameAvailable(c.Request.Context(), form.Username) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGet) + return + } + if !usernameAvailable { + validationError(fmt.Errorf("Username %s is already taken", form.Username)) + return + } + + // retrieve the information previously set by the oidc logic + appID, ok := s.Get(sessionAppID).(string) + if !ok { + err := fmt.Errorf("key %s was not found in session", sessionAppID) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGet) + return + } + + // retrieve the claims returned by the IDP. Having this present means that we previously already verified these claims + claims, ok := s.Get(sessionClaims).(*oidc.Claims) + if !ok { + err := fmt.Errorf("key %s was not found in session", sessionClaims) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGet) + return + } + + // we're now ready to actually create the user + user, errWithCode := m.createUserFromOIDC(c.Request.Context(), claims, form, net.IP(c.ClientIP()), appID) + if errWithCode != nil { + m.clearSession(s) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + s.Delete(sessionClaims) + s.Delete(sessionAppID) + s.Set(sessionUserID, user.ID) + if err := s.Save(); err != nil { + m.clearSession(s) + apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) + return + } + c.Redirect(http.StatusFound, "/oauth"+OauthAuthorizePath) +} + +func (m *Module) fetchUserForClaims(ctx context.Context, claims *oidc.Claims, ip net.IP, appID string) (*gtsmodel.User, gtserror.WithCode) { + if claims.Sub == "" { + err := errors.New("no sub claim found - is your provider OIDC compliant?") + return nil, gtserror.NewErrorBadRequest(err, err.Error()) + } + user, err := m.db.GetUserByExternalID(ctx, claims.Sub) + if err == nil { + return user, nil + } + if err != db.ErrNoEntries { + err := fmt.Errorf("error checking database for externalID %s: %s", claims.Sub, err) + return nil, gtserror.NewErrorInternalError(err) + } + if !config.GetOIDCLinkExisting() { + return nil, nil + } + // fallback to email if we want to link existing users + user, err = m.db.GetUserByEmailAddress(ctx, claims.Email) + if err == db.ErrNoEntries { + return nil, nil + } else if err != nil { + err := fmt.Errorf("error checking database for email %s: %s", claims.Email, err) + return nil, gtserror.NewErrorInternalError(err) + } + // at this point we have found a matching user but still need to link the newly received external ID + + user.ExternalID = claims.Sub + err = m.db.UpdateUser(ctx, user, "external_id") + if err != nil { + err := fmt.Errorf("error linking existing user %s: %s", claims.Email, err) + return nil, gtserror.NewErrorInternalError(err) + } + return user, nil +} + +func (m *Module) createUserFromOIDC(ctx context.Context, claims *oidc.Claims, extraInfo *extraInfo, ip net.IP, appID string) (*gtsmodel.User, gtserror.WithCode) { + // check if the email address is available for use; if it's not there's nothing we can so + emailAvailable, err := m.db.IsEmailAvailable(ctx, claims.Email) + if err != nil { + return nil, gtserror.NewErrorBadRequest(err) + } + if !emailAvailable { + help := "The email address given to us by your authentication provider already exists in our records and the server administrator has not enabled account migration" + return nil, gtserror.NewErrorConflict(fmt.Errorf("email address %s is not available", claims.Email), help) + } + + // check if the user is in any recognised admin groups + var admin bool + for _, g := range claims.Groups { + if strings.EqualFold(g, "admin") || strings.EqualFold(g, "admins") { + admin = true + } + } + + // We still need to set *a* password even if it's not a password the user will end up using, so set something random. + // We'll just set two uuids on top of each other, which should be long + random enough to baffle any attempts to crack. + // + // If the user ever wants to log in using gts password rather than oidc flow, they'll have to request a password reset, which is fine + password := uuid.NewString() + uuid.NewString() + + // Since this user is created via oidc, which has been set up by the admin, we can assume that the account is already + // implicitly approved, and that the email address has already been verified: otherwise, we end up in situations where + // the admin first approves the user in OIDC, and then has to approve them again in GoToSocial, which doesn't make sense. + // + // In other words, if a user logs in via OIDC, they should be able to use their account straight away. + // + // See: https://github.com/superseriousbusiness/gotosocial/issues/357 + requireApproval := false + emailVerified := true + + // create the user! this will also create an account and store it in the database so we don't need to do that here + user, err := m.db.NewSignup(ctx, extraInfo.Username, "", requireApproval, claims.Email, password, ip, "", appID, emailVerified, claims.Sub, admin) + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + + return user, nil +} diff --git a/internal/api/auth/oob.go b/internal/api/auth/oob.go @@ -0,0 +1,113 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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" + "errors" + "fmt" + "net/http" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +func (m *Module) OobHandler(c *gin.Context) { + host := config.GetHost() + instance, errWithCode := m.processor.InstanceGet(c.Request.Context(), host) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + instanceGet := func(ctx context.Context, domain string) (*apimodel.Instance, gtserror.WithCode) { + return instance, nil + } + + oobToken := c.Query("code") + if oobToken == "" { + err := errors.New("no 'code' query value provided in callback redirect") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error(), oauth.HelpfulAdvice), instanceGet) + return + } + + s := sessions.Default(c) + + errs := []string{} + + scope, ok := s.Get(sessionScope).(string) + if !ok { + errs = append(errs, fmt.Sprintf("key %s was not found in session", sessionScope)) + } + + userID, ok := s.Get(sessionUserID).(string) + if !ok { + errs = append(errs, fmt.Sprintf("key %s was not found in session", sessionUserID)) + } + + if len(errs) != 0 { + errs = append(errs, oauth.HelpfulAdvice) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(errors.New("one or more missing keys on session during OobHandler"), errs...), m.processor.InstanceGet) + return + } + + user, err := m.db.GetUserByID(c.Request.Context(), userID) + if err != nil { + m.clearSession(s) + safe := fmt.Sprintf("user with id %s could not be retrieved", userID) + var errWithCode gtserror.WithCode + if err == db.ErrNoEntries { + errWithCode = gtserror.NewErrorBadRequest(err, safe, oauth.HelpfulAdvice) + } else { + errWithCode = gtserror.NewErrorInternalError(err, safe, oauth.HelpfulAdvice) + } + apiutil.ErrorHandler(c, errWithCode, instanceGet) + return + } + + acct, err := m.db.GetAccountByID(c.Request.Context(), user.AccountID) + if err != nil { + m.clearSession(s) + safe := fmt.Sprintf("account with id %s could not be retrieved", user.AccountID) + var errWithCode gtserror.WithCode + if err == db.ErrNoEntries { + errWithCode = gtserror.NewErrorBadRequest(err, safe, oauth.HelpfulAdvice) + } else { + errWithCode = gtserror.NewErrorInternalError(err, safe, oauth.HelpfulAdvice) + } + apiutil.ErrorHandler(c, errWithCode, instanceGet) + return + } + + // we're done with the session now, so just clear it out + m.clearSession(s) + + c.HTML(http.StatusOK, "oob.tmpl", gin.H{ + "instance": instance, + "user": acct.Username, + "oobToken": oobToken, + "scope": scope, + }) +} diff --git a/internal/api/auth/signin.go b/internal/api/auth/signin.go @@ -0,0 +1,145 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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" + "errors" + "fmt" + "net/http" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "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. +// If an idp provider is set, then the user will be redirected to that to do their sign in. +func (m *Module) SignInGETHandler(c *gin.Context) { + if _, err := apiutil.NegotiateAccept(c, apiutil.HTMLAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + if !config.GetOIDCEnabled() { + instance, errWithCode := m.processor.InstanceGet(c.Request.Context(), config.GetHost()) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + // no idp provider, use our own funky little sign in page + c.HTML(http.StatusOK, "sign-in.tmpl", gin.H{ + "instance": instance, + }) + return + } + + // idp provider is in use, so redirect to it + s := sessions.Default(c) + + internalStateI := s.Get(sessionInternalState) + internalState, ok := internalStateI.(string) + if !ok { + m.clearSession(s) + err := fmt.Errorf("key %s was not found in session", sessionInternalState) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + c.Redirect(http.StatusSeeOther, m.idp.AuthCodeURL(internalState)) +} + +// 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) { + s := sessions.Default(c) + + form := &login{} + if err := c.ShouldBind(form); err != nil { + m.clearSession(s) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGet) + return + } + + userid, errWithCode := m.ValidatePassword(c.Request.Context(), form.Email, form.Password) + if errWithCode != nil { + // don't clear session here, so the user can just press back and try again + // if they accidentally gave the wrong password or something + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + s.Set(sessionUserID, userid) + if err := s.Save(); err != nil { + err := fmt.Errorf("error saving user id onto session: %s", err) + apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err, oauth.HelpfulAdvice), m.processor.InstanceGet) + } + + c.Redirect(http.StatusFound, "/oauth"+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 ulid) 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(ctx context.Context, email string, password string) (string, gtserror.WithCode) { + if email == "" || password == "" { + err := errors.New("email or password was not provided") + return incorrectPassword(err) + } + + user, err := m.db.GetUserByEmailAddress(ctx, email) + if err != nil { + err := fmt.Errorf("user %s was not retrievable from db during oauth authorization attempt: %s", email, err) + return incorrectPassword(err) + } + + if user.EncryptedPassword == "" { + err := fmt.Errorf("encrypted password for user %s was empty for some reason", user.Email) + return incorrectPassword(err) + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.EncryptedPassword), []byte(password)); err != nil { + err := fmt.Errorf("password hash didn't match for user %s during login attempt: %s", user.Email, err) + return incorrectPassword(err) + } + + return user.ID, nil +} + +// incorrectPassword wraps the given error in a gtserror.WithCode, and returns +// only a generic 'safe' error message to the user, to not give any info away. +func incorrectPassword(err error) (string, gtserror.WithCode) { + safeErr := fmt.Errorf("password/email combination was incorrect") + return "", gtserror.NewErrorUnauthorized(err, safeErr.Error(), oauth.HelpfulAdvice) +} diff --git a/internal/api/auth/token.go b/internal/api/auth/token.go @@ -0,0 +1,115 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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" + "net/url" + + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + + "github.com/gin-gonic/gin" +) + +type tokenRequestForm struct { + GrantType *string `form:"grant_type" json:"grant_type" xml:"grant_type"` + Code *string `form:"code" json:"code" xml:"code"` + RedirectURI *string `form:"redirect_uri" json:"redirect_uri" xml:"redirect_uri"` + ClientID *string `form:"client_id" json:"client_id" xml:"client_id"` + ClientSecret *string `form:"client_secret" json:"client_secret" xml:"client_secret"` + Scope *string `form:"scope" json:"scope" xml:"scope"` +} + +// TokenPOSTHandler should be served as a POST at https://example.org/oauth/token +// The idea here is to serve an oauth access token to a user, which can be used for authorizing against non-public APIs. +func (m *Module) TokenPOSTHandler(c *gin.Context) { + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + help := []string{} + + form := &tokenRequestForm{} + if err := c.ShouldBind(form); err != nil { + apiutil.OAuthErrorHandler(c, gtserror.NewErrorBadRequest(oauth.InvalidRequest(), err.Error())) + return + } + + c.Request.Form = url.Values{} + + var grantType string + if form.GrantType != nil { + grantType = *form.GrantType + c.Request.Form.Set("grant_type", grantType) + } else { + help = append(help, "grant_type was not set in the token request form, but must be set to authorization_code or client_credentials") + } + + if form.ClientID != nil { + c.Request.Form.Set("client_id", *form.ClientID) + } else { + help = append(help, "client_id was not set in the token request form") + } + + if form.ClientSecret != nil { + c.Request.Form.Set("client_secret", *form.ClientSecret) + } else { + help = append(help, "client_secret was not set in the token request form") + } + + if form.RedirectURI != nil { + c.Request.Form.Set("redirect_uri", *form.RedirectURI) + } else { + help = append(help, "redirect_uri was not set in the token request form") + } + + var code string + if form.Code != nil { + if grantType != "authorization_code" { + help = append(help, "a code was provided in the token request form, but grant_type was not set to authorization_code") + } else { + code = *form.Code + c.Request.Form.Set("code", code) + } + } else if grantType == "authorization_code" { + help = append(help, "code was not set in the token request form, but must be set since grant_type is authorization_code") + } + + if form.Scope != nil { + c.Request.Form.Set("scope", *form.Scope) + } + + if len(help) != 0 { + apiutil.OAuthErrorHandler(c, gtserror.NewErrorBadRequest(oauth.InvalidRequest(), help...)) + return + } + + token, errWithCode := m.processor.OAuthHandleTokenRequest(c.Request) + if errWithCode != nil { + apiutil.OAuthErrorHandler(c, errWithCode) + return + } + + c.Header("Cache-Control", "no-store") + c.Header("Pragma", "no-cache") + c.JSON(http.StatusOK, token) +} diff --git a/internal/api/client/auth/token_test.go b/internal/api/auth/token_test.go diff --git a/internal/api/client.go b/internal/api/client.go @@ -0,0 +1,129 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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/api/client/accounts" + "github.com/superseriousbusiness/gotosocial/internal/api/client/admin" + "github.com/superseriousbusiness/gotosocial/internal/api/client/apps" + "github.com/superseriousbusiness/gotosocial/internal/api/client/blocks" + "github.com/superseriousbusiness/gotosocial/internal/api/client/bookmarks" + "github.com/superseriousbusiness/gotosocial/internal/api/client/customemojis" + "github.com/superseriousbusiness/gotosocial/internal/api/client/favourites" + filter "github.com/superseriousbusiness/gotosocial/internal/api/client/filters" + "github.com/superseriousbusiness/gotosocial/internal/api/client/followrequests" + "github.com/superseriousbusiness/gotosocial/internal/api/client/instance" + "github.com/superseriousbusiness/gotosocial/internal/api/client/lists" + "github.com/superseriousbusiness/gotosocial/internal/api/client/media" + "github.com/superseriousbusiness/gotosocial/internal/api/client/notifications" + "github.com/superseriousbusiness/gotosocial/internal/api/client/search" + "github.com/superseriousbusiness/gotosocial/internal/api/client/statuses" + "github.com/superseriousbusiness/gotosocial/internal/api/client/streaming" + "github.com/superseriousbusiness/gotosocial/internal/api/client/timelines" + "github.com/superseriousbusiness/gotosocial/internal/api/client/user" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/middleware" + "github.com/superseriousbusiness/gotosocial/internal/processing" + "github.com/superseriousbusiness/gotosocial/internal/router" +) + +type Client struct { + processor processing.Processor + db db.DB + + accounts *accounts.Module // api/v1/accounts + admin *admin.Module // api/v1/admin + apps *apps.Module // api/v1/apps + blocks *blocks.Module // api/v1/blocks + bookmarks *bookmarks.Module // api/v1/bookmarks + customEmojis *customemojis.Module // api/v1/custom_emojis + favourites *favourites.Module // api/v1/favourites + filters *filter.Module // api/v1/filters + followRequests *followrequests.Module // api/v1/follow_requests + instance *instance.Module // api/v1/instance + lists *lists.Module // api/v1/lists + media *media.Module // api/v1/media, api/v2/media + notifications *notifications.Module // api/v1/notifications + search *search.Module // api/v1/search, api/v2/search + statuses *statuses.Module // api/v1/statuses + streaming *streaming.Module // api/v1/streaming + timelines *timelines.Module // api/v1/timelines + user *user.Module // api/v1/user +} + +func (c *Client) Route(r router.Router) { + // create a new group on the top level client 'api' prefix + apiGroup := r.AttachGroup("api") + + // attach non-global middlewares appropriate to the client api + apiGroup.Use( + middleware.TokenCheck(c.db, c.processor.OAuthValidateBearerToken), + middleware.RateLimit(), + middleware.Gzip(), + middleware.CacheControl("no-store"), // never cache api responses + ) + + // for each client api module, pass it the Handle function + // so that the module can attach its routes to this group + h := apiGroup.Handle + c.accounts.Route(h) + c.admin.Route(h) + c.apps.Route(h) + c.blocks.Route(h) + c.bookmarks.Route(h) + c.customEmojis.Route(h) + c.favourites.Route(h) + c.filters.Route(h) + c.followRequests.Route(h) + c.instance.Route(h) + c.lists.Route(h) + c.media.Route(h) + c.notifications.Route(h) + c.search.Route(h) + c.statuses.Route(h) + c.streaming.Route(h) + c.timelines.Route(h) + c.user.Route(h) +} + +func NewClient(db db.DB, p processing.Processor) *Client { + return &Client{ + processor: p, + db: db, + + accounts: accounts.New(p), + admin: admin.New(p), + apps: apps.New(p), + blocks: blocks.New(p), + bookmarks: bookmarks.New(p), + customEmojis: customemojis.New(p), + favourites: favourites.New(p), + filters: filter.New(p), + followRequests: followrequests.New(p), + instance: instance.New(p), + lists: lists.New(p), + media: media.New(p), + notifications: notifications.New(p), + search: search.New(p), + statuses: statuses.New(p), + streaming: streaming.New(p), + timelines: timelines.New(p), + user: user.New(p), + } +} diff --git a/internal/api/client/account/account.go b/internal/api/client/account/account.go @@ -1,141 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/processing" - - "github.com/superseriousbusiness/gotosocial/internal/router" -) - -const ( - // LimitKey is for setting the return amount limit for eg., requesting an account's statuses - LimitKey = "limit" - // ExcludeRepliesKey is for specifying whether to exclude replies in a list of returned statuses by an account. - ExcludeRepliesKey = "exclude_replies" - // ExcludeReblogsKey is for specifying whether to exclude reblogs in a list of returned statuses by an account. - ExcludeReblogsKey = "exclude_reblogs" - // PinnedKey is for specifying whether to include pinned statuses in a list of returned statuses by an account. - PinnedKey = "pinned" - // MaxIDKey is for specifying the maximum ID of the status to retrieve. - MaxIDKey = "max_id" - // MinIDKey is for specifying the minimum ID of the status to retrieve. - MinIDKey = "min_id" - // OnlyMediaKey is for specifying that only statuses with media should be returned in a list of returned statuses by an account. - OnlyMediaKey = "only_media" - // OnlyPublicKey is for specifying that only statuses with visibility public should be returned in a list of returned statuses by account. - OnlyPublicKey = "only_public" - - // 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" - // GetStatusesPath is for showing an account's statuses - GetStatusesPath = BasePathWithID + "/statuses" - // GetFollowersPath is for showing an account's followers - GetFollowersPath = BasePathWithID + "/followers" - // GetFollowingPath is for showing account's that an account follows. - GetFollowingPath = BasePathWithID + "/following" - // GetRelationshipsPath is for showing an account's relationship with other accounts - GetRelationshipsPath = BasePath + "/relationships" - // FollowPath is for POSTing new follows to, and updating existing follows - FollowPath = BasePathWithID + "/follow" - // UnfollowPath is for POSTing an unfollow - UnfollowPath = BasePathWithID + "/unfollow" - // BlockPath is for creating a block of an account - BlockPath = BasePathWithID + "/block" - // UnblockPath is for removing a block of an account - UnblockPath = BasePathWithID + "/unblock" - // DeleteAccountPath is for deleting one's account via the API - DeleteAccountPath = BasePath + "/delete" -) - -// Module implements the ClientAPIModule interface for account-related actions -type Module struct { - processor processing.Processor -} - -// New returns a new account module -func New(processor processing.Processor) api.ClientModule { - return &Module{ - processor: processor, - } -} - -// Route attaches all routes from this module to the given router -func (m *Module) Route(r router.Router) error { - // create account - r.AttachHandler(http.MethodPost, BasePath, m.AccountCreatePOSTHandler) - - // delete account - r.AttachHandler(http.MethodPost, DeleteAccountPath, m.AccountDeletePOSTHandler) - - // get account - r.AttachHandler(http.MethodGet, BasePathWithID, m.muxHandler) - - // modify account - r.AttachHandler(http.MethodPatch, BasePathWithID, m.muxHandler) - - // get account's statuses - r.AttachHandler(http.MethodGet, GetStatusesPath, m.AccountStatusesGETHandler) - - // get following or followers - r.AttachHandler(http.MethodGet, GetFollowersPath, m.AccountFollowersGETHandler) - r.AttachHandler(http.MethodGet, GetFollowingPath, m.AccountFollowingGETHandler) - - // get relationship with account - r.AttachHandler(http.MethodGet, GetRelationshipsPath, m.AccountRelationshipsGETHandler) - - // follow or unfollow account - r.AttachHandler(http.MethodPost, FollowPath, m.AccountFollowPOSTHandler) - r.AttachHandler(http.MethodPost, UnfollowPath, m.AccountUnfollowPOSTHandler) - - // block or unblock account - r.AttachHandler(http.MethodPost, BlockPath, m.AccountBlockPOSTHandler) - r.AttachHandler(http.MethodPost, UnblockPath, m.AccountUnblockPOSTHandler) - - 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 @@ -1,127 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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" - "net/http" - "net/http/httptest" - - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/api/client/account" - "github.com/superseriousbusiness/gotosocial/internal/concurrency" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/email" - "github.com/superseriousbusiness/gotosocial/internal/federation" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/messages" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/processing" - "github.com/superseriousbusiness/gotosocial/internal/storage" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type AccountStandardTestSuite struct { - // standard suite interfaces - suite.Suite - db db.DB - storage *storage.Driver - mediaManager media.Manager - federator federation.Federator - processor processing.Processor - emailSender email.Sender - sentEmails map[string]string - - // standard suite models - testTokens map[string]*gtsmodel.Token - testClients map[string]*gtsmodel.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 -} - -func (suite *AccountStandardTestSuite) 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 *AccountStandardTestSuite) SetupTest() { - testrig.InitTestConfig() - testrig.InitTestLog() - - fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) - clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) - - suite.db = testrig.NewTestDB() - suite.storage = testrig.NewInMemoryStorage() - suite.mediaManager = testrig.NewTestMediaManager(suite.db, suite.storage) - suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker), suite.storage, suite.mediaManager, fedWorker) - suite.sentEmails = make(map[string]string) - suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails) - suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, suite.mediaManager, clientWorker, fedWorker) - suite.accountModule = account.New(suite.processor).(*account.Module) - testrig.StandardDBSetup(suite.db, nil) - testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") - - suite.NoError(suite.processor.Start()) -} - -func (suite *AccountStandardTestSuite) TearDownTest() { - testrig.StandardDBTeardown(suite.db) - testrig.StandardStorageTeardown(suite.storage) -} - -func (suite *AccountStandardTestSuite) newContext(recorder *httptest.ResponseRecorder, requestMethod string, requestBody []byte, requestPath string, bodyContentType string) *gin.Context { - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - - ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) - ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"])) - ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) - ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) - - protocol := config.GetProtocol() - host := config.GetHost() - - baseURI := fmt.Sprintf("%s://%s", protocol, host) - requestURI := fmt.Sprintf("%s/%s", baseURI, requestPath) - - ctx.Request = httptest.NewRequest(http.MethodPatch, requestURI, bytes.NewReader(requestBody)) // the endpoint we're hitting - - if bodyContentType != "" { - ctx.Request.Header.Set("Content-Type", bodyContentType) - } - - ctx.Request.Header.Set("accept", "application/json") - - return ctx -} diff --git a/internal/api/client/account/accountcreate.go b/internal/api/client/account/accountcreate.go @@ -1,150 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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" - "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/validate" -) - -// AccountCreatePOSTHandler swagger:operation POST /api/v1/accounts accountCreate -// -// Create a new account using an application token. -// -// The parameters can also be given in the body of the request, as JSON, if the content-type is set to 'application/json'. -// The parameters can also be given in the body of the request, as XML, if the content-type is set to 'application/xml'. -// -// --- -// tags: -// - accounts -// -// consumes: -// - application/json -// - application/xml -// - application/x-www-form-urlencoded -// -// produces: -// - application/json -// -// security: -// - OAuth2 Application: -// - write:accounts -// -// responses: -// '200': -// description: "An OAuth2 access token for the newly-created account." -// schema: -// "$ref": "#/definitions/oauthToken" -// '400': -// description: bad request -// '401': -// description: unauthorized -// '404': -// description: not found -// '406': -// description: not acceptable -// '500': -// description: internal server error -func (m *Module) AccountCreatePOSTHandler(c *gin.Context) { - authed, err := oauth.Authed(c, true, true, false, false) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) - return - } - - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - form := &model.AccountCreateRequest{} - if err := c.ShouldBind(form); err != nil { - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - if err := validateCreateAccount(form); err != nil { - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - clientIP := c.ClientIP() - signUpIP := net.ParseIP(clientIP) - if signUpIP == nil { - err := errors.New("ip address could not be parsed from request") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - form.IP = signUpIP - - ti, errWithCode := m.processor.AccountCreate(c.Request.Context(), authed, form) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - 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) error { - if form == nil { - return errors.New("form was nil") - } - - if !config.GetAccountsRegistrationOpen() { - return errors.New("registration is not open for this server") - } - - if err := validate.Username(form.Username); err != nil { - return err - } - - if err := validate.Email(form.Email); err != nil { - return err - } - - if err := validate.NewPassword(form.Password); err != nil { - return err - } - - if !form.Agreement { - return errors.New("agreement to terms and conditions not given") - } - - if err := validate.Language(form.Locale); err != nil { - return err - } - - if err := validate.SignUpReason(form.Reason, config.GetAccountsReasonRequired()); err != nil { - return err - } - - return nil -} diff --git a/internal/api/client/account/accountcreate_test.go b/internal/api/client/account/accountcreate_test.go @@ -1,19 +0,0 @@ -// /* -// GoToSocial -// Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. - -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public 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/account/accountdelete.go b/internal/api/client/account/accountdelete.go @@ -1,95 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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/http" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// AccountDeletePOSTHandler swagger:operation POST /api/v1/accounts/delete accountDelete -// -// Delete your account. -// -// --- -// tags: -// - accounts -// -// consumes: -// - multipart/form-data -// -// parameters: -// - -// name: password -// in: formData -// description: Password of the account user, for confirmation. -// type: string -// required: true -// -// security: -// - OAuth2 Bearer: -// - write:accounts -// -// responses: -// '202': -// description: "The account deletion has been accepted and the account will be deleted." -// '400': -// description: bad request -// '401': -// description: unauthorized -// '404': -// description: not found -// '406': -// description: not acceptable -// '500': -// description: internal server error -func (m *Module) AccountDeletePOSTHandler(c *gin.Context) { - authed, err := oauth.Authed(c, true, true, true, true) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) - return - } - - form := &model.AccountDeleteRequest{} - if err := c.ShouldBind(&form); err != nil { - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - if form.Password == "" { - err = errors.New("no password provided in account delete request") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - form.DeleteOriginID = authed.Account.ID - - if errWithCode := m.processor.AccountDeleteLocal(c.Request.Context(), authed, form); errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - c.JSON(http.StatusAccepted, gin.H{"message": "accepted"}) -} diff --git a/internal/api/client/account/accountdelete_test.go b/internal/api/client/account/accountdelete_test.go @@ -1,101 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 ( - "net/http" - "net/http/httptest" - "testing" - - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/api/client/account" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type AccountDeleteTestSuite struct { - AccountStandardTestSuite -} - -func (suite *AccountDeleteTestSuite) TestAccountDeletePOSTHandler() { - // set up the request - // we're deleting zork - requestBody, w, err := testrig.CreateMultipartFormData( - "", "", - map[string]string{ - "password": "password", - }) - if err != nil { - panic(err) - } - bodyBytes := requestBody.Bytes() - recorder := httptest.NewRecorder() - ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, account.DeleteAccountPath, w.FormDataContentType()) - - // call the handler - suite.accountModule.AccountDeletePOSTHandler(ctx) - - // 1. we should have Accepted because our request was valid - suite.Equal(http.StatusAccepted, recorder.Code) -} - -func (suite *AccountDeleteTestSuite) TestAccountDeletePOSTHandlerWrongPassword() { - // set up the request - // we're deleting zork - requestBody, w, err := testrig.CreateMultipartFormData( - "", "", - map[string]string{ - "password": "aaaaaaaaaaaaaaaaaaaaaaaaaaaa", - }) - if err != nil { - panic(err) - } - bodyBytes := requestBody.Bytes() - recorder := httptest.NewRecorder() - ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, account.DeleteAccountPath, w.FormDataContentType()) - - // call the handler - suite.accountModule.AccountDeletePOSTHandler(ctx) - - // 1. we should have Forbidden because we supplied the wrong password - suite.Equal(http.StatusForbidden, recorder.Code) -} - -func (suite *AccountDeleteTestSuite) TestAccountDeletePOSTHandlerNoPassword() { - // set up the request - // we're deleting zork - requestBody, w, err := testrig.CreateMultipartFormData( - "", "", - map[string]string{}) - if err != nil { - panic(err) - } - bodyBytes := requestBody.Bytes() - recorder := httptest.NewRecorder() - ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, account.DeleteAccountPath, w.FormDataContentType()) - - // call the handler - suite.accountModule.AccountDeletePOSTHandler(ctx) - - // 1. we should have StatusBadRequest because our request was invalid - suite.Equal(http.StatusBadRequest, recorder.Code) -} - -func TestAccountDeleteTestSuite(t *testing.T) { - suite.Run(t, new(AccountDeleteTestSuite)) -} diff --git a/internal/api/client/account/accountget.go b/internal/api/client/account/accountget.go @@ -1,95 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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/http" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// AccountGETHandler swagger:operation GET /api/v1/accounts/{id} accountGet -// -// Get information about an account with the given ID. -// -// --- -// tags: -// - accounts -// -// produces: -// - application/json -// -// parameters: -// - -// name: id -// type: string -// description: The id of the requested account. -// in: path -// required: true -// -// security: -// - OAuth2 Bearer: -// - read:accounts -// -// responses: -// '200': -// description: The requested account. -// schema: -// "$ref": "#/definitions/account" -// '400': -// description: bad request -// '401': -// description: unauthorized -// '404': -// description: not found -// '406': -// description: not acceptable -// '500': -// description: internal server error -func (m *Module) AccountGETHandler(c *gin.Context) { - authed, err := oauth.Authed(c, true, true, true, true) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) - return - } - - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - targetAcctID := c.Param(IDKey) - if targetAcctID == "" { - err := errors.New("no account id specified") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - acctInfo, errWithCode := m.processor.AccountGet(c.Request.Context(), authed, targetAcctID) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - c.JSON(http.StatusOK, acctInfo) -} diff --git a/internal/api/client/account/accountupdate.go b/internal/api/client/account/accountupdate.go @@ -1,216 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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/http" - "strconv" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// AccountUpdateCredentialsPATCHHandler swagger:operation PATCH /api/v1/accounts/update_credentials accountUpdate -// -// Update your account. -// -// --- -// tags: -// - accounts -// -// consumes: -// - multipart/form-data -// -// produces: -// - application/json -// -// parameters: -// - -// name: discoverable -// in: formData -// description: Account should be made discoverable and shown in the profile directory (if enabled). -// type: boolean -// - -// name: bot -// in: formData -// description: Account is flagged as a bot. -// type: boolean -// - -// name: display_name -// in: formData -// description: The display name to use for the account. -// type: string -// allowEmptyValue: true -// - -// name: note -// in: formData -// description: Bio/description of this account. -// type: string -// allowEmptyValue: true -// - -// name: avatar -// in: formData -// description: Avatar of the user. -// type: file -// - -// name: header -// in: formData -// description: Header of the user. -// type: file -// - -// name: locked -// in: formData -// description: Require manual approval of follow requests. -// type: boolean -// - -// name: source[privacy] -// in: formData -// description: Default post privacy for authored statuses. -// type: string -// - -// name: source[sensitive] -// in: formData -// description: Mark authored statuses as sensitive by default. -// type: boolean -// - -// name: source[language] -// in: formData -// description: Default language to use for authored statuses (ISO 6391). -// type: string -// - -// name: source[status_format] -// in: formData -// description: Default format to use for authored statuses (plain or markdown). -// type: string -// - -// name: custom_css -// in: formData -// description: >- -// Custom CSS to use when rendering this account's profile or statuses. -// String must be no more than 5,000 characters (~5kb). -// type: string -// - -// name: enable_rss -// in: formData -// description: Enable RSS feed for this account's Public posts at `/[username]/feed.rss` -// type: boolean -// -// security: -// - OAuth2 Bearer: -// - write:accounts -// -// responses: -// '200': -// description: "The newly updated account." -// schema: -// "$ref": "#/definitions/account" -// '400': -// description: bad request -// '401': -// description: unauthorized -// '404': -// description: not found -// '406': -// description: not acceptable -// '500': -// description: internal server error -func (m *Module) AccountUpdateCredentialsPATCHHandler(c *gin.Context) { - authed, err := oauth.Authed(c, true, true, true, true) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) - return - } - - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - form, err := parseUpdateAccountForm(c) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - acctSensitive, errWithCode := m.processor.AccountUpdate(c.Request.Context(), authed, form) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - c.JSON(http.StatusOK, acctSensitive) -} - -func parseUpdateAccountForm(c *gin.Context) (*model.UpdateCredentialsRequest, error) { - form := &model.UpdateCredentialsRequest{ - Source: &model.UpdateSource{}, - } - - if err := c.ShouldBind(&form); err != nil { - return nil, fmt.Errorf("could not parse form from request: %s", err) - } - - // parse source field-by-field - sourceMap := c.PostFormMap("source") - - if privacy, ok := sourceMap["privacy"]; ok { - form.Source.Privacy = &privacy - } - - if sensitive, ok := sourceMap["sensitive"]; ok { - sensitiveBool, err := strconv.ParseBool(sensitive) - if err != nil { - return nil, fmt.Errorf("error parsing form source[sensitive]: %s", err) - } - form.Source.Sensitive = &sensitiveBool - } - - if language, ok := sourceMap["language"]; ok { - form.Source.Language = &language - } - - if statusFormat, ok := sourceMap["status_format"]; ok { - form.Source.StatusFormat = &statusFormat - } - - if form == nil || - (form.Discoverable == nil && - form.Bot == nil && - form.DisplayName == nil && - form.Note == nil && - form.Avatar == nil && - form.Header == nil && - form.Locked == nil && - form.Source.Privacy == nil && - form.Source.Sensitive == nil && - form.Source.Language == nil && - form.Source.StatusFormat == nil && - form.FieldsAttributes == nil && - form.CustomCSS == nil && - form.EnableRSS == nil) { - return nil, errors.New("empty form submitted") - } - - return form, nil -} diff --git a/internal/api/client/account/accountupdate_test.go b/internal/api/client/account/accountupdate_test.go @@ -1,452 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 ( - "context" - "encoding/json" - "io/ioutil" - "net/http" - "net/http/httptest" - "testing" - - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/api/client/account" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type AccountUpdateTestSuite struct { - AccountStandardTestSuite -} - -func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandler() { - // set up the request - // we're updating the note of zork - newBio := "this is my new bio read it and weep" - requestBody, w, err := testrig.CreateMultipartFormData( - "", "", - map[string]string{ - "note": newBio, - }) - if err != nil { - panic(err) - } - bodyBytes := requestBody.Bytes() - recorder := httptest.NewRecorder() - ctx := suite.newContext(recorder, http.MethodPatch, bodyBytes, account.UpdateCredentialsPath, w.FormDataContentType()) - - // call the handler - suite.accountModule.AccountUpdateCredentialsPATCHHandler(ctx) - - // 1. we should have OK because our request was valid - suite.Equal(http.StatusOK, recorder.Code) - - // 2. we should have no error message in the result body - result := recorder.Result() - defer result.Body.Close() - - // check the response - b, err := ioutil.ReadAll(result.Body) - suite.NoError(err) - - // unmarshal the returned account - apimodelAccount := &apimodel.Account{} - err = json.Unmarshal(b, apimodelAccount) - suite.NoError(err) - - // check the returned api model account - // fields should be updated - suite.Equal("<p>this is my new bio read it and weep</p>", apimodelAccount.Note) - suite.Equal(newBio, apimodelAccount.Source.Note) -} - -func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandlerUnlockLock() { - // set up the first request - requestBody1, w1, err := testrig.CreateMultipartFormData( - "", "", - map[string]string{ - "locked": "false", - }) - if err != nil { - panic(err) - } - bodyBytes1 := requestBody1.Bytes() - recorder1 := httptest.NewRecorder() - ctx1 := suite.newContext(recorder1, http.MethodPatch, bodyBytes1, account.UpdateCredentialsPath, w1.FormDataContentType()) - - // call the handler - suite.accountModule.AccountUpdateCredentialsPATCHHandler(ctx1) - - // 1. we should have OK because our request was valid - suite.Equal(http.StatusOK, recorder1.Code) - - // 2. we should have no error message in the result body - result1 := recorder1.Result() - defer result1.Body.Close() - - // check the response - b1, err := ioutil.ReadAll(result1.Body) - suite.NoError(err) - - // unmarshal the returned account - apimodelAccount1 := &apimodel.Account{} - err = json.Unmarshal(b1, apimodelAccount1) - suite.NoError(err) - - // check the returned api model account - // fields should be updated - suite.False(apimodelAccount1.Locked) - - // set up the first request - requestBody2, w2, err := testrig.CreateMultipartFormData( - "", "", - map[string]string{ - "locked": "true", - }) - if err != nil { - panic(err) - } - bodyBytes2 := requestBody2.Bytes() - recorder2 := httptest.NewRecorder() - ctx2 := suite.newContext(recorder2, http.MethodPatch, bodyBytes2, account.UpdateCredentialsPath, w2.FormDataContentType()) - - // call the handler - suite.accountModule.AccountUpdateCredentialsPATCHHandler(ctx2) - - // 1. we should have OK because our request was valid - suite.Equal(http.StatusOK, recorder1.Code) - - // 2. we should have no error message in the result body - result2 := recorder2.Result() - defer result2.Body.Close() - - // check the response - b2, err := ioutil.ReadAll(result2.Body) - suite.NoError(err) - - // unmarshal the returned account - apimodelAccount2 := &apimodel.Account{} - err = json.Unmarshal(b2, apimodelAccount2) - suite.NoError(err) - - // check the returned api model account - // fields should be updated - suite.True(apimodelAccount2.Locked) -} - -func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandlerGetAccountFirst() { - // get the account first to make sure it's in the database cache -- when the account is updated via - // the PATCH handler, it should invalidate the cache and not return the old version - _, err := suite.db.GetAccountByID(context.Background(), suite.testAccounts["local_account_1"].ID) - suite.NoError(err) - - // set up the request - // we're updating the note of zork - newBio := "this is my new bio read it and weep" - requestBody, w, err := testrig.CreateMultipartFormData( - "", "", - map[string]string{ - "note": newBio, - }) - if err != nil { - panic(err) - } - bodyBytes := requestBody.Bytes() - recorder := httptest.NewRecorder() - ctx := suite.newContext(recorder, http.MethodPatch, bodyBytes, account.UpdateCredentialsPath, w.FormDataContentType()) - - // call the handler - suite.accountModule.AccountUpdateCredentialsPATCHHandler(ctx) - - // 1. we should have OK because our request was valid - suite.Equal(http.StatusOK, recorder.Code) - - // 2. we should have no error message in the result body - result := recorder.Result() - defer result.Body.Close() - - // check the response - b, err := ioutil.ReadAll(result.Body) - suite.NoError(err) - - // unmarshal the returned account - apimodelAccount := &apimodel.Account{} - err = json.Unmarshal(b, apimodelAccount) - suite.NoError(err) - - // check the returned api model account - // fields should be updated - suite.Equal("<p>this is my new bio read it and weep</p>", apimodelAccount.Note) - suite.Equal(newBio, apimodelAccount.Source.Note) -} - -func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandlerTwoFields() { - // set up the request - // we're updating the note of zork, and setting locked to true - newBio := "this is my new bio read it and weep :rainbow:" - requestBody, w, err := testrig.CreateMultipartFormData( - "", "", - map[string]string{ - "note": newBio, - "locked": "true", - }) - if err != nil { - panic(err) - } - bodyBytes := requestBody.Bytes() - recorder := httptest.NewRecorder() - ctx := suite.newContext(recorder, http.MethodPatch, bodyBytes, account.UpdateCredentialsPath, w.FormDataContentType()) - - // call the handler - suite.accountModule.AccountUpdateCredentialsPATCHHandler(ctx) - - // 1. we should have OK because our request was valid - suite.Equal(http.StatusOK, recorder.Code) - - // 2. we should have no error message in the result body - result := recorder.Result() - defer result.Body.Close() - - // check the response - b, err := ioutil.ReadAll(result.Body) - suite.NoError(err) - - // unmarshal the returned account - apimodelAccount := &apimodel.Account{} - err = json.Unmarshal(b, apimodelAccount) - suite.NoError(err) - - // check the returned api model account - // fields should be updated - suite.Equal("<p>this is my new bio read it and weep :rainbow:</p>", apimodelAccount.Note) - suite.Equal(newBio, apimodelAccount.Source.Note) - suite.True(apimodelAccount.Locked) - suite.NotEmpty(apimodelAccount.Emojis) - suite.Equal(apimodelAccount.Emojis[0].Shortcode, "rainbow") - - // check the account in the database - dbZork, err := suite.db.GetAccountByID(context.Background(), apimodelAccount.ID) - suite.NoError(err) - suite.Equal(newBio, dbZork.NoteRaw) - suite.Equal("<p>this is my new bio read it and weep :rainbow:</p>", dbZork.Note) - suite.True(*dbZork.Locked) - suite.NotEmpty(dbZork.EmojiIDs) -} - -func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandlerWithMedia() { - // set up the request - // we're updating the header image, the display name, and the locked status of zork - // we're removing the note/bio - requestBody, w, err := testrig.CreateMultipartFormData( - "header", "../../../../testrig/media/test-jpeg.jpg", - map[string]string{ - "display_name": "updated zork display name!!!", - "note": "", - "locked": "true", - }) - if err != nil { - panic(err) - } - bodyBytes := requestBody.Bytes() - recorder := httptest.NewRecorder() - ctx := suite.newContext(recorder, http.MethodPatch, bodyBytes, account.UpdateCredentialsPath, w.FormDataContentType()) - - // call the handler - suite.accountModule.AccountUpdateCredentialsPATCHHandler(ctx) - - // 1. we should have OK because our request was valid - suite.Equal(http.StatusOK, recorder.Code) - - // 2. we should have no error message in the result body - result := recorder.Result() - defer result.Body.Close() - - // check the response - b, err := ioutil.ReadAll(result.Body) - suite.NoError(err) - - // unmarshal the returned account - apimodelAccount := &apimodel.Account{} - err = json.Unmarshal(b, apimodelAccount) - suite.NoError(err) - - // check the returned api model account - // fields should be updated - suite.Equal("updated zork display name!!!", apimodelAccount.DisplayName) - suite.True(apimodelAccount.Locked) - suite.Empty(apimodelAccount.Note) - suite.Empty(apimodelAccount.Source.Note) - - // header values... - // should be set - suite.NotEmpty(apimodelAccount.Header) - suite.NotEmpty(apimodelAccount.HeaderStatic) - - // should be different from the values set before - suite.NotEqual("http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg", apimodelAccount.Header) - suite.NotEqual("http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg", apimodelAccount.HeaderStatic) -} - -func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandlerEmptyForm() { - // set up the request - bodyBytes := []byte{} - recorder := httptest.NewRecorder() - ctx := suite.newContext(recorder, http.MethodPatch, bodyBytes, account.UpdateCredentialsPath, "") - - // call the handler - suite.accountModule.AccountUpdateCredentialsPATCHHandler(ctx) - - // 1. we should have OK because our request was valid - suite.Equal(http.StatusBadRequest, recorder.Code) - - // 2. we should have no error message in the result body - result := recorder.Result() - defer result.Body.Close() - - // check the response - b, err := ioutil.ReadAll(result.Body) - suite.NoError(err) - suite.Equal(`{"error":"Bad Request: empty form submitted"}`, string(b)) -} - -func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandlerUpdateSource() { - // set up the request - // we're updating the language of zork - newLanguage := "de" - requestBody, w, err := testrig.CreateMultipartFormData( - "", "", - map[string]string{ - "source[privacy]": string(apimodel.VisibilityPrivate), - "source[language]": "de", - "source[sensitive]": "true", - "locked": "true", - }) - if err != nil { - panic(err) - } - bodyBytes := requestBody.Bytes() - recorder := httptest.NewRecorder() - ctx := suite.newContext(recorder, http.MethodPatch, bodyBytes, account.UpdateCredentialsPath, w.FormDataContentType()) - - // call the handler - suite.accountModule.AccountUpdateCredentialsPATCHHandler(ctx) - - // 1. we should have OK because our request was valid - suite.Equal(http.StatusOK, recorder.Code) - - // 2. we should have no error message in the result body - result := recorder.Result() - defer result.Body.Close() - - // check the response - b, err := ioutil.ReadAll(result.Body) - suite.NoError(err) - - // unmarshal the returned account - apimodelAccount := &apimodel.Account{} - err = json.Unmarshal(b, apimodelAccount) - suite.NoError(err) - - // check the returned api model account - // fields should be updated - suite.Equal(newLanguage, apimodelAccount.Source.Language) - suite.EqualValues(apimodel.VisibilityPrivate, apimodelAccount.Source.Privacy) - suite.True(apimodelAccount.Source.Sensitive) - suite.True(apimodelAccount.Locked) -} - -func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandlerUpdateStatusFormatOK() { - // set up the request - // we're updating the language of zork - requestBody, w, err := testrig.CreateMultipartFormData( - "", "", - map[string]string{ - "source[status_format]": "markdown", - }) - if err != nil { - panic(err) - } - bodyBytes := requestBody.Bytes() - recorder := httptest.NewRecorder() - ctx := suite.newContext(recorder, http.MethodPatch, bodyBytes, account.UpdateCredentialsPath, w.FormDataContentType()) - - // call the handler - suite.accountModule.AccountUpdateCredentialsPATCHHandler(ctx) - - // 1. we should have OK because our request was valid - suite.Equal(http.StatusOK, recorder.Code) - - // 2. we should have no error message in the result body - result := recorder.Result() - defer result.Body.Close() - - // check the response - b, err := ioutil.ReadAll(result.Body) - suite.NoError(err) - - // unmarshal the returned account - apimodelAccount := &apimodel.Account{} - err = json.Unmarshal(b, apimodelAccount) - suite.NoError(err) - - // check the returned api model account - // fields should be updated - suite.Equal("markdown", apimodelAccount.Source.StatusFormat) - - dbAccount, err := suite.db.GetAccountByID(context.Background(), suite.testAccounts["local_account_1"].ID) - if err != nil { - suite.FailNow(err.Error()) - } - suite.Equal(dbAccount.StatusFormat, "markdown") -} - -func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandlerUpdateStatusFormatBad() { - // set up the request - // we're updating the language of zork - requestBody, w, err := testrig.CreateMultipartFormData( - "", "", - map[string]string{ - "source[status_format]": "peepeepoopoo", - }) - if err != nil { - panic(err) - } - bodyBytes := requestBody.Bytes() - recorder := httptest.NewRecorder() - ctx := suite.newContext(recorder, http.MethodPatch, bodyBytes, account.UpdateCredentialsPath, w.FormDataContentType()) - - // call the handler - suite.accountModule.AccountUpdateCredentialsPATCHHandler(ctx) - - suite.Equal(http.StatusBadRequest, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - - // check the response - b, err := ioutil.ReadAll(result.Body) - suite.NoError(err) - - suite.Equal(`{"error":"Bad Request: status format 'peepeepoopoo' was not recognized, valid options are 'plain', 'markdown'"}`, string(b)) -} - -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 @@ -1,78 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// AccountVerifyGETHandler swagger:operation GET /api/v1/accounts/verify_credentials accountVerify -// -// Verify a token by returning account details pertaining to it. -// -// --- -// tags: -// - accounts -// -// produces: -// - application/json -// -// security: -// - OAuth2 Bearer: -// - read:accounts -// -// responses: -// '200': -// schema: -// "$ref": "#/definitions/account" -// '400': -// description: bad request -// '401': -// description: unauthorized -// '404': -// description: not found -// '406': -// description: not acceptable -// '500': -// description: internal server error -func (m *Module) AccountVerifyGETHandler(c *gin.Context) { - authed, err := oauth.Authed(c, true, true, true, true) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) - return - } - - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - acctSensitive, errWithCode := m.processor.AccountGet(c.Request.Context(), authed, authed.Account.ID) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - c.JSON(http.StatusOK, acctSensitive) -} diff --git a/internal/api/client/account/accountverify_test.go b/internal/api/client/account/accountverify_test.go @@ -1,91 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 ( - "encoding/json" - "io/ioutil" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/api/client/account" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" -) - -type AccountVerifyTestSuite struct { - AccountStandardTestSuite -} - -func (suite *AccountVerifyTestSuite) TestAccountVerifyGet() { - testAccount := suite.testAccounts["local_account_1"] - - // set up the request - recorder := httptest.NewRecorder() - ctx := suite.newContext(recorder, http.MethodGet, nil, account.VerifyPath, "") - - // call the handler - suite.accountModule.AccountVerifyGETHandler(ctx) - - // 1. we should have OK because our request was valid - suite.Equal(http.StatusOK, recorder.Code) - - // 2. we should have no error message in the result body - result := recorder.Result() - defer result.Body.Close() - - // check the response - b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) - - // unmarshal the returned account - apimodelAccount := &apimodel.Account{} - err = json.Unmarshal(b, apimodelAccount) - suite.NoError(err) - - createdAt, err := time.Parse(time.RFC3339, apimodelAccount.CreatedAt) - suite.NoError(err) - - suite.Equal(testAccount.ID, apimodelAccount.ID) - suite.Equal(testAccount.Username, apimodelAccount.Username) - suite.Equal(testAccount.Username, apimodelAccount.Acct) - suite.Equal(testAccount.DisplayName, apimodelAccount.DisplayName) - suite.Equal(*testAccount.Locked, apimodelAccount.Locked) - suite.Equal(*testAccount.Bot, apimodelAccount.Bot) - suite.WithinDuration(testAccount.CreatedAt, createdAt, 30*time.Second) // we lose a bit of accuracy serializing so fuzz this a bit - suite.Equal(testAccount.URL, apimodelAccount.URL) - suite.Equal("http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpeg", apimodelAccount.Avatar) - suite.Equal("http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpeg", apimodelAccount.AvatarStatic) - suite.Equal("http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg", apimodelAccount.Header) - suite.Equal("http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg", apimodelAccount.HeaderStatic) - suite.Equal(2, apimodelAccount.FollowersCount) - suite.Equal(2, apimodelAccount.FollowingCount) - suite.Equal(5, apimodelAccount.StatusesCount) - suite.EqualValues(gtsmodel.VisibilityPublic, apimodelAccount.Source.Privacy) - suite.Equal(testAccount.Language, apimodelAccount.Source.Language) - suite.Equal(testAccount.NoteRaw, apimodelAccount.Source.Note) -} - -func TestAccountVerifyTestSuite(t *testing.T) { - suite.Run(t, new(AccountVerifyTestSuite)) -} diff --git a/internal/api/client/account/block.go b/internal/api/client/account/block.go @@ -1,95 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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/http" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// AccountBlockPOSTHandler swagger:operation POST /api/v1/accounts/{id}/block accountBlock -// -// Block account with id. -// -// --- -// tags: -// - accounts -// -// produces: -// - application/json -// -// parameters: -// - -// name: id -// type: string -// description: The id of the account to block. -// in: path -// required: true -// -// security: -// - OAuth2 Bearer: -// - write:blocks -// -// responses: -// '200': -// description: Your relationship to the account. -// schema: -// "$ref": "#/definitions/accountRelationship" -// '400': -// description: bad request -// '401': -// description: unauthorized -// '404': -// description: not found -// '406': -// description: not acceptable -// '500': -// description: internal server error -func (m *Module) AccountBlockPOSTHandler(c *gin.Context) { - authed, err := oauth.Authed(c, true, true, true, true) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) - return - } - - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - targetAcctID := c.Param(IDKey) - if targetAcctID == "" { - err := errors.New("no account id specified") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - relationship, errWithCode := m.processor.AccountBlockCreate(c.Request.Context(), authed, targetAcctID) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - c.JSON(http.StatusOK, relationship) -} diff --git a/internal/api/client/account/block_test.go b/internal/api/client/account/block_test.go @@ -1,74 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 ( - "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/account" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type BlockTestSuite struct { - AccountStandardTestSuite -} - -func (suite *BlockTestSuite) TestBlockSelf() { - testAcct := suite.testAccounts["local_account_1"] - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - ctx.Set(oauth.SessionAuthorizedAccount, testAcct) - ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"])) - ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) - ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(account.BlockPath, ":id", testAcct.ID, 1)), nil) - - ctx.Params = gin.Params{ - gin.Param{ - Key: account.IDKey, - Value: testAcct.ID, - }, - } - - suite.accountModule.AccountBlockPOSTHandler(ctx) - - // 1. status should be Not Acceptable due to attempted self-block - suite.Equal(http.StatusNotAcceptable, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - - // check the response - b, err := ioutil.ReadAll(result.Body) - _ = b - assert.NoError(suite.T(), err) -} - -func TestBlockTestSuite(t *testing.T) { - suite.Run(t, new(BlockTestSuite)) -} diff --git a/internal/api/client/account/follow.go b/internal/api/client/account/follow.go @@ -1,124 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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/http" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// AccountFollowPOSTHandler swagger:operation POST /api/v1/accounts/{id}/follow accountFollow -// -// Follow account with id. -// -// The parameters can also be given in the body of the request, as JSON, if the content-type is set to 'application/json'. -// The parameters can also be given in the body of the request, as XML, if the content-type is set to 'application/xml'. -// -// --- -// tags: -// - accounts -// -// consumes: -// - application/json -// - application/xml -// - application/x-www-form-urlencoded -// -// parameters: -// - -// name: id -// required: true -// in: path -// description: ID of the account to follow. -// type: string -// - -// name: reblogs -// type: boolean -// default: true -// description: Show reblogs from this account. -// in: formData -// - -// default: false -// description: Notify when this account posts. -// in: formData -// name: notify -// type: boolean -// -// produces: -// - application/json -// -// security: -// - OAuth2 Bearer: -// - write:follows -// -// responses: -// '200': -// name: account relationship -// description: Your relationship to this account. -// schema: -// "$ref": "#/definitions/accountRelationship" -// '400': -// description: bad request -// '401': -// description: unauthorized -// '404': -// description: not found -// '406': -// description: not acceptable -// '500': -// description: internal server error -func (m *Module) AccountFollowPOSTHandler(c *gin.Context) { - authed, err := oauth.Authed(c, true, true, true, true) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) - return - } - - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - targetAcctID := c.Param(IDKey) - if targetAcctID == "" { - err := errors.New("no account id specified") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - form := &model.AccountFollowRequest{} - if err := c.ShouldBind(form); err != nil { - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - form.ID = targetAcctID - - relationship, errWithCode := m.processor.AccountFollowCreate(c.Request.Context(), authed, form) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - c.JSON(http.StatusOK, relationship) -} diff --git a/internal/api/client/account/follow_test.go b/internal/api/client/account/follow_test.go @@ -1,75 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 ( - "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/account" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type FollowTestSuite struct { - AccountStandardTestSuite -} - -func (suite *FollowTestSuite) TestFollowSelf() { - testAcct := suite.testAccounts["local_account_1"] - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - ctx.Set(oauth.SessionAuthorizedAccount, testAcct) - ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"])) - ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) - ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(account.FollowPath, ":id", testAcct.ID, 1)), nil) - - ctx.Params = gin.Params{ - gin.Param{ - Key: account.IDKey, - Value: testAcct.ID, - }, - } - - // call the handler - suite.accountModule.AccountFollowPOSTHandler(ctx) - - // 1. status should be Not Acceptable due to self-follow attempt - suite.Equal(http.StatusNotAcceptable, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - - // check the response - b, err := ioutil.ReadAll(result.Body) - _ = b - assert.NoError(suite.T(), err) -} - -func TestFollowTestSuite(t *testing.T) { - suite.Run(t, new(FollowTestSuite)) -} diff --git a/internal/api/client/account/followers.go b/internal/api/client/account/followers.go @@ -1,98 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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/http" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// AccountFollowersGETHandler swagger:operation GET /api/v1/accounts/{id}/followers accountFollowers -// -// See followers of account with given id. -// -// --- -// tags: -// - accounts -// -// produces: -// - application/json -// -// parameters: -// - -// name: id -// type: string -// description: Account ID. -// in: path -// required: true -// -// security: -// - OAuth2 Bearer: -// - read:accounts -// -// responses: -// '200': -// name: accounts -// description: Array of accounts that follow this account. -// schema: -// type: array -// items: -// "$ref": "#/definitions/account" -// '400': -// description: bad request -// '401': -// description: unauthorized -// '404': -// description: not found -// '406': -// description: not acceptable -// '500': -// description: internal server error -func (m *Module) AccountFollowersGETHandler(c *gin.Context) { - authed, err := oauth.Authed(c, true, true, true, true) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) - return - } - - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - targetAcctID := c.Param(IDKey) - if targetAcctID == "" { - err := errors.New("no account id specified") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - followers, errWithCode := m.processor.AccountFollowersGet(c.Request.Context(), authed, targetAcctID) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - c.JSON(http.StatusOK, followers) -} diff --git a/internal/api/client/account/following.go b/internal/api/client/account/following.go @@ -1,98 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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/http" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// AccountFollowingGETHandler swagger:operation GET /api/v1/accounts/{id}/following accountFollowing -// -// See accounts followed by given account id. -// -// --- -// tags: -// - accounts -// -// produces: -// - application/json -// -// parameters: -// - -// name: id -// type: string -// description: Account ID. -// in: path -// required: true -// -// security: -// - OAuth2 Bearer: -// - read:accounts -// -// responses: -// '200': -// name: accounts -// description: Array of accounts that are followed by this account. -// schema: -// type: array -// items: -// "$ref": "#/definitions/account" -// '400': -// description: bad request -// '401': -// description: unauthorized -// '404': -// description: not found -// '406': -// description: not acceptable -// '500': -// description: internal server error -func (m *Module) AccountFollowingGETHandler(c *gin.Context) { - authed, err := oauth.Authed(c, true, true, true, true) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) - return - } - - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - targetAcctID := c.Param(IDKey) - if targetAcctID == "" { - err := errors.New("no account id specified") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - following, errWithCode := m.processor.AccountFollowingGet(c.Request.Context(), authed, targetAcctID) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - c.JSON(http.StatusOK, following) -} diff --git a/internal/api/client/account/relationships.go b/internal/api/client/account/relationships.go @@ -1,93 +0,0 @@ -package account - -import ( - "errors" - "net/http" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// AccountRelationshipsGETHandler swagger:operation GET /api/v1/accounts/relationships accountRelationships -// -// See your account's relationships with the given account IDs. -// -// --- -// tags: -// - accounts -// -// produces: -// - application/json -// -// parameters: -// - -// name: id -// type: array -// items: -// type: string -// description: Account IDs. -// in: query -// required: true -// -// security: -// - OAuth2 Bearer: -// - read:accounts -// -// responses: -// '200': -// name: account relationships -// description: Array of account relationships. -// schema: -// type: array -// items: -// "$ref": "#/definitions/accountRelationship" -// '400': -// description: bad request -// '401': -// description: unauthorized -// '404': -// description: not found -// '406': -// description: not acceptable -// '500': -// description: internal server error -func (m *Module) AccountRelationshipsGETHandler(c *gin.Context) { - authed, err := oauth.Authed(c, true, true, true, true) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) - return - } - - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - targetAccountIDs := c.QueryArray("id[]") - if len(targetAccountIDs) == 0 { - // check fallback -- let's be generous and see if maybe it's just set as 'id'? - id := c.Query("id") - if id == "" { - err = errors.New("no account id(s) specified in query") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - targetAccountIDs = append(targetAccountIDs, id) - } - - relationships := []model.Relationship{} - - for _, targetAccountID := range targetAccountIDs { - r, errWithCode := m.processor.AccountRelationshipGet(c.Request.Context(), authed, targetAccountID) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - relationships = append(relationships, *r) - } - - c.JSON(http.StatusOK, relationships) -} diff --git a/internal/api/client/account/statuses.go b/internal/api/client/account/statuses.go @@ -1,246 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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/http" - "strconv" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// AccountStatusesGETHandler swagger:operation GET /api/v1/accounts/{id}/statuses accountStatuses -// -// See statuses posted by the requested account. -// -// The statuses will be returned in descending chronological order (newest first), with sequential IDs (bigger = newer). -// -// --- -// tags: -// - accounts -// -// produces: -// - application/json -// -// parameters: -// - -// name: id -// type: string -// description: Account ID. -// in: path -// required: true -// - -// name: limit -// type: integer -// description: Number of statuses to return. -// default: 30 -// in: query -// required: false -// - -// name: exclude_replies -// type: boolean -// description: Exclude statuses that are a reply to another status. -// default: false -// in: query -// required: false -// - -// name: exclude_reblogs -// type: boolean -// description: Exclude statuses that are a reblog/boost of another status. -// default: false -// in: query -// required: false -// - -// name: max_id -// type: string -// description: >- -// Return only statuses *OLDER* than the given max status ID. -// The status with the specified ID will not be included in the response. -// in: query -// - -// name: min_id -// type: string -// description: >- -// Return only statuses *NEWER* than the given min status ID. -// The status with the specified ID will not be included in the response. -// in: query -// required: false -// - -// name: pinned_only -// type: boolean -// description: Show only pinned statuses. In other words, exclude statuses that are not pinned to the given account ID. -// default: false -// in: query -// required: false -// - -// name: only_media -// type: boolean -// description: Show only statuses with media attachments. -// default: false -// in: query -// required: false -// - -// name: only_public -// type: boolean -// description: Show only statuses with a privacy setting of 'public'. -// default: false -// in: query -// required: false -// -// security: -// - OAuth2 Bearer: -// - read:accounts -// -// responses: -// '200': -// name: statuses -// description: Array of statuses. -// schema: -// type: array -// items: -// "$ref": "#/definitions/status" -// '400': -// description: bad request -// '401': -// description: unauthorized -// '404': -// description: not found -// '406': -// description: not acceptable -// '500': -// description: internal server error -func (m *Module) AccountStatusesGETHandler(c *gin.Context) { - authed, err := oauth.Authed(c, false, false, false, false) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) - return - } - - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - targetAcctID := c.Param(IDKey) - if targetAcctID == "" { - err := errors.New("no account id specified") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - limit := 30 - limitString := c.Query(LimitKey) - if limitString != "" { - i, err := strconv.ParseInt(limitString, 10, 32) - if err != nil { - err := fmt.Errorf("error parsing %s: %s", LimitKey, err) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - limit = int(i) - } - - excludeReplies := false - excludeRepliesString := c.Query(ExcludeRepliesKey) - if excludeRepliesString != "" { - i, err := strconv.ParseBool(excludeRepliesString) - if err != nil { - err := fmt.Errorf("error parsing %s: %s", ExcludeRepliesKey, err) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - excludeReplies = i - } - - excludeReblogs := false - excludeReblogsString := c.Query(ExcludeReblogsKey) - if excludeReblogsString != "" { - i, err := strconv.ParseBool(excludeReblogsString) - if err != nil { - err := fmt.Errorf("error parsing %s: %s", ExcludeReblogsKey, err) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - excludeReblogs = i - } - - maxID := "" - maxIDString := c.Query(MaxIDKey) - if maxIDString != "" { - maxID = maxIDString - } - - minID := "" - minIDString := c.Query(MinIDKey) - if minIDString != "" { - minID = minIDString - } - - pinnedOnly := false - pinnedString := c.Query(PinnedKey) - if pinnedString != "" { - i, err := strconv.ParseBool(pinnedString) - if err != nil { - err := fmt.Errorf("error parsing %s: %s", PinnedKey, err) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - pinnedOnly = i - } - - mediaOnly := false - mediaOnlyString := c.Query(OnlyMediaKey) - if mediaOnlyString != "" { - i, err := strconv.ParseBool(mediaOnlyString) - if err != nil { - err := fmt.Errorf("error parsing %s: %s", OnlyMediaKey, err) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - mediaOnly = i - } - - publicOnly := false - publicOnlyString := c.Query(OnlyPublicKey) - if publicOnlyString != "" { - i, err := strconv.ParseBool(publicOnlyString) - if err != nil { - err := fmt.Errorf("error parsing %s: %s", OnlyPublicKey, err) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - publicOnly = i - } - - resp, errWithCode := m.processor.AccountStatusesGet(c.Request.Context(), authed, targetAcctID, limit, excludeReplies, excludeReblogs, maxID, minID, pinnedOnly, mediaOnly, publicOnly) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - if resp.LinkHeader != "" { - c.Header("Link", resp.LinkHeader) - } - c.JSON(http.StatusOK, resp.Items) -} diff --git a/internal/api/client/account/statuses_test.go b/internal/api/client/account/statuses_test.go @@ -1,123 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 ( - "encoding/json" - "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" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" -) - -type AccountStatusesTestSuite struct { - AccountStandardTestSuite -} - -func (suite *AccountStatusesTestSuite) TestGetStatusesPublicOnly() { - // set up the request - // we're getting statuses of admin - targetAccount := suite.testAccounts["admin_account"] - recorder := httptest.NewRecorder() - ctx := suite.newContext(recorder, http.MethodGet, nil, fmt.Sprintf("/api/v1/accounts/%s/statuses?limit=20&only_media=false&only_public=true", targetAccount.ID), "") - ctx.Params = gin.Params{ - gin.Param{ - Key: account.IDKey, - Value: targetAccount.ID, - }, - } - - // call the handler - suite.accountModule.AccountStatusesGETHandler(ctx) - - // 1. we should have OK because our request was valid - suite.Equal(http.StatusOK, recorder.Code) - - // 2. we should have no error message in the result body - result := recorder.Result() - defer result.Body.Close() - - // check the response - b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) - - // unmarshal the returned statuses - apimodelStatuses := []*apimodel.Status{} - err = json.Unmarshal(b, &apimodelStatuses) - suite.NoError(err) - suite.NotEmpty(apimodelStatuses) - - for _, s := range apimodelStatuses { - suite.Equal(apimodel.VisibilityPublic, s.Visibility) - } - - suite.Equal(`<http://localhost:8080/api/v1/accounts/01F8MH17FWEB39HZJ76B6VXSKF/statuses?limit=20&max_id=01F8MH75CBF9JFX4ZAD54N0W0R&exclude_replies=false&exclude_reblogs=false&pinned_only=false&only_media=false&only_public=true>; rel="next", <http://localhost:8080/api/v1/accounts/01F8MH17FWEB39HZJ76B6VXSKF/statuses?limit=20&min_id=01G36SF3V6Y6V5BF9P4R7PQG7G&exclude_replies=false&exclude_reblogs=false&pinned_only=false&only_media=false&only_public=true>; rel="prev"`, result.Header.Get("link")) -} - -func (suite *AccountStatusesTestSuite) TestGetStatusesPublicOnlyMediaOnly() { - // set up the request - // we're getting statuses of admin - targetAccount := suite.testAccounts["admin_account"] - recorder := httptest.NewRecorder() - ctx := suite.newContext(recorder, http.MethodGet, nil, fmt.Sprintf("/api/v1/accounts/%s/statuses?limit=20&only_media=true&only_public=true", targetAccount.ID), "") - ctx.Params = gin.Params{ - gin.Param{ - Key: account.IDKey, - Value: targetAccount.ID, - }, - } - - // call the handler - suite.accountModule.AccountStatusesGETHandler(ctx) - - // 1. we should have OK because our request was valid - suite.Equal(http.StatusOK, recorder.Code) - - // 2. we should have no error message in the result body - result := recorder.Result() - defer result.Body.Close() - - // check the response - b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) - - // unmarshal the returned statuses - apimodelStatuses := []*apimodel.Status{} - err = json.Unmarshal(b, &apimodelStatuses) - suite.NoError(err) - suite.NotEmpty(apimodelStatuses) - - for _, s := range apimodelStatuses { - suite.NotEmpty(s.MediaAttachments) - suite.Equal(apimodel.VisibilityPublic, s.Visibility) - } - - suite.Equal(`<http://localhost:8080/api/v1/accounts/01F8MH17FWEB39HZJ76B6VXSKF/statuses?limit=20&max_id=01F8MH75CBF9JFX4ZAD54N0W0R&exclude_replies=false&exclude_reblogs=false&pinned_only=false&only_media=true&only_public=true>; rel="next", <http://localhost:8080/api/v1/accounts/01F8MH17FWEB39HZJ76B6VXSKF/statuses?limit=20&min_id=01F8MH75CBF9JFX4ZAD54N0W0R&exclude_replies=false&exclude_reblogs=false&pinned_only=false&only_media=true&only_public=true>; rel="prev"`, result.Header.Get("link")) -} - -func TestAccountStatusesTestSuite(t *testing.T) { - suite.Run(t, new(AccountStatusesTestSuite)) -} diff --git a/internal/api/client/account/unblock.go b/internal/api/client/account/unblock.go @@ -1,96 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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/http" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// AccountUnblockPOSTHandler swagger:operation POST /api/v1/accounts/{id}/unblock accountUnblock -// -// Unblock account with ID. -// -// --- -// tags: -// - accounts -// -// produces: -// - application/json -// -// parameters: -// - -// name: id -// type: string -// description: The id of the account to unblock. -// in: path -// required: true -// -// security: -// - OAuth2 Bearer: -// - write:blocks -// -// responses: -// '200': -// name: account relationship -// description: Your relationship to this account. -// schema: -// "$ref": "#/definitions/accountRelationship" -// '400': -// description: bad request -// '401': -// description: unauthorized -// '404': -// description: not found -// '406': -// description: not acceptable -// '500': -// description: internal server error -func (m *Module) AccountUnblockPOSTHandler(c *gin.Context) { - authed, err := oauth.Authed(c, true, true, true, true) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) - return - } - - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - targetAcctID := c.Param(IDKey) - if targetAcctID == "" { - err := errors.New("no account id specified") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - relationship, errWithCode := m.processor.AccountBlockRemove(c.Request.Context(), authed, targetAcctID) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - c.JSON(http.StatusOK, relationship) -} diff --git a/internal/api/client/account/unfollow.go b/internal/api/client/account/unfollow.go @@ -1,96 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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/http" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// AccountUnfollowPOSTHandler swagger:operation POST /api/v1/accounts/{id}/unfollow accountUnfollow -// -// Unfollow account with id. -// -// --- -// tags: -// - accounts -// -// produces: -// - application/json -// -// parameters: -// - -// name: id -// type: string -// description: The id of the account to unfollow. -// in: path -// required: true -// -// security: -// - OAuth2 Bearer: -// - write:follows -// -// responses: -// '200': -// name: account relationship -// description: Your relationship to this account. -// schema: -// "$ref": "#/definitions/accountRelationship" -// '400': -// description: bad request -// '401': -// description: unauthorized -// '404': -// description: not found -// '406': -// description: not acceptable -// '500': -// description: internal server error -func (m *Module) AccountUnfollowPOSTHandler(c *gin.Context) { - authed, err := oauth.Authed(c, true, true, true, true) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) - return - } - - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - targetAcctID := c.Param(IDKey) - if targetAcctID == "" { - err := errors.New("no account id specified") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - relationship, errWithCode := m.processor.AccountFollowRemove(c.Request.Context(), authed, targetAcctID) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - c.JSON(http.StatusOK, relationship) -} diff --git a/internal/api/client/accounts/account_test.go b/internal/api/client/accounts/account_test.go @@ -0,0 +1,127 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 accounts_test + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/accounts" + "github.com/superseriousbusiness/gotosocial/internal/concurrency" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/email" + "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/messages" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/processing" + "github.com/superseriousbusiness/gotosocial/internal/storage" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type AccountStandardTestSuite struct { + // standard suite interfaces + suite.Suite + db db.DB + storage *storage.Driver + mediaManager media.Manager + federator federation.Federator + processor processing.Processor + emailSender email.Sender + sentEmails map[string]string + + // standard suite models + testTokens map[string]*gtsmodel.Token + testClients map[string]*gtsmodel.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 + accountsModule *accounts.Module +} + +func (suite *AccountStandardTestSuite) 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 *AccountStandardTestSuite) SetupTest() { + testrig.InitTestConfig() + testrig.InitTestLog() + + fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) + clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) + + suite.db = testrig.NewTestDB() + suite.storage = testrig.NewInMemoryStorage() + suite.mediaManager = testrig.NewTestMediaManager(suite.db, suite.storage) + suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker), suite.storage, suite.mediaManager, fedWorker) + suite.sentEmails = make(map[string]string) + suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails) + suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, suite.mediaManager, clientWorker, fedWorker) + suite.accountsModule = accounts.New(suite.processor) + testrig.StandardDBSetup(suite.db, nil) + testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") + + suite.NoError(suite.processor.Start()) +} + +func (suite *AccountStandardTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) +} + +func (suite *AccountStandardTestSuite) newContext(recorder *httptest.ResponseRecorder, requestMethod string, requestBody []byte, requestPath string, bodyContentType string) *gin.Context { + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"])) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + + protocol := config.GetProtocol() + host := config.GetHost() + + baseURI := fmt.Sprintf("%s://%s", protocol, host) + requestURI := fmt.Sprintf("%s/%s", baseURI, requestPath) + + ctx.Request = httptest.NewRequest(http.MethodPatch, requestURI, bytes.NewReader(requestBody)) // the endpoint we're hitting + + if bodyContentType != "" { + ctx.Request.Header.Set("Content-Type", bodyContentType) + } + + ctx.Request.Header.Set("accept", "application/json") + + return ctx +} diff --git a/internal/api/client/accounts/accountcreate.go b/internal/api/client/accounts/accountcreate.go @@ -0,0 +1,150 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 accounts + +import ( + "errors" + "net" + "net/http" + + "github.com/gin-gonic/gin" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/validate" +) + +// AccountCreatePOSTHandler swagger:operation POST /api/v1/accounts accountCreate +// +// Create a new account using an application token. +// +// The parameters can also be given in the body of the request, as JSON, if the content-type is set to 'application/json'. +// The parameters can also be given in the body of the request, as XML, if the content-type is set to 'application/xml'. +// +// --- +// tags: +// - accounts +// +// consumes: +// - application/json +// - application/xml +// - application/x-www-form-urlencoded +// +// produces: +// - application/json +// +// security: +// - OAuth2 Application: +// - write:accounts +// +// responses: +// '200': +// description: "An OAuth2 access token for the newly-created account." +// schema: +// "$ref": "#/definitions/oauthToken" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) AccountCreatePOSTHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, false, false) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + form := &apimodel.AccountCreateRequest{} + if err := c.ShouldBind(form); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + if err := validateCreateAccount(form); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + clientIP := c.ClientIP() + signUpIP := net.ParseIP(clientIP) + if signUpIP == nil { + err := errors.New("ip address could not be parsed from request") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + form.IP = signUpIP + + ti, errWithCode := m.processor.AccountCreate(c.Request.Context(), authed, form) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + 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 *apimodel.AccountCreateRequest) error { + if form == nil { + return errors.New("form was nil") + } + + if !config.GetAccountsRegistrationOpen() { + return errors.New("registration is not open for this server") + } + + if err := validate.Username(form.Username); err != nil { + return err + } + + if err := validate.Email(form.Email); err != nil { + return err + } + + if err := validate.NewPassword(form.Password); err != nil { + return err + } + + if !form.Agreement { + return errors.New("agreement to terms and conditions not given") + } + + if err := validate.Language(form.Locale); err != nil { + return err + } + + if err := validate.SignUpReason(form.Reason, config.GetAccountsReasonRequired()); err != nil { + return err + } + + return nil +} diff --git a/internal/api/client/accounts/accountcreate_test.go b/internal/api/client/accounts/accountcreate_test.go @@ -0,0 +1,19 @@ +// /* +// GoToSocial +// Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public 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 accounts_test diff --git a/internal/api/client/accounts/accountdelete.go b/internal/api/client/accounts/accountdelete.go @@ -0,0 +1,95 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 accounts + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// AccountDeletePOSTHandler swagger:operation POST /api/v1/accounts/delete accountDelete +// +// Delete your account. +// +// --- +// tags: +// - accounts +// +// consumes: +// - multipart/form-data +// +// parameters: +// - +// name: password +// in: formData +// description: Password of the account user, for confirmation. +// type: string +// required: true +// +// security: +// - OAuth2 Bearer: +// - write:accounts +// +// responses: +// '202': +// description: "The account deletion has been accepted and the account will be deleted." +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) AccountDeletePOSTHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + + form := &apimodel.AccountDeleteRequest{} + if err := c.ShouldBind(&form); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + if form.Password == "" { + err = errors.New("no password provided in account delete request") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + form.DeleteOriginID = authed.Account.ID + + if errWithCode := m.processor.AccountDeleteLocal(c.Request.Context(), authed, form); errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + c.JSON(http.StatusAccepted, gin.H{"message": "accepted"}) +} diff --git a/internal/api/client/accounts/accountdelete_test.go b/internal/api/client/accounts/accountdelete_test.go @@ -0,0 +1,101 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 accounts_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/accounts" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type AccountDeleteTestSuite struct { + AccountStandardTestSuite +} + +func (suite *AccountDeleteTestSuite) TestAccountDeletePOSTHandler() { + // set up the request + // we're deleting zork + requestBody, w, err := testrig.CreateMultipartFormData( + "", "", + map[string]string{ + "password": "password", + }) + if err != nil { + panic(err) + } + bodyBytes := requestBody.Bytes() + recorder := httptest.NewRecorder() + ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, accounts.DeleteAccountPath, w.FormDataContentType()) + + // call the handler + suite.accountsModule.AccountDeletePOSTHandler(ctx) + + // 1. we should have Accepted because our request was valid + suite.Equal(http.StatusAccepted, recorder.Code) +} + +func (suite *AccountDeleteTestSuite) TestAccountDeletePOSTHandlerWrongPassword() { + // set up the request + // we're deleting zork + requestBody, w, err := testrig.CreateMultipartFormData( + "", "", + map[string]string{ + "password": "aaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }) + if err != nil { + panic(err) + } + bodyBytes := requestBody.Bytes() + recorder := httptest.NewRecorder() + ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, accounts.DeleteAccountPath, w.FormDataContentType()) + + // call the handler + suite.accountsModule.AccountDeletePOSTHandler(ctx) + + // 1. we should have Forbidden because we supplied the wrong password + suite.Equal(http.StatusForbidden, recorder.Code) +} + +func (suite *AccountDeleteTestSuite) TestAccountDeletePOSTHandlerNoPassword() { + // set up the request + // we're deleting zork + requestBody, w, err := testrig.CreateMultipartFormData( + "", "", + map[string]string{}) + if err != nil { + panic(err) + } + bodyBytes := requestBody.Bytes() + recorder := httptest.NewRecorder() + ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, accounts.DeleteAccountPath, w.FormDataContentType()) + + // call the handler + suite.accountsModule.AccountDeletePOSTHandler(ctx) + + // 1. we should have StatusBadRequest because our request was invalid + suite.Equal(http.StatusBadRequest, recorder.Code) +} + +func TestAccountDeleteTestSuite(t *testing.T) { + suite.Run(t, new(AccountDeleteTestSuite)) +} diff --git a/internal/api/client/accounts/accountget.go b/internal/api/client/accounts/accountget.go @@ -0,0 +1,95 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 accounts + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// AccountGETHandler swagger:operation GET /api/v1/accounts/{id} accountGet +// +// Get information about an account with the given ID. +// +// --- +// tags: +// - accounts +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: string +// description: The id of the requested account. +// in: path +// required: true +// +// security: +// - OAuth2 Bearer: +// - read:accounts +// +// responses: +// '200': +// description: The requested account. +// schema: +// "$ref": "#/definitions/account" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) AccountGETHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + targetAcctID := c.Param(IDKey) + if targetAcctID == "" { + err := errors.New("no account id specified") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + acctInfo, errWithCode := m.processor.AccountGet(c.Request.Context(), authed, targetAcctID) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + c.JSON(http.StatusOK, acctInfo) +} diff --git a/internal/api/client/accounts/accounts.go b/internal/api/client/accounts/accounts.go @@ -0,0 +1,119 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 accounts + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/processing" +) + +const ( + // LimitKey is for setting the return amount limit for eg., requesting an account's statuses + LimitKey = "limit" + // ExcludeRepliesKey is for specifying whether to exclude replies in a list of returned statuses by an account. + ExcludeRepliesKey = "exclude_replies" + // ExcludeReblogsKey is for specifying whether to exclude reblogs in a list of returned statuses by an account. + ExcludeReblogsKey = "exclude_reblogs" + // PinnedKey is for specifying whether to include pinned statuses in a list of returned statuses by an account. + PinnedKey = "pinned" + // MaxIDKey is for specifying the maximum ID of the status to retrieve. + MaxIDKey = "max_id" + // MinIDKey is for specifying the minimum ID of the status to retrieve. + MinIDKey = "min_id" + // OnlyMediaKey is for specifying that only statuses with media should be returned in a list of returned statuses by an account. + OnlyMediaKey = "only_media" + // OnlyPublicKey is for specifying that only statuses with visibility public should be returned in a list of returned statuses by account. + OnlyPublicKey = "only_public" + + // IDKey is the key to use for retrieving account ID in requests + IDKey = "id" + // BasePath is the base API path for this module, excluding the 'api' prefix + BasePath = "/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" + // GetStatusesPath is for showing an account's statuses + GetStatusesPath = BasePathWithID + "/statuses" + // GetFollowersPath is for showing an account's followers + GetFollowersPath = BasePathWithID + "/followers" + // GetFollowingPath is for showing account's that an account follows. + GetFollowingPath = BasePathWithID + "/following" + // GetRelationshipsPath is for showing an account's relationship with other accounts + GetRelationshipsPath = BasePath + "/relationships" + // FollowPath is for POSTing new follows to, and updating existing follows + FollowPath = BasePathWithID + "/follow" + // UnfollowPath is for POSTing an unfollow + UnfollowPath = BasePathWithID + "/unfollow" + // BlockPath is for creating a block of an account + BlockPath = BasePathWithID + "/block" + // UnblockPath is for removing a block of an account + UnblockPath = BasePathWithID + "/unblock" + // DeleteAccountPath is for deleting one's account via the API + DeleteAccountPath = BasePath + "/delete" +) + +type Module struct { + processor processing.Processor +} + +func New(processor processing.Processor) *Module { + return &Module{ + processor: processor, + } +} + +func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) { + // create account + attachHandler(http.MethodPost, BasePath, m.AccountCreatePOSTHandler) + + // get account + attachHandler(http.MethodGet, BasePathWithID, m.AccountGETHandler) + + // delete account + attachHandler(http.MethodPost, DeleteAccountPath, m.AccountDeletePOSTHandler) + + // verify account + attachHandler(http.MethodGet, VerifyPath, m.AccountVerifyGETHandler) + + // modify account + attachHandler(http.MethodPatch, UpdateCredentialsPath, m.AccountUpdateCredentialsPATCHHandler) + + // get account's statuses + attachHandler(http.MethodGet, GetStatusesPath, m.AccountStatusesGETHandler) + + // get following or followers + attachHandler(http.MethodGet, GetFollowersPath, m.AccountFollowersGETHandler) + attachHandler(http.MethodGet, GetFollowingPath, m.AccountFollowingGETHandler) + + // get relationship with account + attachHandler(http.MethodGet, GetRelationshipsPath, m.AccountRelationshipsGETHandler) + + // follow or unfollow account + attachHandler(http.MethodPost, FollowPath, m.AccountFollowPOSTHandler) + attachHandler(http.MethodPost, UnfollowPath, m.AccountUnfollowPOSTHandler) + + // block or unblock account + attachHandler(http.MethodPost, BlockPath, m.AccountBlockPOSTHandler) + attachHandler(http.MethodPost, UnblockPath, m.AccountUnblockPOSTHandler) +} diff --git a/internal/api/client/accounts/accountupdate.go b/internal/api/client/accounts/accountupdate.go @@ -0,0 +1,216 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 accounts + +import ( + "errors" + "fmt" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// AccountUpdateCredentialsPATCHHandler swagger:operation PATCH /api/v1/accounts/update_credentials accountUpdate +// +// Update your account. +// +// --- +// tags: +// - accounts +// +// consumes: +// - multipart/form-data +// +// produces: +// - application/json +// +// parameters: +// - +// name: discoverable +// in: formData +// description: Account should be made discoverable and shown in the profile directory (if enabled). +// type: boolean +// - +// name: bot +// in: formData +// description: Account is flagged as a bot. +// type: boolean +// - +// name: display_name +// in: formData +// description: The display name to use for the account. +// type: string +// allowEmptyValue: true +// - +// name: note +// in: formData +// description: Bio/description of this account. +// type: string +// allowEmptyValue: true +// - +// name: avatar +// in: formData +// description: Avatar of the user. +// type: file +// - +// name: header +// in: formData +// description: Header of the user. +// type: file +// - +// name: locked +// in: formData +// description: Require manual approval of follow requests. +// type: boolean +// - +// name: source[privacy] +// in: formData +// description: Default post privacy for authored statuses. +// type: string +// - +// name: source[sensitive] +// in: formData +// description: Mark authored statuses as sensitive by default. +// type: boolean +// - +// name: source[language] +// in: formData +// description: Default language to use for authored statuses (ISO 6391). +// type: string +// - +// name: source[status_format] +// in: formData +// description: Default format to use for authored statuses (plain or markdown). +// type: string +// - +// name: custom_css +// in: formData +// description: >- +// Custom CSS to use when rendering this account's profile or statuses. +// String must be no more than 5,000 characters (~5kb). +// type: string +// - +// name: enable_rss +// in: formData +// description: Enable RSS feed for this account's Public posts at `/[username]/feed.rss` +// type: boolean +// +// security: +// - OAuth2 Bearer: +// - write:accounts +// +// responses: +// '200': +// description: "The newly updated account." +// schema: +// "$ref": "#/definitions/account" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) AccountUpdateCredentialsPATCHHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + form, err := parseUpdateAccountForm(c) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + acctSensitive, errWithCode := m.processor.AccountUpdate(c.Request.Context(), authed, form) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + c.JSON(http.StatusOK, acctSensitive) +} + +func parseUpdateAccountForm(c *gin.Context) (*apimodel.UpdateCredentialsRequest, error) { + form := &apimodel.UpdateCredentialsRequest{ + Source: &apimodel.UpdateSource{}, + } + + if err := c.ShouldBind(&form); err != nil { + return nil, fmt.Errorf("could not parse form from request: %s", err) + } + + // parse source field-by-field + sourceMap := c.PostFormMap("source") + + if privacy, ok := sourceMap["privacy"]; ok { + form.Source.Privacy = &privacy + } + + if sensitive, ok := sourceMap["sensitive"]; ok { + sensitiveBool, err := strconv.ParseBool(sensitive) + if err != nil { + return nil, fmt.Errorf("error parsing form source[sensitive]: %s", err) + } + form.Source.Sensitive = &sensitiveBool + } + + if language, ok := sourceMap["language"]; ok { + form.Source.Language = &language + } + + if statusFormat, ok := sourceMap["status_format"]; ok { + form.Source.StatusFormat = &statusFormat + } + + if form == nil || + (form.Discoverable == nil && + form.Bot == nil && + form.DisplayName == nil && + form.Note == nil && + form.Avatar == nil && + form.Header == nil && + form.Locked == nil && + form.Source.Privacy == nil && + form.Source.Sensitive == nil && + form.Source.Language == nil && + form.Source.StatusFormat == nil && + form.FieldsAttributes == nil && + form.CustomCSS == nil && + form.EnableRSS == nil) { + return nil, errors.New("empty form submitted") + } + + return form, nil +} diff --git a/internal/api/client/accounts/accountupdate_test.go b/internal/api/client/accounts/accountupdate_test.go @@ -0,0 +1,452 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 accounts_test + +import ( + "context" + "encoding/json" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/accounts" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type AccountUpdateTestSuite struct { + AccountStandardTestSuite +} + +func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandler() { + // set up the request + // we're updating the note of zork + newBio := "this is my new bio read it and weep" + requestBody, w, err := testrig.CreateMultipartFormData( + "", "", + map[string]string{ + "note": newBio, + }) + if err != nil { + panic(err) + } + bodyBytes := requestBody.Bytes() + recorder := httptest.NewRecorder() + ctx := suite.newContext(recorder, http.MethodPatch, bodyBytes, accounts.UpdateCredentialsPath, w.FormDataContentType()) + + // call the handler + suite.accountsModule.AccountUpdateCredentialsPATCHHandler(ctx) + + // 1. we should have OK because our request was valid + suite.Equal(http.StatusOK, recorder.Code) + + // 2. we should have no error message in the result body + result := recorder.Result() + defer result.Body.Close() + + // check the response + b, err := ioutil.ReadAll(result.Body) + suite.NoError(err) + + // unmarshal the returned account + apimodelAccount := &apimodel.Account{} + err = json.Unmarshal(b, apimodelAccount) + suite.NoError(err) + + // check the returned api model account + // fields should be updated + suite.Equal("<p>this is my new bio read it and weep</p>", apimodelAccount.Note) + suite.Equal(newBio, apimodelAccount.Source.Note) +} + +func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandlerUnlockLock() { + // set up the first request + requestBody1, w1, err := testrig.CreateMultipartFormData( + "", "", + map[string]string{ + "locked": "false", + }) + if err != nil { + panic(err) + } + bodyBytes1 := requestBody1.Bytes() + recorder1 := httptest.NewRecorder() + ctx1 := suite.newContext(recorder1, http.MethodPatch, bodyBytes1, accounts.UpdateCredentialsPath, w1.FormDataContentType()) + + // call the handler + suite.accountsModule.AccountUpdateCredentialsPATCHHandler(ctx1) + + // 1. we should have OK because our request was valid + suite.Equal(http.StatusOK, recorder1.Code) + + // 2. we should have no error message in the result body + result1 := recorder1.Result() + defer result1.Body.Close() + + // check the response + b1, err := ioutil.ReadAll(result1.Body) + suite.NoError(err) + + // unmarshal the returned account + apimodelAccount1 := &apimodel.Account{} + err = json.Unmarshal(b1, apimodelAccount1) + suite.NoError(err) + + // check the returned api model account + // fields should be updated + suite.False(apimodelAccount1.Locked) + + // set up the first request + requestBody2, w2, err := testrig.CreateMultipartFormData( + "", "", + map[string]string{ + "locked": "true", + }) + if err != nil { + panic(err) + } + bodyBytes2 := requestBody2.Bytes() + recorder2 := httptest.NewRecorder() + ctx2 := suite.newContext(recorder2, http.MethodPatch, bodyBytes2, accounts.UpdateCredentialsPath, w2.FormDataContentType()) + + // call the handler + suite.accountsModule.AccountUpdateCredentialsPATCHHandler(ctx2) + + // 1. we should have OK because our request was valid + suite.Equal(http.StatusOK, recorder1.Code) + + // 2. we should have no error message in the result body + result2 := recorder2.Result() + defer result2.Body.Close() + + // check the response + b2, err := ioutil.ReadAll(result2.Body) + suite.NoError(err) + + // unmarshal the returned account + apimodelAccount2 := &apimodel.Account{} + err = json.Unmarshal(b2, apimodelAccount2) + suite.NoError(err) + + // check the returned api model account + // fields should be updated + suite.True(apimodelAccount2.Locked) +} + +func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandlerGetAccountFirst() { + // get the account first to make sure it's in the database cache -- when the account is updated via + // the PATCH handler, it should invalidate the cache and not return the old version + _, err := suite.db.GetAccountByID(context.Background(), suite.testAccounts["local_account_1"].ID) + suite.NoError(err) + + // set up the request + // we're updating the note of zork + newBio := "this is my new bio read it and weep" + requestBody, w, err := testrig.CreateMultipartFormData( + "", "", + map[string]string{ + "note": newBio, + }) + if err != nil { + panic(err) + } + bodyBytes := requestBody.Bytes() + recorder := httptest.NewRecorder() + ctx := suite.newContext(recorder, http.MethodPatch, bodyBytes, accounts.UpdateCredentialsPath, w.FormDataContentType()) + + // call the handler + suite.accountsModule.AccountUpdateCredentialsPATCHHandler(ctx) + + // 1. we should have OK because our request was valid + suite.Equal(http.StatusOK, recorder.Code) + + // 2. we should have no error message in the result body + result := recorder.Result() + defer result.Body.Close() + + // check the response + b, err := ioutil.ReadAll(result.Body) + suite.NoError(err) + + // unmarshal the returned account + apimodelAccount := &apimodel.Account{} + err = json.Unmarshal(b, apimodelAccount) + suite.NoError(err) + + // check the returned api model account + // fields should be updated + suite.Equal("<p>this is my new bio read it and weep</p>", apimodelAccount.Note) + suite.Equal(newBio, apimodelAccount.Source.Note) +} + +func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandlerTwoFields() { + // set up the request + // we're updating the note of zork, and setting locked to true + newBio := "this is my new bio read it and weep :rainbow:" + requestBody, w, err := testrig.CreateMultipartFormData( + "", "", + map[string]string{ + "note": newBio, + "locked": "true", + }) + if err != nil { + panic(err) + } + bodyBytes := requestBody.Bytes() + recorder := httptest.NewRecorder() + ctx := suite.newContext(recorder, http.MethodPatch, bodyBytes, accounts.UpdateCredentialsPath, w.FormDataContentType()) + + // call the handler + suite.accountsModule.AccountUpdateCredentialsPATCHHandler(ctx) + + // 1. we should have OK because our request was valid + suite.Equal(http.StatusOK, recorder.Code) + + // 2. we should have no error message in the result body + result := recorder.Result() + defer result.Body.Close() + + // check the response + b, err := ioutil.ReadAll(result.Body) + suite.NoError(err) + + // unmarshal the returned account + apimodelAccount := &apimodel.Account{} + err = json.Unmarshal(b, apimodelAccount) + suite.NoError(err) + + // check the returned api model account + // fields should be updated + suite.Equal("<p>this is my new bio read it and weep :rainbow:</p>", apimodelAccount.Note) + suite.Equal(newBio, apimodelAccount.Source.Note) + suite.True(apimodelAccount.Locked) + suite.NotEmpty(apimodelAccount.Emojis) + suite.Equal(apimodelAccount.Emojis[0].Shortcode, "rainbow") + + // check the account in the database + dbZork, err := suite.db.GetAccountByID(context.Background(), apimodelAccount.ID) + suite.NoError(err) + suite.Equal(newBio, dbZork.NoteRaw) + suite.Equal("<p>this is my new bio read it and weep :rainbow:</p>", dbZork.Note) + suite.True(*dbZork.Locked) + suite.NotEmpty(dbZork.EmojiIDs) +} + +func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandlerWithMedia() { + // set up the request + // we're updating the header image, the display name, and the locked status of zork + // we're removing the note/bio + requestBody, w, err := testrig.CreateMultipartFormData( + "header", "../../../../testrig/media/test-jpeg.jpg", + map[string]string{ + "display_name": "updated zork display name!!!", + "note": "", + "locked": "true", + }) + if err != nil { + panic(err) + } + bodyBytes := requestBody.Bytes() + recorder := httptest.NewRecorder() + ctx := suite.newContext(recorder, http.MethodPatch, bodyBytes, accounts.UpdateCredentialsPath, w.FormDataContentType()) + + // call the handler + suite.accountsModule.AccountUpdateCredentialsPATCHHandler(ctx) + + // 1. we should have OK because our request was valid + suite.Equal(http.StatusOK, recorder.Code) + + // 2. we should have no error message in the result body + result := recorder.Result() + defer result.Body.Close() + + // check the response + b, err := ioutil.ReadAll(result.Body) + suite.NoError(err) + + // unmarshal the returned account + apimodelAccount := &apimodel.Account{} + err = json.Unmarshal(b, apimodelAccount) + suite.NoError(err) + + // check the returned api model account + // fields should be updated + suite.Equal("updated zork display name!!!", apimodelAccount.DisplayName) + suite.True(apimodelAccount.Locked) + suite.Empty(apimodelAccount.Note) + suite.Empty(apimodelAccount.Source.Note) + + // header values... + // should be set + suite.NotEmpty(apimodelAccount.Header) + suite.NotEmpty(apimodelAccount.HeaderStatic) + + // should be different from the values set before + suite.NotEqual("http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg", apimodelAccount.Header) + suite.NotEqual("http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg", apimodelAccount.HeaderStatic) +} + +func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandlerEmptyForm() { + // set up the request + bodyBytes := []byte{} + recorder := httptest.NewRecorder() + ctx := suite.newContext(recorder, http.MethodPatch, bodyBytes, accounts.UpdateCredentialsPath, "") + + // call the handler + suite.accountsModule.AccountUpdateCredentialsPATCHHandler(ctx) + + // 1. we should have OK because our request was valid + suite.Equal(http.StatusBadRequest, recorder.Code) + + // 2. we should have no error message in the result body + result := recorder.Result() + defer result.Body.Close() + + // check the response + b, err := ioutil.ReadAll(result.Body) + suite.NoError(err) + suite.Equal(`{"error":"Bad Request: empty form submitted"}`, string(b)) +} + +func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandlerUpdateSource() { + // set up the request + // we're updating the language of zork + newLanguage := "de" + requestBody, w, err := testrig.CreateMultipartFormData( + "", "", + map[string]string{ + "source[privacy]": string(apimodel.VisibilityPrivate), + "source[language]": "de", + "source[sensitive]": "true", + "locked": "true", + }) + if err != nil { + panic(err) + } + bodyBytes := requestBody.Bytes() + recorder := httptest.NewRecorder() + ctx := suite.newContext(recorder, http.MethodPatch, bodyBytes, accounts.UpdateCredentialsPath, w.FormDataContentType()) + + // call the handler + suite.accountsModule.AccountUpdateCredentialsPATCHHandler(ctx) + + // 1. we should have OK because our request was valid + suite.Equal(http.StatusOK, recorder.Code) + + // 2. we should have no error message in the result body + result := recorder.Result() + defer result.Body.Close() + + // check the response + b, err := ioutil.ReadAll(result.Body) + suite.NoError(err) + + // unmarshal the returned account + apimodelAccount := &apimodel.Account{} + err = json.Unmarshal(b, apimodelAccount) + suite.NoError(err) + + // check the returned api model account + // fields should be updated + suite.Equal(newLanguage, apimodelAccount.Source.Language) + suite.EqualValues(apimodel.VisibilityPrivate, apimodelAccount.Source.Privacy) + suite.True(apimodelAccount.Source.Sensitive) + suite.True(apimodelAccount.Locked) +} + +func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandlerUpdateStatusFormatOK() { + // set up the request + // we're updating the language of zork + requestBody, w, err := testrig.CreateMultipartFormData( + "", "", + map[string]string{ + "source[status_format]": "markdown", + }) + if err != nil { + panic(err) + } + bodyBytes := requestBody.Bytes() + recorder := httptest.NewRecorder() + ctx := suite.newContext(recorder, http.MethodPatch, bodyBytes, accounts.UpdateCredentialsPath, w.FormDataContentType()) + + // call the handler + suite.accountsModule.AccountUpdateCredentialsPATCHHandler(ctx) + + // 1. we should have OK because our request was valid + suite.Equal(http.StatusOK, recorder.Code) + + // 2. we should have no error message in the result body + result := recorder.Result() + defer result.Body.Close() + + // check the response + b, err := ioutil.ReadAll(result.Body) + suite.NoError(err) + + // unmarshal the returned account + apimodelAccount := &apimodel.Account{} + err = json.Unmarshal(b, apimodelAccount) + suite.NoError(err) + + // check the returned api model account + // fields should be updated + suite.Equal("markdown", apimodelAccount.Source.StatusFormat) + + dbAccount, err := suite.db.GetAccountByID(context.Background(), suite.testAccounts["local_account_1"].ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.Equal(dbAccount.StatusFormat, "markdown") +} + +func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandlerUpdateStatusFormatBad() { + // set up the request + // we're updating the language of zork + requestBody, w, err := testrig.CreateMultipartFormData( + "", "", + map[string]string{ + "source[status_format]": "peepeepoopoo", + }) + if err != nil { + panic(err) + } + bodyBytes := requestBody.Bytes() + recorder := httptest.NewRecorder() + ctx := suite.newContext(recorder, http.MethodPatch, bodyBytes, accounts.UpdateCredentialsPath, w.FormDataContentType()) + + // call the handler + suite.accountsModule.AccountUpdateCredentialsPATCHHandler(ctx) + + suite.Equal(http.StatusBadRequest, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + + // check the response + b, err := ioutil.ReadAll(result.Body) + suite.NoError(err) + + suite.Equal(`{"error":"Bad Request: status format 'peepeepoopoo' was not recognized, valid options are 'plain', 'markdown'"}`, string(b)) +} + +func TestAccountUpdateTestSuite(t *testing.T) { + suite.Run(t, new(AccountUpdateTestSuite)) +} diff --git a/internal/api/client/accounts/accountverify.go b/internal/api/client/accounts/accountverify.go @@ -0,0 +1,78 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 accounts + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// AccountVerifyGETHandler swagger:operation GET /api/v1/accounts/verify_credentials accountVerify +// +// Verify a token by returning account details pertaining to it. +// +// --- +// tags: +// - accounts +// +// produces: +// - application/json +// +// security: +// - OAuth2 Bearer: +// - read:accounts +// +// responses: +// '200': +// schema: +// "$ref": "#/definitions/account" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) AccountVerifyGETHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + acctSensitive, errWithCode := m.processor.AccountGet(c.Request.Context(), authed, authed.Account.ID) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + c.JSON(http.StatusOK, acctSensitive) +} diff --git a/internal/api/client/accounts/accountverify_test.go b/internal/api/client/accounts/accountverify_test.go @@ -0,0 +1,91 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 accounts_test + +import ( + "encoding/json" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/accounts" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +type AccountVerifyTestSuite struct { + AccountStandardTestSuite +} + +func (suite *AccountVerifyTestSuite) TestAccountVerifyGet() { + testAccount := suite.testAccounts["local_account_1"] + + // set up the request + recorder := httptest.NewRecorder() + ctx := suite.newContext(recorder, http.MethodGet, nil, accounts.VerifyPath, "") + + // call the handler + suite.accountsModule.AccountVerifyGETHandler(ctx) + + // 1. we should have OK because our request was valid + suite.Equal(http.StatusOK, recorder.Code) + + // 2. we should have no error message in the result body + result := recorder.Result() + defer result.Body.Close() + + // check the response + b, err := ioutil.ReadAll(result.Body) + assert.NoError(suite.T(), err) + + // unmarshal the returned account + apimodelAccount := &apimodel.Account{} + err = json.Unmarshal(b, apimodelAccount) + suite.NoError(err) + + createdAt, err := time.Parse(time.RFC3339, apimodelAccount.CreatedAt) + suite.NoError(err) + + suite.Equal(testAccount.ID, apimodelAccount.ID) + suite.Equal(testAccount.Username, apimodelAccount.Username) + suite.Equal(testAccount.Username, apimodelAccount.Acct) + suite.Equal(testAccount.DisplayName, apimodelAccount.DisplayName) + suite.Equal(*testAccount.Locked, apimodelAccount.Locked) + suite.Equal(*testAccount.Bot, apimodelAccount.Bot) + suite.WithinDuration(testAccount.CreatedAt, createdAt, 30*time.Second) // we lose a bit of accuracy serializing so fuzz this a bit + suite.Equal(testAccount.URL, apimodelAccount.URL) + suite.Equal("http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpeg", apimodelAccount.Avatar) + suite.Equal("http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpeg", apimodelAccount.AvatarStatic) + suite.Equal("http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg", apimodelAccount.Header) + suite.Equal("http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg", apimodelAccount.HeaderStatic) + suite.Equal(2, apimodelAccount.FollowersCount) + suite.Equal(2, apimodelAccount.FollowingCount) + suite.Equal(5, apimodelAccount.StatusesCount) + suite.EqualValues(gtsmodel.VisibilityPublic, apimodelAccount.Source.Privacy) + suite.Equal(testAccount.Language, apimodelAccount.Source.Language) + suite.Equal(testAccount.NoteRaw, apimodelAccount.Source.Note) +} + +func TestAccountVerifyTestSuite(t *testing.T) { + suite.Run(t, new(AccountVerifyTestSuite)) +} diff --git a/internal/api/client/accounts/block.go b/internal/api/client/accounts/block.go @@ -0,0 +1,95 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 accounts + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// AccountBlockPOSTHandler swagger:operation POST /api/v1/accounts/{id}/block accountBlock +// +// Block account with id. +// +// --- +// tags: +// - accounts +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: string +// description: The id of the account to block. +// in: path +// required: true +// +// security: +// - OAuth2 Bearer: +// - write:blocks +// +// responses: +// '200': +// description: Your relationship to the account. +// schema: +// "$ref": "#/definitions/accountRelationship" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) AccountBlockPOSTHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + targetAcctID := c.Param(IDKey) + if targetAcctID == "" { + err := errors.New("no account id specified") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + relationship, errWithCode := m.processor.AccountBlockCreate(c.Request.Context(), authed, targetAcctID) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + c.JSON(http.StatusOK, relationship) +} diff --git a/internal/api/client/accounts/block_test.go b/internal/api/client/accounts/block_test.go @@ -0,0 +1,74 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 accounts_test + +import ( + "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/accounts" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type BlockTestSuite struct { + AccountStandardTestSuite +} + +func (suite *BlockTestSuite) TestBlockSelf() { + testAcct := suite.testAccounts["local_account_1"] + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Set(oauth.SessionAuthorizedAccount, testAcct) + ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"])) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(accounts.BlockPath, ":id", testAcct.ID, 1)), nil) + + ctx.Params = gin.Params{ + gin.Param{ + Key: accounts.IDKey, + Value: testAcct.ID, + }, + } + + suite.accountsModule.AccountBlockPOSTHandler(ctx) + + // 1. status should be Not Acceptable due to attempted self-block + suite.Equal(http.StatusNotAcceptable, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + + // check the response + b, err := ioutil.ReadAll(result.Body) + _ = b + assert.NoError(suite.T(), err) +} + +func TestBlockTestSuite(t *testing.T) { + suite.Run(t, new(BlockTestSuite)) +} diff --git a/internal/api/client/accounts/follow.go b/internal/api/client/accounts/follow.go @@ -0,0 +1,124 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 accounts + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// AccountFollowPOSTHandler swagger:operation POST /api/v1/accounts/{id}/follow accountFollow +// +// Follow account with id. +// +// The parameters can also be given in the body of the request, as JSON, if the content-type is set to 'application/json'. +// The parameters can also be given in the body of the request, as XML, if the content-type is set to 'application/xml'. +// +// --- +// tags: +// - accounts +// +// consumes: +// - application/json +// - application/xml +// - application/x-www-form-urlencoded +// +// parameters: +// - +// name: id +// required: true +// in: path +// description: ID of the account to follow. +// type: string +// - +// name: reblogs +// type: boolean +// default: true +// description: Show reblogs from this account. +// in: formData +// - +// default: false +// description: Notify when this account posts. +// in: formData +// name: notify +// type: boolean +// +// produces: +// - application/json +// +// security: +// - OAuth2 Bearer: +// - write:follows +// +// responses: +// '200': +// name: account relationship +// description: Your relationship to this account. +// schema: +// "$ref": "#/definitions/accountRelationship" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) AccountFollowPOSTHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + targetAcctID := c.Param(IDKey) + if targetAcctID == "" { + err := errors.New("no account id specified") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + form := &apimodel.AccountFollowRequest{} + if err := c.ShouldBind(form); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + form.ID = targetAcctID + + relationship, errWithCode := m.processor.AccountFollowCreate(c.Request.Context(), authed, form) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + c.JSON(http.StatusOK, relationship) +} diff --git a/internal/api/client/accounts/follow_test.go b/internal/api/client/accounts/follow_test.go @@ -0,0 +1,75 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 accounts_test + +import ( + "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/accounts" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type FollowTestSuite struct { + AccountStandardTestSuite +} + +func (suite *FollowTestSuite) TestFollowSelf() { + testAcct := suite.testAccounts["local_account_1"] + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Set(oauth.SessionAuthorizedAccount, testAcct) + ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"])) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(accounts.FollowPath, ":id", testAcct.ID, 1)), nil) + + ctx.Params = gin.Params{ + gin.Param{ + Key: accounts.IDKey, + Value: testAcct.ID, + }, + } + + // call the handler + suite.accountsModule.AccountFollowPOSTHandler(ctx) + + // 1. status should be Not Acceptable due to self-follow attempt + suite.Equal(http.StatusNotAcceptable, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + + // check the response + b, err := ioutil.ReadAll(result.Body) + _ = b + assert.NoError(suite.T(), err) +} + +func TestFollowTestSuite(t *testing.T) { + suite.Run(t, new(FollowTestSuite)) +} diff --git a/internal/api/client/accounts/followers.go b/internal/api/client/accounts/followers.go @@ -0,0 +1,98 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 accounts + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// AccountFollowersGETHandler swagger:operation GET /api/v1/accounts/{id}/followers accountFollowers +// +// See followers of account with given id. +// +// --- +// tags: +// - accounts +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: string +// description: Account ID. +// in: path +// required: true +// +// security: +// - OAuth2 Bearer: +// - read:accounts +// +// responses: +// '200': +// name: accounts +// description: Array of accounts that follow this account. +// schema: +// type: array +// items: +// "$ref": "#/definitions/account" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) AccountFollowersGETHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + targetAcctID := c.Param(IDKey) + if targetAcctID == "" { + err := errors.New("no account id specified") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + followers, errWithCode := m.processor.AccountFollowersGet(c.Request.Context(), authed, targetAcctID) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + c.JSON(http.StatusOK, followers) +} diff --git a/internal/api/client/accounts/following.go b/internal/api/client/accounts/following.go @@ -0,0 +1,98 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 accounts + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// AccountFollowingGETHandler swagger:operation GET /api/v1/accounts/{id}/following accountFollowing +// +// See accounts followed by given account id. +// +// --- +// tags: +// - accounts +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: string +// description: Account ID. +// in: path +// required: true +// +// security: +// - OAuth2 Bearer: +// - read:accounts +// +// responses: +// '200': +// name: accounts +// description: Array of accounts that are followed by this account. +// schema: +// type: array +// items: +// "$ref": "#/definitions/account" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) AccountFollowingGETHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + targetAcctID := c.Param(IDKey) + if targetAcctID == "" { + err := errors.New("no account id specified") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + following, errWithCode := m.processor.AccountFollowingGet(c.Request.Context(), authed, targetAcctID) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + c.JSON(http.StatusOK, following) +} diff --git a/internal/api/client/accounts/relationships.go b/internal/api/client/accounts/relationships.go @@ -0,0 +1,93 @@ +package accounts + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// AccountRelationshipsGETHandler swagger:operation GET /api/v1/accounts/relationships accountRelationships +// +// See your account's relationships with the given account IDs. +// +// --- +// tags: +// - accounts +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: array +// items: +// type: string +// description: Account IDs. +// in: query +// required: true +// +// security: +// - OAuth2 Bearer: +// - read:accounts +// +// responses: +// '200': +// name: account relationships +// description: Array of account relationships. +// schema: +// type: array +// items: +// "$ref": "#/definitions/accountRelationship" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) AccountRelationshipsGETHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + targetAccountIDs := c.QueryArray("id[]") + if len(targetAccountIDs) == 0 { + // check fallback -- let's be generous and see if maybe it's just set as 'id'? + id := c.Query("id") + if id == "" { + err = errors.New("no account id(s) specified in query") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + targetAccountIDs = append(targetAccountIDs, id) + } + + relationships := []apimodel.Relationship{} + + for _, targetAccountID := range targetAccountIDs { + r, errWithCode := m.processor.AccountRelationshipGet(c.Request.Context(), authed, targetAccountID) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + relationships = append(relationships, *r) + } + + c.JSON(http.StatusOK, relationships) +} diff --git a/internal/api/client/accounts/statuses.go b/internal/api/client/accounts/statuses.go @@ -0,0 +1,246 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 accounts + +import ( + "errors" + "fmt" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// AccountStatusesGETHandler swagger:operation GET /api/v1/accounts/{id}/statuses accountStatuses +// +// See statuses posted by the requested account. +// +// The statuses will be returned in descending chronological order (newest first), with sequential IDs (bigger = newer). +// +// --- +// tags: +// - accounts +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: string +// description: Account ID. +// in: path +// required: true +// - +// name: limit +// type: integer +// description: Number of statuses to return. +// default: 30 +// in: query +// required: false +// - +// name: exclude_replies +// type: boolean +// description: Exclude statuses that are a reply to another status. +// default: false +// in: query +// required: false +// - +// name: exclude_reblogs +// type: boolean +// description: Exclude statuses that are a reblog/boost of another status. +// default: false +// in: query +// required: false +// - +// name: max_id +// type: string +// description: >- +// Return only statuses *OLDER* than the given max status ID. +// The status with the specified ID will not be included in the response. +// in: query +// - +// name: min_id +// type: string +// description: >- +// Return only statuses *NEWER* than the given min status ID. +// The status with the specified ID will not be included in the response. +// in: query +// required: false +// - +// name: pinned_only +// type: boolean +// description: Show only pinned statuses. In other words, exclude statuses that are not pinned to the given account ID. +// default: false +// in: query +// required: false +// - +// name: only_media +// type: boolean +// description: Show only statuses with media attachments. +// default: false +// in: query +// required: false +// - +// name: only_public +// type: boolean +// description: Show only statuses with a privacy setting of 'public'. +// default: false +// in: query +// required: false +// +// security: +// - OAuth2 Bearer: +// - read:accounts +// +// responses: +// '200': +// name: statuses +// description: Array of statuses. +// schema: +// type: array +// items: +// "$ref": "#/definitions/status" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) AccountStatusesGETHandler(c *gin.Context) { + authed, err := oauth.Authed(c, false, false, false, false) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + targetAcctID := c.Param(IDKey) + if targetAcctID == "" { + err := errors.New("no account id specified") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + limit := 30 + limitString := c.Query(LimitKey) + if limitString != "" { + i, err := strconv.ParseInt(limitString, 10, 32) + if err != nil { + err := fmt.Errorf("error parsing %s: %s", LimitKey, err) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + limit = int(i) + } + + excludeReplies := false + excludeRepliesString := c.Query(ExcludeRepliesKey) + if excludeRepliesString != "" { + i, err := strconv.ParseBool(excludeRepliesString) + if err != nil { + err := fmt.Errorf("error parsing %s: %s", ExcludeRepliesKey, err) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + excludeReplies = i + } + + excludeReblogs := false + excludeReblogsString := c.Query(ExcludeReblogsKey) + if excludeReblogsString != "" { + i, err := strconv.ParseBool(excludeReblogsString) + if err != nil { + err := fmt.Errorf("error parsing %s: %s", ExcludeReblogsKey, err) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + excludeReblogs = i + } + + maxID := "" + maxIDString := c.Query(MaxIDKey) + if maxIDString != "" { + maxID = maxIDString + } + + minID := "" + minIDString := c.Query(MinIDKey) + if minIDString != "" { + minID = minIDString + } + + pinnedOnly := false + pinnedString := c.Query(PinnedKey) + if pinnedString != "" { + i, err := strconv.ParseBool(pinnedString) + if err != nil { + err := fmt.Errorf("error parsing %s: %s", PinnedKey, err) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + pinnedOnly = i + } + + mediaOnly := false + mediaOnlyString := c.Query(OnlyMediaKey) + if mediaOnlyString != "" { + i, err := strconv.ParseBool(mediaOnlyString) + if err != nil { + err := fmt.Errorf("error parsing %s: %s", OnlyMediaKey, err) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + mediaOnly = i + } + + publicOnly := false + publicOnlyString := c.Query(OnlyPublicKey) + if publicOnlyString != "" { + i, err := strconv.ParseBool(publicOnlyString) + if err != nil { + err := fmt.Errorf("error parsing %s: %s", OnlyPublicKey, err) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + publicOnly = i + } + + resp, errWithCode := m.processor.AccountStatusesGet(c.Request.Context(), authed, targetAcctID, limit, excludeReplies, excludeReblogs, maxID, minID, pinnedOnly, mediaOnly, publicOnly) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + if resp.LinkHeader != "" { + c.Header("Link", resp.LinkHeader) + } + c.JSON(http.StatusOK, resp.Items) +} diff --git a/internal/api/client/accounts/statuses_test.go b/internal/api/client/accounts/statuses_test.go @@ -0,0 +1,123 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 accounts_test + +import ( + "encoding/json" + "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/accounts" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +) + +type AccountStatusesTestSuite struct { + AccountStandardTestSuite +} + +func (suite *AccountStatusesTestSuite) TestGetStatusesPublicOnly() { + // set up the request + // we're getting statuses of admin + targetAccount := suite.testAccounts["admin_account"] + recorder := httptest.NewRecorder() + ctx := suite.newContext(recorder, http.MethodGet, nil, fmt.Sprintf("/api/v1/accounts/%s/statuses?limit=20&only_media=false&only_public=true", targetAccount.ID), "") + ctx.Params = gin.Params{ + gin.Param{ + Key: accounts.IDKey, + Value: targetAccount.ID, + }, + } + + // call the handler + suite.accountsModule.AccountStatusesGETHandler(ctx) + + // 1. we should have OK because our request was valid + suite.Equal(http.StatusOK, recorder.Code) + + // 2. we should have no error message in the result body + result := recorder.Result() + defer result.Body.Close() + + // check the response + b, err := ioutil.ReadAll(result.Body) + assert.NoError(suite.T(), err) + + // unmarshal the returned statuses + apimodelStatuses := []*apimodel.Status{} + err = json.Unmarshal(b, &apimodelStatuses) + suite.NoError(err) + suite.NotEmpty(apimodelStatuses) + + for _, s := range apimodelStatuses { + suite.Equal(apimodel.VisibilityPublic, s.Visibility) + } + + suite.Equal(`<http://localhost:8080/api/v1/accounts/01F8MH17FWEB39HZJ76B6VXSKF/statuses?limit=20&max_id=01F8MH75CBF9JFX4ZAD54N0W0R&exclude_replies=false&exclude_reblogs=false&pinned_only=false&only_media=false&only_public=true>; rel="next", <http://localhost:8080/api/v1/accounts/01F8MH17FWEB39HZJ76B6VXSKF/statuses?limit=20&min_id=01G36SF3V6Y6V5BF9P4R7PQG7G&exclude_replies=false&exclude_reblogs=false&pinned_only=false&only_media=false&only_public=true>; rel="prev"`, result.Header.Get("link")) +} + +func (suite *AccountStatusesTestSuite) TestGetStatusesPublicOnlyMediaOnly() { + // set up the request + // we're getting statuses of admin + targetAccount := suite.testAccounts["admin_account"] + recorder := httptest.NewRecorder() + ctx := suite.newContext(recorder, http.MethodGet, nil, fmt.Sprintf("/api/v1/accounts/%s/statuses?limit=20&only_media=true&only_public=true", targetAccount.ID), "") + ctx.Params = gin.Params{ + gin.Param{ + Key: accounts.IDKey, + Value: targetAccount.ID, + }, + } + + // call the handler + suite.accountsModule.AccountStatusesGETHandler(ctx) + + // 1. we should have OK because our request was valid + suite.Equal(http.StatusOK, recorder.Code) + + // 2. we should have no error message in the result body + result := recorder.Result() + defer result.Body.Close() + + // check the response + b, err := ioutil.ReadAll(result.Body) + assert.NoError(suite.T(), err) + + // unmarshal the returned statuses + apimodelStatuses := []*apimodel.Status{} + err = json.Unmarshal(b, &apimodelStatuses) + suite.NoError(err) + suite.NotEmpty(apimodelStatuses) + + for _, s := range apimodelStatuses { + suite.NotEmpty(s.MediaAttachments) + suite.Equal(apimodel.VisibilityPublic, s.Visibility) + } + + suite.Equal(`<http://localhost:8080/api/v1/accounts/01F8MH17FWEB39HZJ76B6VXSKF/statuses?limit=20&max_id=01F8MH75CBF9JFX4ZAD54N0W0R&exclude_replies=false&exclude_reblogs=false&pinned_only=false&only_media=true&only_public=true>; rel="next", <http://localhost:8080/api/v1/accounts/01F8MH17FWEB39HZJ76B6VXSKF/statuses?limit=20&min_id=01F8MH75CBF9JFX4ZAD54N0W0R&exclude_replies=false&exclude_reblogs=false&pinned_only=false&only_media=true&only_public=true>; rel="prev"`, result.Header.Get("link")) +} + +func TestAccountStatusesTestSuite(t *testing.T) { + suite.Run(t, new(AccountStatusesTestSuite)) +} diff --git a/internal/api/client/accounts/unblock.go b/internal/api/client/accounts/unblock.go @@ -0,0 +1,96 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 accounts + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// AccountUnblockPOSTHandler swagger:operation POST /api/v1/accounts/{id}/unblock accountUnblock +// +// Unblock account with ID. +// +// --- +// tags: +// - accounts +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: string +// description: The id of the account to unblock. +// in: path +// required: true +// +// security: +// - OAuth2 Bearer: +// - write:blocks +// +// responses: +// '200': +// name: account relationship +// description: Your relationship to this account. +// schema: +// "$ref": "#/definitions/accountRelationship" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) AccountUnblockPOSTHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + targetAcctID := c.Param(IDKey) + if targetAcctID == "" { + err := errors.New("no account id specified") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + relationship, errWithCode := m.processor.AccountBlockRemove(c.Request.Context(), authed, targetAcctID) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + c.JSON(http.StatusOK, relationship) +} diff --git a/internal/api/client/accounts/unfollow.go b/internal/api/client/accounts/unfollow.go @@ -0,0 +1,96 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 accounts + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// AccountUnfollowPOSTHandler swagger:operation POST /api/v1/accounts/{id}/unfollow accountUnfollow +// +// Unfollow account with id. +// +// --- +// tags: +// - accounts +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: string +// description: The id of the account to unfollow. +// in: path +// required: true +// +// security: +// - OAuth2 Bearer: +// - write:follows +// +// responses: +// '200': +// name: account relationship +// description: Your relationship to this account. +// schema: +// "$ref": "#/definitions/accountRelationship" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) AccountUnfollowPOSTHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + targetAcctID := c.Param(IDKey) + if targetAcctID == "" { + err := errors.New("no account id specified") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + relationship, errWithCode := m.processor.AccountFollowRemove(c.Request.Context(), authed, targetAcctID) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + c.JSON(http.StatusOK, relationship) +} diff --git a/internal/api/client/admin/accountaction.go b/internal/api/client/admin/accountaction.go @@ -24,8 +24,8 @@ import ( "net/http" "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/api/model" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -85,38 +85,38 @@ import ( func (m *Module) AccountActionPOSTHandler(c *gin.Context) { authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if !*authed.User.Admin { err := fmt.Errorf("user %s not an admin", authed.User.ID) - api.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGet) return } - form := &model.AdminAccountActionRequest{} + form := &apimodel.AdminAccountActionRequest{} if err := c.ShouldBind(form); err != nil { - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } if form.Type == "" { err := errors.New("no type specified") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } targetAcctID := c.Param(IDKey) if targetAcctID == "" { err := errors.New("no account id specified") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } form.TargetAccountID = targetAcctID if errWithCode := m.processor.AdminAccountAction(c.Request.Context(), authed, form); errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/admin/admin.go b/internal/api/client/admin/admin.go @@ -21,14 +21,13 @@ package admin import ( "net/http" - "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/processing" - "github.com/superseriousbusiness/gotosocial/internal/router" ) const ( - // BasePath is the base API path for this module. - BasePath = "/api/v1/admin" + // BasePath is the base API path for this module, excluding the api prefix + BasePath = "/v1/admin" // EmojiPath is used for posting/deleting custom emojis. EmojiPath = BasePath + "/custom_emojis" // EmojiPathWithID is used for interacting with a single emoji. @@ -68,32 +67,28 @@ const ( DomainQueryKey = "domain" ) -// Module implements the ClientAPIModule interface for admin-related actions (reports, emojis, etc) type Module struct { processor processing.Processor } -// New returns a new admin module -func New(processor processing.Processor) api.ClientModule { +func New(processor processing.Processor) *Module { return &Module{ processor: processor, } } -// Route attaches all routes from this module to the given router -func (m *Module) Route(r router.Router) error { - r.AttachHandler(http.MethodPost, EmojiPath, m.EmojiCreatePOSTHandler) - r.AttachHandler(http.MethodGet, EmojiPath, m.EmojisGETHandler) - r.AttachHandler(http.MethodDelete, EmojiPathWithID, m.EmojiDELETEHandler) - r.AttachHandler(http.MethodGet, EmojiPathWithID, m.EmojiGETHandler) - r.AttachHandler(http.MethodPatch, EmojiPathWithID, m.EmojiPATCHHandler) - r.AttachHandler(http.MethodPost, DomainBlocksPath, m.DomainBlocksPOSTHandler) - r.AttachHandler(http.MethodGet, DomainBlocksPath, m.DomainBlocksGETHandler) - r.AttachHandler(http.MethodGet, DomainBlocksPathWithID, m.DomainBlockGETHandler) - r.AttachHandler(http.MethodDelete, DomainBlocksPathWithID, m.DomainBlockDELETEHandler) - r.AttachHandler(http.MethodPost, AccountsActionPath, m.AccountActionPOSTHandler) - r.AttachHandler(http.MethodPost, MediaCleanupPath, m.MediaCleanupPOSTHandler) - r.AttachHandler(http.MethodPost, MediaRefetchPath, m.MediaRefetchPOSTHandler) - r.AttachHandler(http.MethodGet, EmojiCategoriesPath, m.EmojiCategoriesGETHandler) - return nil +func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) { + attachHandler(http.MethodPost, EmojiPath, m.EmojiCreatePOSTHandler) + attachHandler(http.MethodGet, EmojiPath, m.EmojisGETHandler) + attachHandler(http.MethodDelete, EmojiPathWithID, m.EmojiDELETEHandler) + attachHandler(http.MethodGet, EmojiPathWithID, m.EmojiGETHandler) + attachHandler(http.MethodPatch, EmojiPathWithID, m.EmojiPATCHHandler) + attachHandler(http.MethodPost, DomainBlocksPath, m.DomainBlocksPOSTHandler) + attachHandler(http.MethodGet, DomainBlocksPath, m.DomainBlocksGETHandler) + attachHandler(http.MethodGet, DomainBlocksPathWithID, m.DomainBlockGETHandler) + attachHandler(http.MethodDelete, DomainBlocksPathWithID, m.DomainBlockDELETEHandler) + attachHandler(http.MethodPost, AccountsActionPath, m.AccountActionPOSTHandler) + attachHandler(http.MethodPost, MediaCleanupPath, m.MediaCleanupPOSTHandler) + attachHandler(http.MethodPost, MediaRefetchPath, m.MediaRefetchPOSTHandler) + attachHandler(http.MethodGet, EmojiCategoriesPath, m.EmojiCategoriesGETHandler) } diff --git a/internal/api/client/admin/admin_test.go b/internal/api/client/admin/admin_test.go @@ -93,7 +93,7 @@ func (suite *AdminStandardTestSuite) SetupTest() { suite.sentEmails = make(map[string]string) suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails) suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, suite.mediaManager, clientWorker, fedWorker) - suite.adminModule = admin.New(suite.processor).(*admin.Module) + suite.adminModule = admin.New(suite.processor) testrig.StandardDBSetup(suite.db, nil) testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") } diff --git a/internal/api/client/admin/domainblockcreate.go b/internal/api/client/admin/domainblockcreate.go @@ -25,8 +25,8 @@ import ( "strconv" "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/api/model" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -126,18 +126,18 @@ import ( func (m *Module) DomainBlocksPOSTHandler(c *gin.Context) { authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if !*authed.User.Admin { err := fmt.Errorf("user %s not an admin", authed.User.ID) - api.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGet) return } - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } @@ -147,21 +147,21 @@ func (m *Module) DomainBlocksPOSTHandler(c *gin.Context) { i, err := strconv.ParseBool(importString) if err != nil { err := fmt.Errorf("error parsing %s: %s", ImportQueryKey, err) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } imp = i } - form := &model.DomainBlockCreateRequest{} + form := &apimodel.DomainBlockCreateRequest{} if err := c.ShouldBind(form); err != nil { - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } if err := validateCreateDomainBlock(form, imp); err != nil { err := fmt.Errorf("error validating form: %s", err) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } @@ -169,7 +169,7 @@ func (m *Module) DomainBlocksPOSTHandler(c *gin.Context) { // we're importing multiple blocks domainBlocks, errWithCode := m.processor.AdminDomainBlocksImport(c.Request.Context(), authed, form) if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } c.JSON(http.StatusOK, domainBlocks) @@ -179,13 +179,13 @@ func (m *Module) DomainBlocksPOSTHandler(c *gin.Context) { // we're just creating one block domainBlock, errWithCode := m.processor.AdminDomainBlockCreate(c.Request.Context(), authed, form) if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } c.JSON(http.StatusOK, domainBlock) } -func validateCreateDomainBlock(form *model.DomainBlockCreateRequest, imp bool) error { +func validateCreateDomainBlock(form *apimodel.DomainBlockCreateRequest, imp bool) error { if imp { if form.Domains.Size == 0 { return errors.New("import was specified but list of domains is empty") diff --git a/internal/api/client/admin/domainblockdelete.go b/internal/api/client/admin/domainblockdelete.go @@ -24,7 +24,7 @@ import ( "net/http" "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -72,31 +72,31 @@ import ( func (m *Module) DomainBlockDELETEHandler(c *gin.Context) { authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if !*authed.User.Admin { err := fmt.Errorf("user %s not an admin", authed.User.ID) - api.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGet) return } - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } domainBlockID := c.Param(IDKey) if domainBlockID == "" { err := errors.New("no domain block id specified") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } domainBlock, errWithCode := m.processor.AdminDomainBlockDelete(c.Request.Context(), authed, domainBlockID) if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/admin/domainblockget.go b/internal/api/client/admin/domainblockget.go @@ -25,7 +25,7 @@ import ( "strconv" "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -73,25 +73,25 @@ import ( func (m *Module) DomainBlockGETHandler(c *gin.Context) { authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if !*authed.User.Admin { err := fmt.Errorf("user %s not an admin", authed.User.ID) - api.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGet) return } - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } domainBlockID := c.Param(IDKey) if domainBlockID == "" { err := errors.New("no domain block id specified") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } @@ -101,7 +101,7 @@ func (m *Module) DomainBlockGETHandler(c *gin.Context) { i, err := strconv.ParseBool(exportString) if err != nil { err := fmt.Errorf("error parsing %s: %s", ExportQueryKey, err) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } export = i @@ -109,7 +109,7 @@ func (m *Module) DomainBlockGETHandler(c *gin.Context) { domainBlock, errWithCode := m.processor.AdminDomainBlockGet(c.Request.Context(), authed, domainBlockID, export) if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/admin/domainblocksget.go b/internal/api/client/admin/domainblocksget.go @@ -24,7 +24,7 @@ import ( "strconv" "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -78,18 +78,18 @@ import ( func (m *Module) DomainBlocksGETHandler(c *gin.Context) { authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if !*authed.User.Admin { err := fmt.Errorf("user %s not an admin", authed.User.ID) - api.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGet) return } - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } @@ -99,7 +99,7 @@ func (m *Module) DomainBlocksGETHandler(c *gin.Context) { i, err := strconv.ParseBool(exportString) if err != nil { err := fmt.Errorf("error parsing %s: %s", ExportQueryKey, err) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } export = i @@ -107,7 +107,7 @@ func (m *Module) DomainBlocksGETHandler(c *gin.Context) { domainBlocks, errWithCode := m.processor.AdminDomainBlocksGet(c.Request.Context(), authed, export) if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/admin/emojicategoriesget.go b/internal/api/client/admin/emojicategoriesget.go @@ -23,7 +23,7 @@ import ( "net/http" "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -69,24 +69,24 @@ import ( func (m *Module) EmojiCategoriesGETHandler(c *gin.Context) { authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if !*authed.User.Admin { err := fmt.Errorf("user %s not an admin", authed.User.ID) - api.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGet) return } - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } categories, errWithCode := m.processor.AdminEmojiCategoriesGet(c.Request.Context()) if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/admin/emojicreate.go b/internal/api/client/admin/emojicreate.go @@ -24,8 +24,8 @@ import ( "net/http" "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/api/model" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" @@ -100,42 +100,42 @@ import ( func (m *Module) EmojiCreatePOSTHandler(c *gin.Context) { authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if !*authed.User.Admin { err := fmt.Errorf("user %s not an admin", authed.User.ID) - api.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGet) return } - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } - form := &model.EmojiCreateRequest{} + form := &apimodel.EmojiCreateRequest{} if err := c.ShouldBind(form); err != nil { - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } if err := validateCreateEmoji(form); err != nil { - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } apiEmoji, errWithCode := m.processor.AdminEmojiCreate(c.Request.Context(), authed, form) if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } c.JSON(http.StatusOK, apiEmoji) } -func validateCreateEmoji(form *model.EmojiCreateRequest) error { +func validateCreateEmoji(form *apimodel.EmojiCreateRequest) error { if form.Image == nil || form.Image.Size == 0 { return errors.New("no emoji given") } diff --git a/internal/api/client/admin/emojidelete.go b/internal/api/client/admin/emojidelete.go @@ -24,7 +24,7 @@ import ( "net/http" "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -78,31 +78,31 @@ import ( func (m *Module) EmojiDELETEHandler(c *gin.Context) { authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if !*authed.User.Admin { err := fmt.Errorf("user %s not an admin", authed.User.ID) - api.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGet) return } - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } emojiID := c.Param(IDKey) if emojiID == "" { err := errors.New("no emoji id specified") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } emoji, errWithCode := m.processor.AdminEmojiDelete(c.Request.Context(), authed, emojiID) if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/admin/emojiget.go b/internal/api/client/admin/emojiget.go @@ -24,7 +24,7 @@ import ( "net/http" "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -68,31 +68,31 @@ import ( func (m *Module) EmojiGETHandler(c *gin.Context) { authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if !*authed.User.Admin { err := fmt.Errorf("user %s not an admin", authed.User.ID) - api.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGet) return } - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } emojiID := c.Param(IDKey) if emojiID == "" { err := errors.New("no emoji id specified") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } emoji, errWithCode := m.processor.AdminEmojiGet(c.Request.Context(), authed, emojiID) if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/admin/emojisget.go b/internal/api/client/admin/emojisget.go @@ -25,7 +25,7 @@ import ( "strings" "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtserror" @@ -125,18 +125,18 @@ import ( func (m *Module) EmojisGETHandler(c *gin.Context) { authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if !*authed.User.Admin { err := fmt.Errorf("user %s not an admin", authed.User.ID) - api.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGet) return } - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } @@ -149,7 +149,7 @@ func (m *Module) EmojisGETHandler(c *gin.Context) { i, err := strconv.ParseInt(limitString, 10, 32) if err != nil { err := fmt.Errorf("error parsing %s: %s", LimitKey, err) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } limit = int(i) @@ -177,7 +177,7 @@ func (m *Module) EmojisGETHandler(c *gin.Context) { shortcode = strings.Trim(filter[10:], ":") // remove any errant ":" default: err := fmt.Errorf("filter %s not recognized; accepted values are 'domain:[domain]', 'disabled', 'enabled', 'shortcode:[shortcode]'", filter) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } } @@ -200,7 +200,7 @@ func (m *Module) EmojisGETHandler(c *gin.Context) { resp, errWithCode := m.processor.AdminEmojisGet(c.Request.Context(), authed, domain, includeDisabled, includeEnabled, shortcode, maxShortcodeDomain, minShortcodeDomain, limit) if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/admin/emojiupdate.go b/internal/api/client/admin/emojiupdate.go @@ -25,8 +25,8 @@ import ( "strings" "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/api/model" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" @@ -123,42 +123,42 @@ import ( func (m *Module) EmojiPATCHHandler(c *gin.Context) { authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if !*authed.User.Admin { err := fmt.Errorf("user %s not an admin", authed.User.ID) - api.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGet) return } - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } emojiID := c.Param(IDKey) if emojiID == "" { err := errors.New("no emoji id specified") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } - form := &model.EmojiUpdateRequest{} + form := &apimodel.EmojiUpdateRequest{} if err := c.ShouldBind(form); err != nil { - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } if err := validateUpdateEmoji(form); err != nil { - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } emoji, errWithCode := m.processor.AdminEmojiUpdate(c.Request.Context(), emojiID, form) if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } @@ -166,14 +166,14 @@ func (m *Module) EmojiPATCHHandler(c *gin.Context) { } // do a first pass on the form here -func validateUpdateEmoji(form *model.EmojiUpdateRequest) error { +func validateUpdateEmoji(form *apimodel.EmojiUpdateRequest) error { // check + normalize update type so we don't need // to do this trimming + lowercasing again later switch strings.TrimSpace(strings.ToLower(string(form.Type))) { - case string(model.EmojiUpdateDisable): + case string(apimodel.EmojiUpdateDisable): // no params required for this one, so don't bother checking - form.Type = model.EmojiUpdateDisable - case string(model.EmojiUpdateCopy): + form.Type = apimodel.EmojiUpdateDisable + case string(apimodel.EmojiUpdateCopy): // need at least a valid shortcode when doing a copy if form.Shortcode == nil { return errors.New("emoji action type was 'copy' but no shortcode was provided") @@ -190,8 +190,8 @@ func validateUpdateEmoji(form *model.EmojiUpdateRequest) error { } } - form.Type = model.EmojiUpdateCopy - case string(model.EmojiUpdateModify): + form.Type = apimodel.EmojiUpdateCopy + case string(apimodel.EmojiUpdateModify): // need either image or category name for modify hasImage := form.Image != nil && form.Image.Size != 0 hasCategoryName := form.CategoryName != nil @@ -212,7 +212,7 @@ func validateUpdateEmoji(form *model.EmojiUpdateRequest) error { } } - form.Type = model.EmojiUpdateModify + form.Type = apimodel.EmojiUpdateModify default: return errors.New("emoji action type must be one of 'disable', 'copy', 'modify'") } diff --git a/internal/api/client/admin/mediacleanup.go b/internal/api/client/admin/mediacleanup.go @@ -23,8 +23,8 @@ import ( "net/http" "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/api/model" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" @@ -71,19 +71,19 @@ import ( func (m *Module) MediaCleanupPOSTHandler(c *gin.Context) { authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if !*authed.User.Admin { err := fmt.Errorf("user %s not an admin", authed.User.ID) - api.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGet) return } - form := &model.MediaCleanupRequest{} + form := &apimodel.MediaCleanupRequest{} if err := c.ShouldBind(form); err != nil { - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } @@ -98,7 +98,7 @@ func (m *Module) MediaCleanupPOSTHandler(c *gin.Context) { } if errWithCode := m.processor.AdminMediaPrune(c.Request.Context(), remoteCacheDays); errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/admin/mediarefetch.go b/internal/api/client/admin/mediarefetch.go @@ -23,7 +23,7 @@ import ( "net/http" "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -74,18 +74,18 @@ import ( func (m *Module) MediaRefetchPOSTHandler(c *gin.Context) { authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if !*authed.User.Admin { err := fmt.Errorf("user %s not an admin", authed.User.ID) - api.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGet) return } if errWithCode := m.processor.AdminMediaRefetch(c.Request.Context(), authed, c.Query(DomainQueryKey)); errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/app/app.go b/internal/api/client/app/app.go @@ -1,48 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/processing" - "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 { - processor processing.Processor -} - -// New returns a new auth module -func New(processor processing.Processor) api.ClientModule { - return &Module{ - processor: processor, - } -} - -// 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 @@ -1,21 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 @@ -1,126 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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" - "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// these consts are used to ensure users can't spam huge entries into our database -const ( - formFieldLen = 1024 - formRedirectLen = 2056 -) - -// AppsPOSTHandler swagger:operation POST /api/v1/apps appCreate -// -// Register a new application on this instance. -// -// The registered application can be used to obtain an application token. -// This can then be used to register a new account, or (through user auth) obtain an access token. -// -// The parameters can also be given in the body of the request, as JSON, if the content-type is set to 'application/json'. -// The parameters can also be given in the body of the request, as XML, if the content-type is set to 'application/xml'. -// -// --- -// tags: -// - apps -// -// consumes: -// - application/json -// - application/xml -// - application/x-www-form-urlencoded -// -// produces: -// - application/json -// -// responses: -// '200': -// description: "The newly-created application." -// schema: -// "$ref": "#/definitions/application" -// '400': -// description: bad request -// '401': -// description: unauthorized -// '403': -// description: forbidden -// '404': -// description: not found -// '406': -// description: not acceptable -// '500': -// description: internal server error -func (m *Module) AppsPOSTHandler(c *gin.Context) { - authed, err := oauth.Authed(c, false, false, false, false) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) - return - } - - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - form := &model.ApplicationCreateRequest{} - if err := c.ShouldBind(form); err != nil { - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - if len([]rune(form.ClientName)) > formFieldLen { - err := fmt.Errorf("client_name must be less than %d characters", formFieldLen) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - if len([]rune(form.RedirectURIs)) > formRedirectLen { - err := fmt.Errorf("redirect_uris must be less than %d characters", formRedirectLen) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - if len([]rune(form.Scopes)) > formFieldLen { - err := fmt.Errorf("scopes must be less than %d characters", formFieldLen) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - if len([]rune(form.Website)) > formFieldLen { - err := fmt.Errorf("website must be less than %d characters", formFieldLen) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - apiApp, errWithCode := m.processor.AppCreate(c.Request.Context(), authed, form) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - c.JSON(http.StatusOK, apiApp) -} diff --git a/internal/api/client/apps/appcreate.go b/internal/api/client/apps/appcreate.go @@ -0,0 +1,126 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 apps + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// these consts are used to ensure users can't spam huge entries into our database +const ( + formFieldLen = 1024 + formRedirectLen = 2056 +) + +// AppsPOSTHandler swagger:operation POST /api/v1/apps appCreate +// +// Register a new application on this instance. +// +// The registered application can be used to obtain an application token. +// This can then be used to register a new account, or (through user auth) obtain an access token. +// +// The parameters can also be given in the body of the request, as JSON, if the content-type is set to 'application/json'. +// The parameters can also be given in the body of the request, as XML, if the content-type is set to 'application/xml'. +// +// --- +// tags: +// - apps +// +// consumes: +// - application/json +// - application/xml +// - application/x-www-form-urlencoded +// +// produces: +// - application/json +// +// responses: +// '200': +// description: "The newly-created application." +// schema: +// "$ref": "#/definitions/application" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) AppsPOSTHandler(c *gin.Context) { + authed, err := oauth.Authed(c, false, false, false, false) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + form := &apimodel.ApplicationCreateRequest{} + if err := c.ShouldBind(form); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + if len([]rune(form.ClientName)) > formFieldLen { + err := fmt.Errorf("client_name must be less than %d characters", formFieldLen) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + if len([]rune(form.RedirectURIs)) > formRedirectLen { + err := fmt.Errorf("redirect_uris must be less than %d characters", formRedirectLen) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + if len([]rune(form.Scopes)) > formFieldLen { + err := fmt.Errorf("scopes must be less than %d characters", formFieldLen) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + if len([]rune(form.Website)) > formFieldLen { + err := fmt.Errorf("website must be less than %d characters", formFieldLen) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + apiApp, errWithCode := m.processor.AppCreate(c.Request.Context(), authed, form) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + c.JSON(http.StatusOK, apiApp) +} diff --git a/internal/api/client/apps/apps.go b/internal/api/client/apps/apps.go @@ -0,0 +1,43 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 apps + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/processing" +) + +// BasePath is the base path for this api module, excluding the api prefix +const BasePath = "/v1/apps" + +type Module struct { + processor processing.Processor +} + +func New(processor processing.Processor) *Module { + return &Module{ + processor: processor, + } +} + +func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) { + attachHandler(http.MethodPost, BasePath, m.AppsPOSTHandler) +} diff --git a/internal/api/client/auth/auth.go b/internal/api/client/auth/auth.go @@ -1,105 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/oidc" - "github.com/superseriousbusiness/gotosocial/internal/processing" - "github.com/superseriousbusiness/gotosocial/internal/router" -) - -/* #nosec G101 */ -const ( - // AuthSignInPath is the API path for users to sign in through - AuthSignInPath = "/auth/sign_in" - - // CheckYourEmailPath users land here after registering a new account, instructs them to confirm thier email - CheckYourEmailPath = "/check_your_email" - - // WaitForApprovalPath users land here after confirming thier email but before an admin approves thier account - // (if such is required) - WaitForApprovalPath = "/wait_for_approval" - - // AccountDisabledPath users land here when thier account is suspended by an admin - AccountDisabledPath = "/account_disabled" - - // 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" - - // OauthFinalizePath is the API path for completing user registration with additional user details - OauthFinalizePath = "/oauth/finalize" - - // CallbackPath is the API path for receiving callback tokens from external OIDC providers - CallbackPath = oidc.CallbackPath - - callbackStateParam = "state" - callbackCodeParam = "code" - - sessionUserID = "userid" - sessionClientID = "client_id" - sessionRedirectURI = "redirect_uri" - sessionForceLogin = "force_login" - sessionResponseType = "response_type" - sessionScope = "scope" - sessionInternalState = "internal_state" - sessionClientState = "client_state" - sessionClaims = "claims" - sessionAppID = "app_id" -) - -// Module implements the ClientAPIModule interface for -type Module struct { - db db.DB - idp oidc.IDP - processor processing.Processor -} - -// New returns a new auth module -func New(db db.DB, idp oidc.IDP, processor processing.Processor) api.ClientModule { - return &Module{ - db: db, - idp: idp, - processor: processor, - } -} - -// 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.AttachHandler(http.MethodGet, CallbackPath, m.CallbackGETHandler) - s.AttachHandler(http.MethodPost, OauthFinalizePath, m.FinalizePOSTHandler) - - s.AttachHandler(http.MethodGet, oauth.OOBTokenPath, m.OobHandler) - return nil -} diff --git a/internal/api/client/auth/auth_test.go b/internal/api/client/auth/auth_test.go @@ -1,139 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 ( - "bytes" - "context" - "fmt" - "net/http/httptest" - - "github.com/gin-contrib/sessions" - "github.com/gin-contrib/sessions/memstore" - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/api/client/auth" - "github.com/superseriousbusiness/gotosocial/internal/concurrency" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/email" - "github.com/superseriousbusiness/gotosocial/internal/federation" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/messages" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/oidc" - "github.com/superseriousbusiness/gotosocial/internal/processing" - "github.com/superseriousbusiness/gotosocial/internal/router" - "github.com/superseriousbusiness/gotosocial/internal/storage" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type AuthStandardTestSuite struct { - suite.Suite - db db.DB - storage *storage.Driver - mediaManager media.Manager - federator federation.Federator - processor processing.Processor - emailSender email.Sender - idp oidc.IDP - oauthServer oauth.Server - - // standard suite models - testTokens map[string]*gtsmodel.Token - testClients map[string]*gtsmodel.Client - testApplications map[string]*gtsmodel.Application - testUsers map[string]*gtsmodel.User - testAccounts map[string]*gtsmodel.Account - - // module being tested - authModule *auth.Module -} - -const ( - sessionUserID = "userid" - sessionClientID = "client_id" -) - -func (suite *AuthStandardTestSuite) SetupSuite() { - suite.testTokens = testrig.NewTestTokens() - suite.testClients = testrig.NewTestClients() - suite.testApplications = testrig.NewTestApplications() - suite.testUsers = testrig.NewTestUsers() - suite.testAccounts = testrig.NewTestAccounts() -} - -func (suite *AuthStandardTestSuite) SetupTest() { - testrig.InitTestConfig() - testrig.InitTestLog() - - fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) - clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) - - suite.db = testrig.NewTestDB() - suite.storage = testrig.NewInMemoryStorage() - suite.mediaManager = testrig.NewTestMediaManager(suite.db, suite.storage) - suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker), suite.storage, suite.mediaManager, fedWorker) - suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil) - suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, suite.mediaManager, clientWorker, fedWorker) - - suite.oauthServer = testrig.NewTestOauthServer(suite.db) - var err error - suite.idp, err = oidc.NewIDP(context.Background()) - if err != nil { - panic(err) - } - suite.authModule = auth.New(suite.db, suite.idp, suite.processor).(*auth.Module) - testrig.StandardDBSetup(suite.db, suite.testAccounts) -} - -func (suite *AuthStandardTestSuite) TearDownTest() { - testrig.StandardDBTeardown(suite.db) -} - -func (suite *AuthStandardTestSuite) newContext(requestMethod string, requestPath string, requestBody []byte, bodyContentType string) (*gin.Context, *httptest.ResponseRecorder) { - // create the recorder and gin test context - recorder := httptest.NewRecorder() - ctx, engine := testrig.CreateGinTestContext(recorder, nil) - - // load templates into the engine - testrig.ConfigureTemplatesWithGin(engine, "../../../../web/template") - - // create the request - protocol := config.GetProtocol() - host := config.GetHost() - baseURI := fmt.Sprintf("%s://%s", protocol, host) - requestURI := fmt.Sprintf("%s/%s", baseURI, requestPath) - - ctx.Request = httptest.NewRequest(requestMethod, requestURI, bytes.NewReader(requestBody)) // the endpoint we're hitting - ctx.Request.Header.Set("accept", "text/html") - - if bodyContentType != "" { - ctx.Request.Header.Set("Content-Type", bodyContentType) - } - - // trigger the session middleware on the context - store := memstore.NewStore(make([]byte, 32), make([]byte, 32)) - store.Options(router.SessionOptions()) - sessionMiddleware := sessions.Sessions("gotosocial-localhost", store) - sessionMiddleware(ctx) - - return ctx, recorder -} diff --git a/internal/api/client/auth/authorize.go b/internal/api/client/auth/authorize.go @@ -1,335 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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/google/uuid" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// 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. -func (m *Module) AuthorizeGETHandler(c *gin.Context) { - s := sessions.Default(c) - - if _, err := api.NegotiateAccept(c, api.HTMLAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - // 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(sessionUserID).(string) - if !ok || userID == "" { - form := &model.OAuthAuthorize{} - if err := c.ShouldBind(form); err != nil { - m.clearSession(s) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGet) - return - } - - if errWithCode := saveAuthFormToSession(s, form); errWithCode != nil { - m.clearSession(s) - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - c.Redirect(http.StatusSeeOther, AuthSignInPath) - return - } - - // use session information to validate app, user, and account for this request - clientID, ok := s.Get(sessionClientID).(string) - if !ok || clientID == "" { - m.clearSession(s) - err := fmt.Errorf("key %s was not found in session", sessionClientID) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGet) - return - } - - app := &gtsmodel.Application{} - if err := m.db.GetWhere(c.Request.Context(), []db.Where{{Key: sessionClientID, Value: clientID}}, app); err != nil { - m.clearSession(s) - safe := fmt.Sprintf("application for %s %s could not be retrieved", sessionClientID, clientID) - var errWithCode gtserror.WithCode - if err == db.ErrNoEntries { - errWithCode = gtserror.NewErrorBadRequest(err, safe, oauth.HelpfulAdvice) - } else { - errWithCode = gtserror.NewErrorInternalError(err, safe, oauth.HelpfulAdvice) - } - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - user, err := m.db.GetUserByID(c.Request.Context(), userID) - if err != nil { - m.clearSession(s) - safe := fmt.Sprintf("user with id %s could not be retrieved", userID) - var errWithCode gtserror.WithCode - if err == db.ErrNoEntries { - errWithCode = gtserror.NewErrorBadRequest(err, safe, oauth.HelpfulAdvice) - } else { - errWithCode = gtserror.NewErrorInternalError(err, safe, oauth.HelpfulAdvice) - } - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - acct, err := m.db.GetAccountByID(c.Request.Context(), user.AccountID) - if err != nil { - m.clearSession(s) - safe := fmt.Sprintf("account with id %s could not be retrieved", user.AccountID) - var errWithCode gtserror.WithCode - if err == db.ErrNoEntries { - errWithCode = gtserror.NewErrorBadRequest(err, safe, oauth.HelpfulAdvice) - } else { - errWithCode = gtserror.NewErrorInternalError(err, safe, oauth.HelpfulAdvice) - } - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - if ensureUserIsAuthorizedOrRedirect(c, user, acct) { - return - } - - // Finally we should also get the redirect and scope of this particular request, as stored in the session. - redirect, ok := s.Get(sessionRedirectURI).(string) - if !ok || redirect == "" { - m.clearSession(s) - err := fmt.Errorf("key %s was not found in session", sessionRedirectURI) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGet) - return - } - - scope, ok := s.Get(sessionScope).(string) - if !ok || scope == "" { - m.clearSession(s) - err := fmt.Errorf("key %s was not found in session", sessionScope) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGet) - return - } - - instance, errWithCode := m.processor.InstanceGet(c.Request.Context(), config.GetHost()) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - 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 - c.HTML(http.StatusOK, "authorize.tmpl", gin.H{ - "appname": app.Name, - "appwebsite": app.Website, - "redirect": redirect, - "scope": scope, - "user": acct.Username, - "instance": instance, - }) -} - -// 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. -func (m *Module) AuthorizePOSTHandler(c *gin.Context) { - s := sessions.Default(c) - - // 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. - errs := []string{} - - forceLogin, ok := s.Get(sessionForceLogin).(string) - if !ok { - forceLogin = "false" - } - - responseType, ok := s.Get(sessionResponseType).(string) - if !ok || responseType == "" { - errs = append(errs, fmt.Sprintf("key %s was not found in session", sessionResponseType)) - } - - clientID, ok := s.Get(sessionClientID).(string) - if !ok || clientID == "" { - errs = append(errs, fmt.Sprintf("key %s was not found in session", sessionClientID)) - } - - redirectURI, ok := s.Get(sessionRedirectURI).(string) - if !ok || redirectURI == "" { - errs = append(errs, fmt.Sprintf("key %s was not found in session", sessionRedirectURI)) - } - - scope, ok := s.Get(sessionScope).(string) - if !ok { - errs = append(errs, fmt.Sprintf("key %s was not found in session", sessionScope)) - } - - var clientState string - if s, ok := s.Get(sessionClientState).(string); ok { - clientState = s - } - - userID, ok := s.Get(sessionUserID).(string) - if !ok { - errs = append(errs, fmt.Sprintf("key %s was not found in session", sessionUserID)) - } - - if len(errs) != 0 { - errs = append(errs, oauth.HelpfulAdvice) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(errors.New("one or more missing keys on session during AuthorizePOSTHandler"), errs...), m.processor.InstanceGet) - return - } - - user, err := m.db.GetUserByID(c.Request.Context(), userID) - if err != nil { - m.clearSession(s) - safe := fmt.Sprintf("user with id %s could not be retrieved", userID) - var errWithCode gtserror.WithCode - if err == db.ErrNoEntries { - errWithCode = gtserror.NewErrorBadRequest(err, safe, oauth.HelpfulAdvice) - } else { - errWithCode = gtserror.NewErrorInternalError(err, safe, oauth.HelpfulAdvice) - } - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - acct, err := m.db.GetAccountByID(c.Request.Context(), user.AccountID) - if err != nil { - m.clearSession(s) - safe := fmt.Sprintf("account with id %s could not be retrieved", user.AccountID) - var errWithCode gtserror.WithCode - if err == db.ErrNoEntries { - errWithCode = gtserror.NewErrorBadRequest(err, safe, oauth.HelpfulAdvice) - } else { - errWithCode = gtserror.NewErrorInternalError(err, safe, oauth.HelpfulAdvice) - } - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - if ensureUserIsAuthorizedOrRedirect(c, user, acct) { - return - } - - if redirectURI != oauth.OOBURI { - // we're done with the session now, so just clear it out - m.clearSession(s) - } - - // we have to set the values on the request form - // so that they're picked up by the oauth server - c.Request.Form = url.Values{ - sessionForceLogin: {forceLogin}, - sessionResponseType: {responseType}, - sessionClientID: {clientID}, - sessionRedirectURI: {redirectURI}, - sessionScope: {scope}, - sessionUserID: {userID}, - } - - if clientState != "" { - c.Request.Form.Set("state", clientState) - } - - if errWithCode := m.processor.OAuthHandleAuthorizeRequest(c.Writer, c.Request); errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - } -} - -// saveAuthFormToSession checks the given OAuthAuthorize form, -// and stores the values in the form into the session. -func saveAuthFormToSession(s sessions.Session, form *model.OAuthAuthorize) gtserror.WithCode { - if form == nil { - err := errors.New("OAuthAuthorize form was nil") - return gtserror.NewErrorBadRequest(err, err.Error(), oauth.HelpfulAdvice) - } - - if form.ResponseType == "" { - err := errors.New("field response_type was not set on OAuthAuthorize form") - return gtserror.NewErrorBadRequest(err, err.Error(), oauth.HelpfulAdvice) - } - - if form.ClientID == "" { - err := errors.New("field client_id was not set on OAuthAuthorize form") - return gtserror.NewErrorBadRequest(err, err.Error(), oauth.HelpfulAdvice) - } - - if form.RedirectURI == "" { - err := errors.New("field redirect_uri was not set on OAuthAuthorize form") - return gtserror.NewErrorBadRequest(err, err.Error(), oauth.HelpfulAdvice) - } - - // 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(sessionForceLogin, form.ForceLogin) - s.Set(sessionResponseType, form.ResponseType) - s.Set(sessionClientID, form.ClientID) - s.Set(sessionRedirectURI, form.RedirectURI) - s.Set(sessionScope, form.Scope) - s.Set(sessionInternalState, uuid.NewString()) - s.Set(sessionClientState, form.State) - - if err := s.Save(); err != nil { - err := fmt.Errorf("error saving form values onto session: %s", err) - return gtserror.NewErrorInternalError(err, oauth.HelpfulAdvice) - } - - return nil -} - -func ensureUserIsAuthorizedOrRedirect(ctx *gin.Context, user *gtsmodel.User, account *gtsmodel.Account) (redirected bool) { - if user.ConfirmedAt.IsZero() { - ctx.Redirect(http.StatusSeeOther, CheckYourEmailPath) - redirected = true - return - } - - if !*user.Approved { - ctx.Redirect(http.StatusSeeOther, WaitForApprovalPath) - redirected = true - return - } - - if *user.Disabled || !account.SuspendedAt.IsZero() { - ctx.Redirect(http.StatusSeeOther, AccountDisabledPath) - redirected = true - return - } - - return -} diff --git a/internal/api/client/auth/authorize_test.go b/internal/api/client/auth/authorize_test.go @@ -1,118 +0,0 @@ -package auth_test - -import ( - "context" - "fmt" - "net/http" - "testing" - "time" - - "github.com/gin-contrib/sessions" - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/api/client/auth" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type AuthAuthorizeTestSuite struct { - AuthStandardTestSuite -} - -type authorizeHandlerTestCase struct { - description string - mutateUserAccount func(*gtsmodel.User, *gtsmodel.Account) []string - expectedStatusCode int - expectedLocationHeader string -} - -func (suite *AuthAuthorizeTestSuite) TestAccountAuthorizeHandler() { - tests := []authorizeHandlerTestCase{ - { - description: "user has their email unconfirmed", - mutateUserAccount: func(user *gtsmodel.User, account *gtsmodel.Account) []string { - user.ConfirmedAt = time.Time{} - return []string{"confirmed_at"} - }, - expectedStatusCode: http.StatusSeeOther, - expectedLocationHeader: auth.CheckYourEmailPath, - }, - { - description: "user has their email confirmed but is not approved", - mutateUserAccount: func(user *gtsmodel.User, account *gtsmodel.Account) []string { - user.ConfirmedAt = time.Now() - user.Email = user.UnconfirmedEmail - return []string{"confirmed_at", "email"} - }, - expectedStatusCode: http.StatusSeeOther, - expectedLocationHeader: auth.WaitForApprovalPath, - }, - { - description: "user has their email confirmed and is approved, but User entity has been disabled", - mutateUserAccount: func(user *gtsmodel.User, account *gtsmodel.Account) []string { - user.ConfirmedAt = time.Now() - user.Email = user.UnconfirmedEmail - user.Approved = testrig.TrueBool() - user.Disabled = testrig.TrueBool() - return []string{"confirmed_at", "email", "approved", "disabled"} - }, - expectedStatusCode: http.StatusSeeOther, - expectedLocationHeader: auth.AccountDisabledPath, - }, - { - description: "user has their email confirmed and is approved, but Account entity has been suspended", - mutateUserAccount: func(user *gtsmodel.User, account *gtsmodel.Account) []string { - user.ConfirmedAt = time.Now() - user.Email = user.UnconfirmedEmail - user.Approved = testrig.TrueBool() - user.Disabled = testrig.FalseBool() - account.SuspendedAt = time.Now() - return []string{"confirmed_at", "email", "approved", "disabled"} - }, - expectedStatusCode: http.StatusSeeOther, - expectedLocationHeader: auth.AccountDisabledPath, - }, - } - - doTest := func(testCase authorizeHandlerTestCase) { - ctx, recorder := suite.newContext(http.MethodGet, auth.OauthAuthorizePath, nil, "") - - user := &gtsmodel.User{} - account := &gtsmodel.Account{} - - *user = *suite.testUsers["unconfirmed_account"] - *account = *suite.testAccounts["unconfirmed_account"] - - testSession := sessions.Default(ctx) - testSession.Set(sessionUserID, user.ID) - testSession.Set(sessionClientID, suite.testApplications["application_1"].ClientID) - if err := testSession.Save(); err != nil { - panic(fmt.Errorf("failed on case %s: %w", testCase.description, err)) - } - - columns := testCase.mutateUserAccount(user, account) - - testCase.description = fmt.Sprintf("%s, %t, %s", user.Email, *user.Disabled, account.SuspendedAt) - - err := suite.db.UpdateUser(context.Background(), user, columns...) - suite.NoError(err) - err = suite.db.UpdateAccount(context.Background(), account) - suite.NoError(err) - - // call the handler - suite.authModule.AuthorizeGETHandler(ctx) - - // 1. we should have a redirect - suite.Equal(testCase.expectedStatusCode, recorder.Code, fmt.Sprintf("failed on case: %s", testCase.description)) - - // 2. we should have a redirect to the check your email path, as this user has not confirmed their email yet. - suite.Equal(testCase.expectedLocationHeader, recorder.Header().Get("Location"), fmt.Sprintf("failed on case: %s", testCase.description)) - } - - for _, testCase := range tests { - doTest(testCase) - } -} - -func TestAccountUpdateTestSuite(t *testing.T) { - suite.Run(t, new(AuthAuthorizeTestSuite)) -} diff --git a/internal/api/client/auth/callback.go b/internal/api/client/auth/callback.go @@ -1,311 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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" - "errors" - "fmt" - "net" - "net/http" - "strings" - - "github.com/gin-contrib/sessions" - "github.com/gin-gonic/gin" - "github.com/google/uuid" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/oidc" - "github.com/superseriousbusiness/gotosocial/internal/validate" -) - -// extraInfo wraps a form-submitted username and transmitted name -type extraInfo struct { - Username string `form:"username"` - Name string `form:"name"` // note that this is only used for re-rendering the page in case of an error -} - -// CallbackGETHandler parses a token from an external auth provider. -func (m *Module) CallbackGETHandler(c *gin.Context) { - s := sessions.Default(c) - - // check the query vs session state parameter to mitigate csrf - // https://auth0.com/docs/secure/attack-protection/state-parameters - - returnedInternalState := c.Query(callbackStateParam) - if returnedInternalState == "" { - m.clearSession(s) - err := fmt.Errorf("%s parameter not found on callback query", callbackStateParam) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - savedInternalStateI := s.Get(sessionInternalState) - savedInternalState, ok := savedInternalStateI.(string) - if !ok { - m.clearSession(s) - err := fmt.Errorf("key %s was not found in session", sessionInternalState) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - if returnedInternalState != savedInternalState { - m.clearSession(s) - err := errors.New("mismatch between callback state and saved state") - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) - return - } - - // retrieve stored claims using code - code := c.Query(callbackCodeParam) - if code == "" { - m.clearSession(s) - err := fmt.Errorf("%s parameter not found on callback query", callbackCodeParam) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - claims, errWithCode := m.idp.HandleCallback(c.Request.Context(), code) - if errWithCode != nil { - m.clearSession(s) - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - 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(sessionClientID).(string) - if !ok || clientID == "" { - m.clearSession(s) - err := fmt.Errorf("key %s was not found in session", sessionClientID) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGet) - return - } - - app := &gtsmodel.Application{} - if err := m.db.GetWhere(c.Request.Context(), []db.Where{{Key: sessionClientID, Value: clientID}}, app); err != nil { - m.clearSession(s) - safe := fmt.Sprintf("application for %s %s could not be retrieved", sessionClientID, clientID) - var errWithCode gtserror.WithCode - if err == db.ErrNoEntries { - errWithCode = gtserror.NewErrorBadRequest(err, safe, oauth.HelpfulAdvice) - } else { - errWithCode = gtserror.NewErrorInternalError(err, safe, oauth.HelpfulAdvice) - } - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - user, errWithCode := m.fetchUserForClaims(c.Request.Context(), claims, net.IP(c.ClientIP()), app.ID) - if errWithCode != nil { - m.clearSession(s) - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - if user == nil { - // no user exists yet - let's ask them for their preferred username - instance, errWithCode := m.processor.InstanceGet(c.Request.Context(), config.GetHost()) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - // store the claims in the session - that way we know the user is authenticated when processing the form later - s.Set(sessionClaims, claims) - s.Set(sessionAppID, app.ID) - if err := s.Save(); err != nil { - m.clearSession(s) - api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) - return - } - c.HTML(http.StatusOK, "finalize.tmpl", gin.H{ - "instance": instance, - "name": claims.Name, - "preferredUsername": claims.PreferredUsername, - }) - return - } - s.Set(sessionUserID, user.ID) - if err := s.Save(); err != nil { - m.clearSession(s) - api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) - return - } - c.Redirect(http.StatusFound, OauthAuthorizePath) -} - -// FinalizePOSTHandler registers the user after additional data has been provided -func (m *Module) FinalizePOSTHandler(c *gin.Context) { - s := sessions.Default(c) - - form := &extraInfo{} - if err := c.ShouldBind(form); err != nil { - m.clearSession(s) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGet) - return - } - - // since we have multiple possible validation error, `validationError` is a shorthand for rendering them - validationError := func(err error) { - instance, errWithCode := m.processor.InstanceGet(c.Request.Context(), config.GetHost()) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - c.HTML(http.StatusOK, "finalize.tmpl", gin.H{ - "instance": instance, - "name": form.Name, - "preferredUsername": form.Username, - "error": err, - }) - } - - // check if the username conforms to the spec - if err := validate.Username(form.Username); err != nil { - validationError(err) - return - } - - // see if the username is still available - usernameAvailable, err := m.db.IsUsernameAvailable(c.Request.Context(), form.Username) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGet) - return - } - if !usernameAvailable { - validationError(fmt.Errorf("Username %s is already taken", form.Username)) - return - } - - // retrieve the information previously set by the oidc logic - appID, ok := s.Get(sessionAppID).(string) - if !ok { - err := fmt.Errorf("key %s was not found in session", sessionAppID) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGet) - return - } - - // retrieve the claims returned by the IDP. Having this present means that we previously already verified these claims - claims, ok := s.Get(sessionClaims).(*oidc.Claims) - if !ok { - err := fmt.Errorf("key %s was not found in session", sessionClaims) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGet) - return - } - - // we're now ready to actually create the user - user, errWithCode := m.createUserFromOIDC(c.Request.Context(), claims, form, net.IP(c.ClientIP()), appID) - if errWithCode != nil { - m.clearSession(s) - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - s.Delete(sessionClaims) - s.Delete(sessionAppID) - s.Set(sessionUserID, user.ID) - if err := s.Save(); err != nil { - m.clearSession(s) - api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) - return - } - c.Redirect(http.StatusFound, OauthAuthorizePath) -} - -func (m *Module) fetchUserForClaims(ctx context.Context, claims *oidc.Claims, ip net.IP, appID string) (*gtsmodel.User, gtserror.WithCode) { - if claims.Sub == "" { - err := errors.New("no sub claim found - is your provider OIDC compliant?") - return nil, gtserror.NewErrorBadRequest(err, err.Error()) - } - user, err := m.db.GetUserByExternalID(ctx, claims.Sub) - if err == nil { - return user, nil - } - if err != db.ErrNoEntries { - err := fmt.Errorf("error checking database for externalID %s: %s", claims.Sub, err) - return nil, gtserror.NewErrorInternalError(err) - } - if !config.GetOIDCLinkExisting() { - return nil, nil - } - // fallback to email if we want to link existing users - user, err = m.db.GetUserByEmailAddress(ctx, claims.Email) - if err == db.ErrNoEntries { - return nil, nil - } else if err != nil { - err := fmt.Errorf("error checking database for email %s: %s", claims.Email, err) - return nil, gtserror.NewErrorInternalError(err) - } - // at this point we have found a matching user but still need to link the newly received external ID - - user.ExternalID = claims.Sub - err = m.db.UpdateUser(ctx, user, "external_id") - if err != nil { - err := fmt.Errorf("error linking existing user %s: %s", claims.Email, err) - return nil, gtserror.NewErrorInternalError(err) - } - return user, nil -} - -func (m *Module) createUserFromOIDC(ctx context.Context, claims *oidc.Claims, extraInfo *extraInfo, ip net.IP, appID string) (*gtsmodel.User, gtserror.WithCode) { - // check if the email address is available for use; if it's not there's nothing we can so - emailAvailable, err := m.db.IsEmailAvailable(ctx, claims.Email) - if err != nil { - return nil, gtserror.NewErrorBadRequest(err) - } - if !emailAvailable { - help := "The email address given to us by your authentication provider already exists in our records and the server administrator has not enabled account migration" - return nil, gtserror.NewErrorConflict(fmt.Errorf("email address %s is not available", claims.Email), help) - } - - // check if the user is in any recognised admin groups - var admin bool - for _, g := range claims.Groups { - if strings.EqualFold(g, "admin") || strings.EqualFold(g, "admins") { - admin = true - } - } - - // We still need to set *a* password even if it's not a password the user will end up using, so set something random. - // We'll just set two uuids on top of each other, which should be long + random enough to baffle any attempts to crack. - // - // If the user ever wants to log in using gts password rather than oidc flow, they'll have to request a password reset, which is fine - password := uuid.NewString() + uuid.NewString() - - // Since this user is created via oidc, which has been set up by the admin, we can assume that the account is already - // implicitly approved, and that the email address has already been verified: otherwise, we end up in situations where - // the admin first approves the user in OIDC, and then has to approve them again in GoToSocial, which doesn't make sense. - // - // In other words, if a user logs in via OIDC, they should be able to use their account straight away. - // - // See: https://github.com/superseriousbusiness/gotosocial/issues/357 - requireApproval := false - emailVerified := true - - // create the user! this will also create an account and store it in the database so we don't need to do that here - user, err := m.db.NewSignup(ctx, extraInfo.Username, "", requireApproval, claims.Email, password, ip, "", appID, emailVerified, claims.Sub, admin) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - - return user, nil -} diff --git a/internal/api/client/auth/oob.go b/internal/api/client/auth/oob.go @@ -1,111 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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" - "errors" - "fmt" - "net/http" - - "github.com/gin-contrib/sessions" - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -func (m *Module) OobHandler(c *gin.Context) { - host := config.GetHost() - instance, errWithCode := m.processor.InstanceGet(c.Request.Context(), host) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - instanceGet := func(ctx context.Context, domain string) (*model.Instance, gtserror.WithCode) { return instance, nil } - - oobToken := c.Query("code") - if oobToken == "" { - err := errors.New("no 'code' query value provided in callback redirect") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error(), oauth.HelpfulAdvice), instanceGet) - return - } - - s := sessions.Default(c) - - errs := []string{} - - scope, ok := s.Get(sessionScope).(string) - if !ok { - errs = append(errs, fmt.Sprintf("key %s was not found in session", sessionScope)) - } - - userID, ok := s.Get(sessionUserID).(string) - if !ok { - errs = append(errs, fmt.Sprintf("key %s was not found in session", sessionUserID)) - } - - if len(errs) != 0 { - errs = append(errs, oauth.HelpfulAdvice) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(errors.New("one or more missing keys on session during OobHandler"), errs...), m.processor.InstanceGet) - return - } - - user, err := m.db.GetUserByID(c.Request.Context(), userID) - if err != nil { - m.clearSession(s) - safe := fmt.Sprintf("user with id %s could not be retrieved", userID) - var errWithCode gtserror.WithCode - if err == db.ErrNoEntries { - errWithCode = gtserror.NewErrorBadRequest(err, safe, oauth.HelpfulAdvice) - } else { - errWithCode = gtserror.NewErrorInternalError(err, safe, oauth.HelpfulAdvice) - } - api.ErrorHandler(c, errWithCode, instanceGet) - return - } - - acct, err := m.db.GetAccountByID(c.Request.Context(), user.AccountID) - if err != nil { - m.clearSession(s) - safe := fmt.Sprintf("account with id %s could not be retrieved", user.AccountID) - var errWithCode gtserror.WithCode - if err == db.ErrNoEntries { - errWithCode = gtserror.NewErrorBadRequest(err, safe, oauth.HelpfulAdvice) - } else { - errWithCode = gtserror.NewErrorInternalError(err, safe, oauth.HelpfulAdvice) - } - api.ErrorHandler(c, errWithCode, instanceGet) - return - } - - // we're done with the session now, so just clear it out - m.clearSession(s) - - c.HTML(http.StatusOK, "oob.tmpl", gin.H{ - "instance": instance, - "user": acct.Username, - "oobToken": oobToken, - "scope": scope, - }) -} diff --git a/internal/api/client/auth/signin.go b/internal/api/client/auth/signin.go @@ -1,145 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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" - "errors" - "fmt" - "net/http" - - "github.com/gin-contrib/sessions" - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "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. -// If an idp provider is set, then the user will be redirected to that to do their sign in. -func (m *Module) SignInGETHandler(c *gin.Context) { - if _, err := api.NegotiateAccept(c, api.HTMLAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - if m.idp == nil { - instance, errWithCode := m.processor.InstanceGet(c.Request.Context(), config.GetHost()) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - // no idp provider, use our own funky little sign in page - c.HTML(http.StatusOK, "sign-in.tmpl", gin.H{ - "instance": instance, - }) - return - } - - // idp provider is in use, so redirect to it - s := sessions.Default(c) - - internalStateI := s.Get(sessionInternalState) - internalState, ok := internalStateI.(string) - if !ok { - m.clearSession(s) - err := fmt.Errorf("key %s was not found in session", sessionInternalState) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - c.Redirect(http.StatusSeeOther, m.idp.AuthCodeURL(internalState)) -} - -// 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) { - s := sessions.Default(c) - - form := &login{} - if err := c.ShouldBind(form); err != nil { - m.clearSession(s) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGet) - return - } - - userid, errWithCode := m.ValidatePassword(c.Request.Context(), form.Email, form.Password) - if errWithCode != nil { - // don't clear session here, so the user can just press back and try again - // if they accidentally gave the wrong password or something - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - s.Set(sessionUserID, userid) - if err := s.Save(); err != nil { - err := fmt.Errorf("error saving user id onto session: %s", err) - api.ErrorHandler(c, gtserror.NewErrorInternalError(err, oauth.HelpfulAdvice), m.processor.InstanceGet) - } - - 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 ulid) 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(ctx context.Context, email string, password string) (string, gtserror.WithCode) { - if email == "" || password == "" { - err := errors.New("email or password was not provided") - return incorrectPassword(err) - } - - user, err := m.db.GetUserByEmailAddress(ctx, email) - if err != nil { - err := fmt.Errorf("user %s was not retrievable from db during oauth authorization attempt: %s", email, err) - return incorrectPassword(err) - } - - if user.EncryptedPassword == "" { - err := fmt.Errorf("encrypted password for user %s was empty for some reason", user.Email) - return incorrectPassword(err) - } - - if err := bcrypt.CompareHashAndPassword([]byte(user.EncryptedPassword), []byte(password)); err != nil { - err := fmt.Errorf("password hash didn't match for user %s during login attempt: %s", user.Email, err) - return incorrectPassword(err) - } - - return user.ID, nil -} - -// incorrectPassword wraps the given error in a gtserror.WithCode, and returns -// only a generic 'safe' error message to the user, to not give any info away. -func incorrectPassword(err error) (string, gtserror.WithCode) { - safeErr := fmt.Errorf("password/email combination was incorrect") - return "", gtserror.NewErrorUnauthorized(err, safeErr.Error(), oauth.HelpfulAdvice) -} diff --git a/internal/api/client/auth/token.go b/internal/api/client/auth/token.go @@ -1,115 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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" - "net/url" - - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - - "github.com/gin-gonic/gin" -) - -type tokenRequestForm struct { - GrantType *string `form:"grant_type" json:"grant_type" xml:"grant_type"` - Code *string `form:"code" json:"code" xml:"code"` - RedirectURI *string `form:"redirect_uri" json:"redirect_uri" xml:"redirect_uri"` - ClientID *string `form:"client_id" json:"client_id" xml:"client_id"` - ClientSecret *string `form:"client_secret" json:"client_secret" xml:"client_secret"` - Scope *string `form:"scope" json:"scope" xml:"scope"` -} - -// TokenPOSTHandler should be served as a POST at https://example.org/oauth/token -// The idea here is to serve an oauth access token to a user, which can be used for authorizing against non-public APIs. -func (m *Module) TokenPOSTHandler(c *gin.Context) { - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - help := []string{} - - form := &tokenRequestForm{} - if err := c.ShouldBind(form); err != nil { - api.OAuthErrorHandler(c, gtserror.NewErrorBadRequest(oauth.InvalidRequest(), err.Error())) - return - } - - c.Request.Form = url.Values{} - - var grantType string - if form.GrantType != nil { - grantType = *form.GrantType - c.Request.Form.Set("grant_type", grantType) - } else { - help = append(help, "grant_type was not set in the token request form, but must be set to authorization_code or client_credentials") - } - - if form.ClientID != nil { - c.Request.Form.Set("client_id", *form.ClientID) - } else { - help = append(help, "client_id was not set in the token request form") - } - - if form.ClientSecret != nil { - c.Request.Form.Set("client_secret", *form.ClientSecret) - } else { - help = append(help, "client_secret was not set in the token request form") - } - - if form.RedirectURI != nil { - c.Request.Form.Set("redirect_uri", *form.RedirectURI) - } else { - help = append(help, "redirect_uri was not set in the token request form") - } - - var code string - if form.Code != nil { - if grantType != "authorization_code" { - help = append(help, "a code was provided in the token request form, but grant_type was not set to authorization_code") - } else { - code = *form.Code - c.Request.Form.Set("code", code) - } - } else if grantType == "authorization_code" { - help = append(help, "code was not set in the token request form, but must be set since grant_type is authorization_code") - } - - if form.Scope != nil { - c.Request.Form.Set("scope", *form.Scope) - } - - if len(help) != 0 { - api.OAuthErrorHandler(c, gtserror.NewErrorBadRequest(oauth.InvalidRequest(), help...)) - return - } - - token, errWithCode := m.processor.OAuthHandleTokenRequest(c.Request) - if errWithCode != nil { - api.OAuthErrorHandler(c, errWithCode) - return - } - - c.Header("Cache-Control", "no-store") - c.Header("Pragma", "no-cache") - c.JSON(http.StatusOK, token) -} diff --git a/internal/api/client/auth/util.go b/internal/api/client/auth/util.go @@ -1,31 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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-contrib/sessions" -) - -func (m *Module) clearSession(s sessions.Session) { - s.Clear() - - if err := s.Save(); err != nil { - panic(err) - } -} diff --git a/internal/api/client/blocks/blocks.go b/internal/api/client/blocks/blocks.go @@ -21,14 +21,13 @@ package blocks import ( "net/http" - "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/processing" - "github.com/superseriousbusiness/gotosocial/internal/router" ) const ( - // BasePath is the base URI path for serving favourites - BasePath = "/api/v1/blocks" + // BasePath is the base URI path for serving blocks, minus the api prefix. + BasePath = "/v1/blocks" // MaxIDKey is the url query for setting a max ID to return MaxIDKey = "max_id" @@ -38,20 +37,16 @@ const ( LimitKey = "limit" ) -// Module implements the ClientAPIModule interface for everything relating to viewing blocks type Module struct { processor processing.Processor } -// New returns a new blocks module -func New(processor processing.Processor) api.ClientModule { +func New(processor processing.Processor) *Module { return &Module{ processor: processor, } } -// Route attaches all routes from this module to the given router -func (m *Module) Route(r router.Router) error { - r.AttachHandler(http.MethodGet, BasePath, m.BlocksGETHandler) - return nil +func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) { + attachHandler(http.MethodGet, BasePath, m.BlocksGETHandler) } diff --git a/internal/api/client/blocks/blocksget.go b/internal/api/client/blocks/blocksget.go @@ -24,7 +24,7 @@ import ( "strconv" "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -96,12 +96,12 @@ import ( func (m *Module) BlocksGETHandler(c *gin.Context) { authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } @@ -123,7 +123,7 @@ func (m *Module) BlocksGETHandler(c *gin.Context) { i, err := strconv.ParseInt(limitString, 10, 32) if err != nil { err := fmt.Errorf("error parsing %s: %s", LimitKey, err) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } limit = int(i) @@ -131,7 +131,7 @@ func (m *Module) BlocksGETHandler(c *gin.Context) { resp, errWithCode := m.processor.BlocksGet(c.Request.Context(), authed, maxID, sinceID, limit) if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/bookmarks/bookmarks.go b/internal/api/client/bookmarks/bookmarks.go @@ -21,9 +21,8 @@ package bookmarks import ( "net/http" - "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/processing" - "github.com/superseriousbusiness/gotosocial/internal/router" ) const ( @@ -31,20 +30,16 @@ const ( BasePath = "/api/v1/bookmarks" ) -// Module implements the ClientAPIModule interface for everything related to bookmarks type Module struct { processor processing.Processor } -// New returns a new emoji module -func New(processor processing.Processor) api.ClientModule { +func New(processor processing.Processor) *Module { return &Module{ processor: processor, } } -// Route attaches all routes from this module to the given router -func (m *Module) Route(r router.Router) error { - r.AttachHandler(http.MethodGet, BasePath, m.BookmarksGETHandler) - return nil +func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) { + attachHandler(http.MethodGet, BasePath, m.BookmarksGETHandler) } diff --git a/internal/api/client/bookmarks/bookmarks_test.go b/internal/api/client/bookmarks/bookmarks_test.go @@ -29,7 +29,7 @@ import ( "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/api/client/bookmarks" - "github.com/superseriousbusiness/gotosocial/internal/api/client/status" + "github.com/superseriousbusiness/gotosocial/internal/api/client/statuses" "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/concurrency" "github.com/superseriousbusiness/gotosocial/internal/db" @@ -67,7 +67,7 @@ type BookmarkTestSuite struct { testFollows map[string]*gtsmodel.Follow // module being tested - statusModule *status.Module + statusModule *statuses.Module bookmarkModule *bookmarks.Module } @@ -99,8 +99,8 @@ func (suite *BookmarkTestSuite) SetupTest() { suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker), suite.storage, suite.mediaManager, fedWorker) suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil) suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, suite.mediaManager, clientWorker, fedWorker) - suite.statusModule = status.New(suite.processor).(*status.Module) - suite.bookmarkModule = bookmarks.New(suite.processor).(*bookmarks.Module) + suite.statusModule = statuses.New(suite.processor) + suite.bookmarkModule = bookmarks.New(suite.processor) suite.NoError(suite.processor.Start()) } @@ -123,7 +123,7 @@ func (suite *BookmarkTestSuite) TestGetBookmark() { 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.BookmarkPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting + ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(statuses.BookmarkPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting ctx.Request.Header.Set("accept", "application/json") suite.bookmarkModule.BookmarksGETHandler(ctx) diff --git a/internal/api/client/bookmarks/bookmarksget.go b/internal/api/client/bookmarks/bookmarksget.go @@ -6,7 +6,7 @@ import ( "strconv" "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -56,12 +56,12 @@ const ( func (m *Module) BookmarksGETHandler(c *gin.Context) { authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } @@ -71,7 +71,7 @@ func (m *Module) BookmarksGETHandler(c *gin.Context) { i, err := strconv.ParseInt(limitString, 10, 64) if err != nil { err := fmt.Errorf("error parsing %s: %s", LimitKey, err) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } limit = int(i) @@ -91,12 +91,12 @@ func (m *Module) BookmarksGETHandler(c *gin.Context) { resp, errWithCode := m.processor.BookmarksGet(c.Request.Context(), authed, maxID, minID, limit) if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/customemojis/customemojis.go b/internal/api/client/customemojis/customemojis.go @@ -0,0 +1,45 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 customemojis + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/processing" +) + +const ( + // BasePath is the base path for serving custom emojis, minus the 'api' prefix + BasePath = "/v1/custom_emojis" +) + +type Module struct { + processor processing.Processor +} + +func New(processor processing.Processor) *Module { + return &Module{ + processor: processor, + } +} + +func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) { + attachHandler(http.MethodGet, BasePath, m.CustomEmojisGETHandler) +} diff --git a/internal/api/client/customemojis/customemojisget.go b/internal/api/client/customemojis/customemojisget.go @@ -0,0 +1,76 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 customemojis + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// CustomEmojisGETHandler swagger:operation GET /api/v1/custom_emojis customEmojisGet +// +// Get an array of custom emojis available on the instance. +// +// --- +// tags: +// - custom_emojis +// +// produces: +// - application/json +// +// security: +// - OAuth2 Bearer: +// - read:custom_emojis +// +// responses: +// '200': +// description: Array of custom emojis. +// schema: +// type: array +// items: +// "$ref": "#/definitions/emoji" +// '401': +// description: unauthorized +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) CustomEmojisGETHandler(c *gin.Context) { + if _, err := oauth.Authed(c, true, true, true, true); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + emojis, errWithCode := m.processor.CustomEmojisGet(c) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + c.JSON(http.StatusOK, emojis) +} diff --git a/internal/api/client/emoji/emoji.go b/internal/api/client/emoji/emoji.go @@ -1,50 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 emoji - -import ( - "net/http" - - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/processing" - "github.com/superseriousbusiness/gotosocial/internal/router" -) - -const ( - // BasePath is the base path for serving the emoji API - BasePath = "/api/v1/custom_emojis" -) - -// Module implements the ClientAPIModule interface for everything related to emoji -type Module struct { - processor processing.Processor -} - -// New returns a new emoji module -func New(processor processing.Processor) api.ClientModule { - return &Module{ - processor: processor, - } -} - -// Route attaches all routes from this module to the given router -func (m *Module) Route(r router.Router) error { - r.AttachHandler(http.MethodGet, BasePath, m.EmojisGETHandler) - return nil -} diff --git a/internal/api/client/emoji/emojisget.go b/internal/api/client/emoji/emojisget.go @@ -1,58 +0,0 @@ -package emoji - -import ( - "net/http" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// EmojisGETHandler swagger:operation GET /api/v1/custom_emojis customEmojisGet -// -// Get an array of custom emojis available on the instance. -// -// --- -// tags: -// - custom_emojis -// -// produces: -// - application/json -// -// security: -// - OAuth2 Bearer: -// - read:custom_emojis -// -// responses: -// '200': -// description: Array of custom emojis. -// schema: -// type: array -// items: -// "$ref": "#/definitions/emoji" -// '401': -// description: unauthorized -// '406': -// description: not acceptable -// '500': -// description: internal server error -func (m *Module) EmojisGETHandler(c *gin.Context) { - if _, err := oauth.Authed(c, true, true, true, true); err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) - return - } - - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - emojis, errWithCode := m.processor.CustomEmojisGet(c) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - c.JSON(http.StatusOK, emojis) -} diff --git a/internal/api/client/favourites/favourites.go b/internal/api/client/favourites/favourites.go @@ -21,14 +21,13 @@ package favourites import ( "net/http" - "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/processing" - "github.com/superseriousbusiness/gotosocial/internal/router" ) const ( - // BasePath is the base URI path for serving favourites - BasePath = "/api/v1/favourites" + // BasePath is the base URI path for serving favourites, minus the 'api' prefix + BasePath = "/v1/favourites" // MaxIDKey is the url query for setting a max status ID to return MaxIDKey = "max_id" @@ -42,20 +41,16 @@ const ( LocalKey = "local" ) -// Module implements the ClientAPIModule interface for everything relating to viewing favourites type Module struct { processor processing.Processor } -// New returns a new favourites module -func New(processor processing.Processor) api.ClientModule { +func New(processor processing.Processor) *Module { return &Module{ processor: processor, } } -// Route attaches all routes from this module to the given router -func (m *Module) Route(r router.Router) error { - r.AttachHandler(http.MethodGet, BasePath, m.FavouritesGETHandler) - return nil +func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) { + attachHandler(http.MethodGet, BasePath, m.FavouritesGETHandler) } diff --git a/internal/api/client/favourites/favourites_test.go b/internal/api/client/favourites/favourites_test.go @@ -87,7 +87,7 @@ func (suite *FavouritesStandardTestSuite) SetupTest() { suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker), suite.storage, suite.mediaManager, fedWorker) suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil) suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, suite.mediaManager, clientWorker, fedWorker) - suite.favModule = favourites.New(suite.processor).(*favourites.Module) + suite.favModule = favourites.New(suite.processor) suite.NoError(suite.processor.Start()) } diff --git a/internal/api/client/favourites/favouritesget.go b/internal/api/client/favourites/favouritesget.go @@ -6,7 +6,7 @@ import ( "strconv" "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -78,12 +78,12 @@ import ( func (m *Module) FavouritesGETHandler(c *gin.Context) { authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } @@ -105,7 +105,7 @@ func (m *Module) FavouritesGETHandler(c *gin.Context) { i, err := strconv.ParseInt(limitString, 10, 32) if err != nil { err := fmt.Errorf("error parsing %s: %s", LimitKey, err) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } limit = int(i) @@ -113,7 +113,7 @@ func (m *Module) FavouritesGETHandler(c *gin.Context) { resp, errWithCode := m.processor.FavedTimelineGet(c.Request.Context(), authed, maxID, minID, limit) if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/fileserver/fileserver.go b/internal/api/client/fileserver/fileserver.go @@ -1,64 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/processing" - "github.com/superseriousbusiness/gotosocial/internal/router" - "github.com/superseriousbusiness/gotosocial/internal/uris" -) - -const ( - // FileServeBasePath forms the first part of the fileserver path. - FileServeBasePath = "/" + uris.FileserverPath - // AccountIDKey is the url key for account id (an account ulid) - 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 { - processor processing.Processor -} - -// New returns a new fileServer module -func New(processor processing.Processor) api.ClientModule { - return &FileServer{ - processor: processor, - } -} - -// Route satisfies the RESTAPIModule interface -func (m *FileServer) Route(s router.Router) error { - // something like "/fileserver/:account_id/:media_type/:media_size/:file_name" - fileServePath := fmt.Sprintf("%s/:%s/:%s/:%s/:%s", FileServeBasePath, AccountIDKey, MediaTypeKey, MediaSizeKey, FileNameKey) - s.AttachHandler(http.MethodGet, fileServePath, m.ServeFile) - s.AttachHandler(http.MethodHead, fileServePath, m.ServeFile) - return nil -} diff --git a/internal/api/client/fileserver/fileserver_test.go b/internal/api/client/fileserver/fileserver_test.go @@ -1,109 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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" - - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/api/client/fileserver" - "github.com/superseriousbusiness/gotosocial/internal/concurrency" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/email" - "github.com/superseriousbusiness/gotosocial/internal/federation" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/log" - "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/messages" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/processing" - "github.com/superseriousbusiness/gotosocial/internal/storage" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type FileserverTestSuite struct { - // standard suite interfaces - suite.Suite - db db.DB - storage *storage.Driver - federator federation.Federator - tc typeutils.TypeConverter - processor processing.Processor - mediaManager media.Manager - oauthServer oauth.Server - emailSender email.Sender - - // standard suite models - testTokens map[string]*gtsmodel.Token - testClients map[string]*gtsmodel.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 *FileserverTestSuite) SetupSuite() { - testrig.InitTestConfig() - testrig.InitTestLog() - - fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) - clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) - - suite.db = testrig.NewTestDB() - suite.storage = testrig.NewInMemoryStorage() - suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker), suite.storage, suite.mediaManager, fedWorker) - suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil) - - suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, testrig.NewTestMediaManager(suite.db, suite.storage), clientWorker, fedWorker) - suite.tc = testrig.NewTestTypeConverter(suite.db) - suite.mediaManager = testrig.NewTestMediaManager(suite.db, suite.storage) - suite.oauthServer = testrig.NewTestOauthServer(suite.db) - - suite.fileServer = fileserver.New(suite.processor).(*fileserver.FileServer) -} - -func (suite *FileserverTestSuite) SetupTest() { - testrig.StandardDBSetup(suite.db, nil) - 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 *FileserverTestSuite) TearDownSuite() { - if err := suite.db.Stop(context.Background()); err != nil { - log.Panicf("error closing db connection: %s", err) - } -} - -func (suite *FileserverTestSuite) TearDownTest() { - testrig.StandardDBTeardown(suite.db) - testrig.StandardStorageTeardown(suite.storage) -} diff --git a/internal/api/client/fileserver/servefile.go b/internal/api/client/fileserver/servefile.go @@ -1,135 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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" - "fmt" - "io" - "net/http" - "strconv" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/log" - "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) { - authed, err := oauth.Authed(c, false, false, false, false) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotFound(err), m.processor.InstanceGet) - 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 == "" { - err := fmt.Errorf("missing %s from request", AccountIDKey) - api.ErrorHandler(c, gtserror.NewErrorNotFound(err), m.processor.InstanceGet) - return - } - - mediaType := c.Param(MediaTypeKey) - if mediaType == "" { - err := fmt.Errorf("missing %s from request", MediaTypeKey) - api.ErrorHandler(c, gtserror.NewErrorNotFound(err), m.processor.InstanceGet) - return - } - - mediaSize := c.Param(MediaSizeKey) - if mediaSize == "" { - err := fmt.Errorf("missing %s from request", MediaSizeKey) - api.ErrorHandler(c, gtserror.NewErrorNotFound(err), m.processor.InstanceGet) - return - } - - fileName := c.Param(FileNameKey) - if fileName == "" { - err := fmt.Errorf("missing %s from request", FileNameKey) - api.ErrorHandler(c, gtserror.NewErrorNotFound(err), m.processor.InstanceGet) - return - } - - content, errWithCode := m.processor.FileGet(c.Request.Context(), authed, &model.GetContentRequestForm{ - AccountID: accountID, - MediaType: mediaType, - MediaSize: mediaSize, - FileName: fileName, - }) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - defer func() { - // close content when we're done - if content.Content != nil { - if err := content.Content.Close(); err != nil { - log.Errorf("ServeFile: error closing readcloser: %s", err) - } - } - }() - - if content.URL != nil { - c.Redirect(http.StatusFound, content.URL.String()) - return - } - - // TODO: if the requester only accepts text/html we should try to serve them *something*. - // This is mostly needed because when sharing a link to a gts-hosted file on something like mastodon, the masto servers will - // attempt to look up the content to provide a preview of the link, and they ask for text/html. - format, err := api.NegotiateAccept(c, api.MIME(content.ContentType)) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - // since we'll never host different files at the same - // URL (bc the ULIDs are generated per piece of media), - // it's sensible and safe to use a long cache here, so - // that clients don't keep fetching files over + over again - c.Header("Cache-Control", "max-age=604800") - - if c.Request.Method == http.MethodHead { - c.Header("Content-Type", format) - c.Header("Content-Length", strconv.FormatInt(content.ContentLength, 10)) - c.Status(http.StatusOK) - return - } - - // try to slurp the first few bytes to make sure we have something - b := bytes.NewBuffer(make([]byte, 0, 64)) - if _, err := io.CopyN(b, content.Content, 64); err != nil { - err = fmt.Errorf("ServeFile: error reading from content: %w", err) - api.ErrorHandler(c, gtserror.NewErrorNotFound(err, err.Error()), m.processor.InstanceGet) - return - } - - // we're good, return the slurped bytes + the rest of the content - c.DataFromReader(http.StatusOK, content.ContentLength, format, io.MultiReader(b, content.Content), nil) -} diff --git a/internal/api/client/fileserver/servefile_test.go b/internal/api/client/fileserver/servefile_test.go @@ -1,272 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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" - "io/ioutil" - "net/http" - "net/http/httptest" - "testing" - - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/api/client/fileserver" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type ServeFileTestSuite struct { - FileserverTestSuite -} - -// GetFile is just a convenience function to save repetition in this test suite. -// It takes the required params to serve a file, calls the handler, and returns -// the http status code, the response headers, and the parsed body bytes. -func (suite *ServeFileTestSuite) GetFile( - accountID string, - mediaType media.Type, - mediaSize media.Size, - filename string, -) (code int, headers http.Header, body []byte) { - recorder := httptest.NewRecorder() - - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - ctx.Request = httptest.NewRequest(http.MethodGet, "http://localhost:8080/whatever", nil) - ctx.Request.Header.Set("accept", "*/*") - ctx.AddParam(fileserver.AccountIDKey, accountID) - ctx.AddParam(fileserver.MediaTypeKey, string(mediaType)) - ctx.AddParam(fileserver.MediaSizeKey, string(mediaSize)) - ctx.AddParam(fileserver.FileNameKey, filename) - - suite.fileServer.ServeFile(ctx) - code = recorder.Code - headers = recorder.Result().Header - - var err error - body, err = ioutil.ReadAll(recorder.Body) - if err != nil { - suite.FailNow(err.Error()) - } - - return -} - -// UncacheAttachment is a convenience function that uncaches the targetAttachment by -// removing its associated files from storage, and updating the database. -func (suite *ServeFileTestSuite) UncacheAttachment(targetAttachment *gtsmodel.MediaAttachment) { - ctx := context.Background() - - cached := false - targetAttachment.Cached = &cached - - if err := suite.db.UpdateByID(ctx, targetAttachment, targetAttachment.ID, "cached"); err != nil { - suite.FailNow(err.Error()) - } - if err := suite.storage.Delete(ctx, targetAttachment.File.Path); err != nil { - suite.FailNow(err.Error()) - } - if err := suite.storage.Delete(ctx, targetAttachment.Thumbnail.Path); err != nil { - suite.FailNow(err.Error()) - } -} - -func (suite *ServeFileTestSuite) TestServeOriginalLocalFileOK() { - targetAttachment := &gtsmodel.MediaAttachment{} - *targetAttachment = *suite.testAttachments["admin_account_status_1_attachment_1"] - fileInStorage, err := suite.storage.Get(context.Background(), targetAttachment.File.Path) - if err != nil { - suite.FailNow(err.Error()) - } - - code, headers, body := suite.GetFile( - targetAttachment.AccountID, - media.TypeAttachment, - media.SizeOriginal, - targetAttachment.ID+".jpeg", - ) - - suite.Equal(http.StatusOK, code) - suite.Equal("image/jpeg", headers.Get("content-type")) - suite.Equal(fileInStorage, body) -} - -func (suite *ServeFileTestSuite) TestServeSmallLocalFileOK() { - targetAttachment := &gtsmodel.MediaAttachment{} - *targetAttachment = *suite.testAttachments["admin_account_status_1_attachment_1"] - fileInStorage, err := suite.storage.Get(context.Background(), targetAttachment.Thumbnail.Path) - if err != nil { - suite.FailNow(err.Error()) - } - - code, headers, body := suite.GetFile( - targetAttachment.AccountID, - media.TypeAttachment, - media.SizeSmall, - targetAttachment.ID+".jpeg", - ) - - suite.Equal(http.StatusOK, code) - suite.Equal("image/jpeg", headers.Get("content-type")) - suite.Equal(fileInStorage, body) -} - -func (suite *ServeFileTestSuite) TestServeOriginalRemoteFileOK() { - targetAttachment := &gtsmodel.MediaAttachment{} - *targetAttachment = *suite.testAttachments["remote_account_1_status_1_attachment_1"] - fileInStorage, err := suite.storage.Get(context.Background(), targetAttachment.File.Path) - if err != nil { - suite.FailNow(err.Error()) - } - - code, headers, body := suite.GetFile( - targetAttachment.AccountID, - media.TypeAttachment, - media.SizeOriginal, - targetAttachment.ID+".jpeg", - ) - - suite.Equal(http.StatusOK, code) - suite.Equal("image/jpeg", headers.Get("content-type")) - suite.Equal(fileInStorage, body) -} - -func (suite *ServeFileTestSuite) TestServeSmallRemoteFileOK() { - targetAttachment := &gtsmodel.MediaAttachment{} - *targetAttachment = *suite.testAttachments["remote_account_1_status_1_attachment_1"] - fileInStorage, err := suite.storage.Get(context.Background(), targetAttachment.Thumbnail.Path) - if err != nil { - suite.FailNow(err.Error()) - } - - code, headers, body := suite.GetFile( - targetAttachment.AccountID, - media.TypeAttachment, - media.SizeSmall, - targetAttachment.ID+".jpeg", - ) - - suite.Equal(http.StatusOK, code) - suite.Equal("image/jpeg", headers.Get("content-type")) - suite.Equal(fileInStorage, body) -} - -func (suite *ServeFileTestSuite) TestServeOriginalRemoteFileRecache() { - targetAttachment := &gtsmodel.MediaAttachment{} - *targetAttachment = *suite.testAttachments["remote_account_1_status_1_attachment_1"] - fileInStorage, err := suite.storage.Get(context.Background(), targetAttachment.File.Path) - if err != nil { - suite.FailNow(err.Error()) - } - - // uncache the attachment so we'll have to refetch it from the 'remote' instance - suite.UncacheAttachment(targetAttachment) - - code, headers, body := suite.GetFile( - targetAttachment.AccountID, - media.TypeAttachment, - media.SizeOriginal, - targetAttachment.ID+".jpeg", - ) - - suite.Equal(http.StatusOK, code) - suite.Equal("image/jpeg", headers.Get("content-type")) - suite.Equal(fileInStorage, body) -} - -func (suite *ServeFileTestSuite) TestServeSmallRemoteFileRecache() { - targetAttachment := &gtsmodel.MediaAttachment{} - *targetAttachment = *suite.testAttachments["remote_account_1_status_1_attachment_1"] - fileInStorage, err := suite.storage.Get(context.Background(), targetAttachment.Thumbnail.Path) - if err != nil { - suite.FailNow(err.Error()) - } - - // uncache the attachment so we'll have to refetch it from the 'remote' instance - suite.UncacheAttachment(targetAttachment) - - code, headers, body := suite.GetFile( - targetAttachment.AccountID, - media.TypeAttachment, - media.SizeSmall, - targetAttachment.ID+".jpeg", - ) - - suite.Equal(http.StatusOK, code) - suite.Equal("image/jpeg", headers.Get("content-type")) - suite.Equal(fileInStorage, body) -} - -func (suite *ServeFileTestSuite) TestServeOriginalRemoteFileRecacheNotFound() { - targetAttachment := &gtsmodel.MediaAttachment{} - *targetAttachment = *suite.testAttachments["remote_account_1_status_1_attachment_1"] - - // uncache the attachment *and* set the remote URL to something that will return a 404 - suite.UncacheAttachment(targetAttachment) - targetAttachment.RemoteURL = "http://nothing.at.this.url/weeeeeeeee" - if err := suite.db.UpdateByID(context.Background(), targetAttachment, targetAttachment.ID, "remote_url"); err != nil { - suite.FailNow(err.Error()) - } - - code, _, _ := suite.GetFile( - targetAttachment.AccountID, - media.TypeAttachment, - media.SizeOriginal, - targetAttachment.ID+".jpeg", - ) - - suite.Equal(http.StatusNotFound, code) -} - -func (suite *ServeFileTestSuite) TestServeSmallRemoteFileRecacheNotFound() { - targetAttachment := &gtsmodel.MediaAttachment{} - *targetAttachment = *suite.testAttachments["remote_account_1_status_1_attachment_1"] - - // uncache the attachment *and* set the remote URL to something that will return a 404 - suite.UncacheAttachment(targetAttachment) - targetAttachment.RemoteURL = "http://nothing.at.this.url/weeeeeeeee" - if err := suite.db.UpdateByID(context.Background(), targetAttachment, targetAttachment.ID, "remote_url"); err != nil { - suite.FailNow(err.Error()) - } - - code, _, _ := suite.GetFile( - targetAttachment.AccountID, - media.TypeAttachment, - media.SizeSmall, - targetAttachment.ID+".jpeg", - ) - - suite.Equal(http.StatusNotFound, code) -} - -// Callers trying to get some random-ass file that doesn't exist should just get a 404 -func (suite *ServeFileTestSuite) TestServeFileNotFound() { - code, _, _ := suite.GetFile( - "01GMMY4G9B0QEG0PQK5Q5JGJWZ", - media.TypeAttachment, - media.SizeOriginal, - "01GMMY68Y7E5DJ3CA3Y9SS8524.jpeg", - ) - - suite.Equal(http.StatusNotFound, code) -} - -func TestServeFileTestSuite(t *testing.T) { - suite.Run(t, new(ServeFileTestSuite)) -} diff --git a/internal/api/client/filter/filter.go b/internal/api/client/filter/filter.go @@ -1,50 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 filter - -import ( - "net/http" - - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/processing" - "github.com/superseriousbusiness/gotosocial/internal/router" -) - -const ( - // BasePath is the base path for serving the filter API - BasePath = "/api/v1/filters" -) - -// Module implements the ClientAPIModule interface for every related to filters -type Module struct { - processor processing.Processor -} - -// New returns a new filter module -func New(processor processing.Processor) api.ClientModule { - return &Module{ - processor: processor, - } -} - -// Route attaches all routes from this module to the given router -func (m *Module) Route(r router.Router) error { - r.AttachHandler(http.MethodGet, BasePath, m.FiltersGETHandler) - return nil -} diff --git a/internal/api/client/filter/filtersget.go b/internal/api/client/filter/filtersget.go @@ -1,25 +0,0 @@ -package filter - -import ( - "net/http" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// FiltersGETHandler returns a list of filters set by/for the authed account -func (m *Module) FiltersGETHandler(c *gin.Context) { - if _, err := oauth.Authed(c, true, true, true, true); err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) - return - } - - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - c.JSON(http.StatusOK, []string{}) -} diff --git a/internal/api/client/filters/filter.go b/internal/api/client/filters/filter.go @@ -0,0 +1,45 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 filter + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/processing" +) + +const ( + // BasePath is the base path for serving the filters API, minus the 'api' prefix + BasePath = "/v1/filters" +) + +type Module struct { + processor processing.Processor +} + +func New(processor processing.Processor) *Module { + return &Module{ + processor: processor, + } +} + +func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) { + attachHandler(http.MethodGet, BasePath, m.FiltersGETHandler) +} diff --git a/internal/api/client/filters/filtersget.go b/internal/api/client/filters/filtersget.go @@ -0,0 +1,25 @@ +package filter + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// FiltersGETHandler returns a list of filters set by/for the authed account +func (m *Module) FiltersGETHandler(c *gin.Context) { + if _, err := oauth.Authed(c, true, true, true, true); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + c.JSON(http.StatusOK, []string{}) +} diff --git a/internal/api/client/followrequest/authorize.go b/internal/api/client/followrequest/authorize.go @@ -1,98 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 followrequest - -import ( - "errors" - "net/http" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// FollowRequestAuthorizePOSTHandler swagger:operation POST /api/v1/follow_requests/{account_id}/authorize authorizeFollowRequest -// -// Accept/authorize follow request from the given account ID. -// -// Accept a follow request and put the requesting account in your 'followers' list. -// -// --- -// tags: -// - follow_requests -// -// produces: -// - application/json -// -// parameters: -// - -// name: account_id -// type: string -// description: ID of the account requesting to follow you. -// in: path -// required: true -// -// security: -// - OAuth2 Bearer: -// - write:follows -// -// responses: -// '200': -// name: account relationship -// description: Your relationship to this account. -// schema: -// "$ref": "#/definitions/accountRelationship" -// '400': -// description: bad request -// '401': -// description: unauthorized -// '404': -// description: not found -// '406': -// description: not acceptable -// '500': -// description: internal server error -func (m *Module) FollowRequestAuthorizePOSTHandler(c *gin.Context) { - authed, err := oauth.Authed(c, true, true, true, true) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) - return - } - - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - originAccountID := c.Param(IDKey) - if originAccountID == "" { - err := errors.New("no account id specified") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - relationship, errWithCode := m.processor.FollowRequestAccept(c.Request.Context(), authed, originAccountID) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - c.JSON(http.StatusOK, relationship) -} diff --git a/internal/api/client/followrequest/authorize_test.go b/internal/api/client/followrequest/authorize_test.go @@ -1,115 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 followrequest_test - -import ( - "context" - "fmt" - "io/ioutil" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/api/client/followrequest" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" -) - -type AuthorizeTestSuite struct { - FollowRequestStandardTestSuite -} - -func (suite *AuthorizeTestSuite) TestAuthorize() { - requestingAccount := suite.testAccounts["remote_account_2"] - targetAccount := suite.testAccounts["local_account_1"] - - // put a follow request in the database - fr := &gtsmodel.FollowRequest{ - ID: "01FJ1S8DX3STJJ6CEYPMZ1M0R3", - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - URI: fmt.Sprintf("%s/follow/01FJ1S8DX3STJJ6CEYPMZ1M0R3", requestingAccount.URI), - AccountID: requestingAccount.ID, - TargetAccountID: targetAccount.ID, - } - - err := suite.db.Put(context.Background(), fr) - suite.NoError(err) - - recorder := httptest.NewRecorder() - ctx := suite.newContext(recorder, http.MethodPost, []byte{}, fmt.Sprintf("/api/v1/follow_requests/%s/authorize", requestingAccount.ID), "") - - ctx.Params = gin.Params{ - gin.Param{ - Key: followrequest.IDKey, - Value: requestingAccount.ID, - }, - } - - // call the handler - suite.followRequestModule.FollowRequestAuthorizePOSTHandler(ctx) - - // 1. we should have OK because our request was valid - suite.Equal(http.StatusOK, recorder.Code) - - // 2. we should have no error message in the result body - result := recorder.Result() - defer result.Body.Close() - - // check the response - b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) - - suite.Equal(`{"id":"01FHMQX3GAABWSM0S2VZEC2SWC","following":false,"showing_reblogs":false,"notifying":false,"followed_by":true,"blocking":false,"blocked_by":false,"muting":false,"muting_notifications":false,"requested":false,"domain_blocking":false,"endorsed":false,"note":""}`, string(b)) -} - -func (suite *AuthorizeTestSuite) TestAuthorizeNoFR() { - requestingAccount := suite.testAccounts["remote_account_2"] - - recorder := httptest.NewRecorder() - ctx := suite.newContext(recorder, http.MethodPost, []byte{}, fmt.Sprintf("/api/v1/follow_requests/%s/authorize", requestingAccount.ID), "") - - ctx.Params = gin.Params{ - gin.Param{ - Key: followrequest.IDKey, - Value: requestingAccount.ID, - }, - } - - // call the handler - suite.followRequestModule.FollowRequestAuthorizePOSTHandler(ctx) - - suite.Equal(http.StatusNotFound, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - - // check the response - b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) - - suite.Equal(`{"error":"Not Found"}`, string(b)) -} - -func TestAuthorizeTestSuite(t *testing.T) { - suite.Run(t, &AuthorizeTestSuite{}) -} diff --git a/internal/api/client/followrequest/followrequest.go b/internal/api/client/followrequest/followrequest.go @@ -1,61 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 followrequest - -import ( - "net/http" - - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/processing" - "github.com/superseriousbusiness/gotosocial/internal/router" -) - -const ( - // IDKey is for account IDs - IDKey = "id" - // BasePath is the base path for serving the follow request API - BasePath = "/api/v1/follow_requests" - // BasePathWithID is just the base path with the ID key in it. - // Use this anywhere you need to know the ID of the account that owns the follow request being queried. - BasePathWithID = BasePath + "/:" + IDKey - // AuthorizePath is used for authorizing follow requests - AuthorizePath = BasePathWithID + "/authorize" - // RejectPath is used for rejecting follow requests - RejectPath = BasePathWithID + "/reject" -) - -// Module implements the ClientAPIModule interface -type Module struct { - processor processing.Processor -} - -// New returns a new follow request module -func New(processor processing.Processor) api.ClientModule { - return &Module{ - processor: processor, - } -} - -// Route attaches all routes from this module to the given router -func (m *Module) Route(r router.Router) error { - r.AttachHandler(http.MethodGet, BasePath, m.FollowRequestGETHandler) - r.AttachHandler(http.MethodPost, AuthorizePath, m.FollowRequestAuthorizePOSTHandler) - r.AttachHandler(http.MethodPost, RejectPath, m.FollowRequestRejectPOSTHandler) - return nil -} diff --git a/internal/api/client/followrequest/followrequest_test.go b/internal/api/client/followrequest/followrequest_test.go @@ -1,122 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 followrequest_test - -import ( - "bytes" - "fmt" - "net/http/httptest" - - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/api/client/followrequest" - "github.com/superseriousbusiness/gotosocial/internal/concurrency" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/email" - "github.com/superseriousbusiness/gotosocial/internal/federation" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/messages" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/processing" - "github.com/superseriousbusiness/gotosocial/internal/storage" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type FollowRequestStandardTestSuite struct { - suite.Suite - db db.DB - storage *storage.Driver - mediaManager media.Manager - federator federation.Federator - processor processing.Processor - emailSender email.Sender - - // standard suite models - testTokens map[string]*gtsmodel.Token - testClients map[string]*gtsmodel.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 - followRequestModule *followrequest.Module -} - -func (suite *FollowRequestStandardTestSuite) 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 *FollowRequestStandardTestSuite) SetupTest() { - testrig.InitTestConfig() - testrig.InitTestLog() - - fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) - clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) - - suite.db = testrig.NewTestDB() - suite.storage = testrig.NewInMemoryStorage() - suite.mediaManager = testrig.NewTestMediaManager(suite.db, suite.storage) - suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker), suite.storage, suite.mediaManager, fedWorker) - suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil) - suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, suite.mediaManager, clientWorker, fedWorker) - suite.followRequestModule = followrequest.New(suite.processor).(*followrequest.Module) - testrig.StandardDBSetup(suite.db, nil) - testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") - - suite.NoError(suite.processor.Start()) -} - -func (suite *FollowRequestStandardTestSuite) TearDownTest() { - testrig.StandardDBTeardown(suite.db) - testrig.StandardStorageTeardown(suite.storage) -} - -func (suite *FollowRequestStandardTestSuite) newContext(recorder *httptest.ResponseRecorder, requestMethod string, requestBody []byte, requestPath string, bodyContentType string) *gin.Context { - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - - ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) - ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"])) - ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) - ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) - - protocol := config.GetProtocol() - host := config.GetHost() - - baseURI := fmt.Sprintf("%s://%s", protocol, host) - requestURI := fmt.Sprintf("%s/%s", baseURI, requestPath) - - ctx.Request = httptest.NewRequest(requestMethod, requestURI, bytes.NewReader(requestBody)) // the endpoint we're hitting - - if bodyContentType != "" { - ctx.Request.Header.Set("Content-Type", bodyContentType) - } - ctx.Request.Header.Set("accept", "application/json") - - return ctx -} diff --git a/internal/api/client/followrequest/get.go b/internal/api/client/followrequest/get.go @@ -1,93 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 followrequest - -import ( - "net/http" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// FollowRequestGETHandler swagger:operation GET /api/v1/follow_requests getFollowRequests -// -// Get an array of accounts that have requested to follow you. -// Accounts will be sorted in order of follow request date descending (newest first). -// -// --- -// tags: -// - follow_requests -// -// produces: -// - application/json -// -// parameters: -// - -// name: limit -// type: integer -// description: Number of accounts to return. -// default: 40 -// in: query -// -// security: -// - OAuth2 Bearer: -// - read:follows -// -// responses: -// '200': -// headers: -// Link: -// type: string -// description: Links to the next and previous queries. -// schema: -// type: array -// items: -// "$ref": "#/definitions/account" -// '400': -// description: bad request -// '401': -// description: unauthorized -// '404': -// description: not found -// '406': -// description: not acceptable -// '500': -// description: internal server error -func (m *Module) FollowRequestGETHandler(c *gin.Context) { - authed, err := oauth.Authed(c, true, true, true, true) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) - return - } - - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - accts, errWithCode := m.processor.FollowRequestsGet(c.Request.Context(), authed) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - c.JSON(http.StatusOK, accts) -} diff --git a/internal/api/client/followrequest/get_test.go b/internal/api/client/followrequest/get_test.go @@ -1,78 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 followrequest_test - -import ( - "context" - "fmt" - "io/ioutil" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" -) - -type GetTestSuite struct { - FollowRequestStandardTestSuite -} - -func (suite *GetTestSuite) TestGet() { - requestingAccount := suite.testAccounts["remote_account_2"] - targetAccount := suite.testAccounts["local_account_1"] - - // put a follow request in the database - fr := &gtsmodel.FollowRequest{ - ID: "01FJ1S8DX3STJJ6CEYPMZ1M0R3", - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - URI: fmt.Sprintf("%s/follow/01FJ1S8DX3STJJ6CEYPMZ1M0R3", requestingAccount.URI), - AccountID: requestingAccount.ID, - TargetAccountID: targetAccount.ID, - } - - err := suite.db.Put(context.Background(), fr) - suite.NoError(err) - - recorder := httptest.NewRecorder() - ctx := suite.newContext(recorder, http.MethodGet, []byte{}, "/api/v1/follow_requests", "") - - // call the handler - suite.followRequestModule.FollowRequestGETHandler(ctx) - - // 1. we should have OK because our request was valid - suite.Equal(http.StatusOK, recorder.Code) - - // 2. we should have no error message in the result body - result := recorder.Result() - defer result.Body.Close() - - // check the response - b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) - - suite.Equal(`[{"id":"01FHMQX3GAABWSM0S2VZEC2SWC","username":"Some_User","acct":"Some_User@example.org","display_name":"some user","locked":true,"bot":false,"created_at":"2020-08-10T12:13:28.000Z","note":"i'm a real son of a gun","url":"http://example.org/@Some_User","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":0,"following_count":0,"statuses_count":0,"last_status_at":null,"emojis":[],"fields":[]}]`, string(b)) -} - -func TestGetTestSuite(t *testing.T) { - suite.Run(t, &GetTestSuite{}) -} diff --git a/internal/api/client/followrequest/reject.go b/internal/api/client/followrequest/reject.go @@ -1,96 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 followrequest - -import ( - "errors" - "net/http" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// FollowRequestRejectPOSTHandler swagger:operation POST /api/v1/follow_requests/{account_id}/reject rejectFollowRequest -// -// Reject/deny follow request from the given account ID. -// -// --- -// tags: -// - follow_requests -// -// produces: -// - application/json -// -// parameters: -// - -// name: account_id -// type: string -// description: ID of the account requesting to follow you. -// in: path -// required: true -// -// security: -// - OAuth2 Bearer: -// - write:follows -// -// responses: -// '200': -// name: account relationship -// description: Your relationship to this account. -// schema: -// "$ref": "#/definitions/accountRelationship" -// '400': -// description: bad request -// '401': -// description: unauthorized -// '404': -// description: not found -// '406': -// description: not acceptable -// '500': -// description: internal server error -func (m *Module) FollowRequestRejectPOSTHandler(c *gin.Context) { - authed, err := oauth.Authed(c, true, true, true, true) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) - return - } - - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - originAccountID := c.Param(IDKey) - if originAccountID == "" { - err := errors.New("no account id specified") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - relationship, errWithCode := m.processor.FollowRequestReject(c.Request.Context(), authed, originAccountID) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - c.JSON(http.StatusOK, relationship) -} diff --git a/internal/api/client/followrequest/reject_test.go b/internal/api/client/followrequest/reject_test.go @@ -1,87 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 followrequest_test - -import ( - "context" - "fmt" - "io/ioutil" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/api/client/followrequest" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" -) - -type RejectTestSuite struct { - FollowRequestStandardTestSuite -} - -func (suite *RejectTestSuite) TestReject() { - requestingAccount := suite.testAccounts["remote_account_2"] - targetAccount := suite.testAccounts["local_account_1"] - - // put a follow request in the database - fr := &gtsmodel.FollowRequest{ - ID: "01FJ1S8DX3STJJ6CEYPMZ1M0R3", - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - URI: fmt.Sprintf("%s/follow/01FJ1S8DX3STJJ6CEYPMZ1M0R3", requestingAccount.URI), - AccountID: requestingAccount.ID, - TargetAccountID: targetAccount.ID, - } - - err := suite.db.Put(context.Background(), fr) - suite.NoError(err) - - recorder := httptest.NewRecorder() - ctx := suite.newContext(recorder, http.MethodPost, []byte{}, fmt.Sprintf("/api/v1/follow_requests/%s/reject", requestingAccount.ID), "") - - ctx.Params = gin.Params{ - gin.Param{ - Key: followrequest.IDKey, - Value: requestingAccount.ID, - }, - } - - // call the handler - suite.followRequestModule.FollowRequestRejectPOSTHandler(ctx) - - // 1. we should have OK because our request was valid - suite.Equal(http.StatusOK, recorder.Code) - - // 2. we should have no error message in the result body - result := recorder.Result() - defer result.Body.Close() - - // check the response - b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) - - suite.Equal(`{"id":"01FHMQX3GAABWSM0S2VZEC2SWC","following":false,"showing_reblogs":false,"notifying":false,"followed_by":false,"blocking":false,"blocked_by":false,"muting":false,"muting_notifications":false,"requested":false,"domain_blocking":false,"endorsed":false,"note":""}`, string(b)) -} - -func TestRejectTestSuite(t *testing.T) { - suite.Run(t, &RejectTestSuite{}) -} diff --git a/internal/api/client/followrequests/authorize.go b/internal/api/client/followrequests/authorize.go @@ -0,0 +1,98 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 followrequests + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// FollowRequestAuthorizePOSTHandler swagger:operation POST /api/v1/follow_requests/{account_id}/authorize authorizeFollowRequest +// +// Accept/authorize follow request from the given account ID. +// +// Accept a follow request and put the requesting account in your 'followers' list. +// +// --- +// tags: +// - follow_requests +// +// produces: +// - application/json +// +// parameters: +// - +// name: account_id +// type: string +// description: ID of the account requesting to follow you. +// in: path +// required: true +// +// security: +// - OAuth2 Bearer: +// - write:follows +// +// responses: +// '200': +// name: account relationship +// description: Your relationship to this account. +// schema: +// "$ref": "#/definitions/accountRelationship" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) FollowRequestAuthorizePOSTHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + originAccountID := c.Param(IDKey) + if originAccountID == "" { + err := errors.New("no account id specified") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + relationship, errWithCode := m.processor.FollowRequestAccept(c.Request.Context(), authed, originAccountID) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + c.JSON(http.StatusOK, relationship) +} diff --git a/internal/api/client/followrequests/authorize_test.go b/internal/api/client/followrequests/authorize_test.go @@ -0,0 +1,115 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 followrequests_test + +import ( + "context" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/followrequests" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +type AuthorizeTestSuite struct { + FollowRequestStandardTestSuite +} + +func (suite *AuthorizeTestSuite) TestAuthorize() { + requestingAccount := suite.testAccounts["remote_account_2"] + targetAccount := suite.testAccounts["local_account_1"] + + // put a follow request in the database + fr := &gtsmodel.FollowRequest{ + ID: "01FJ1S8DX3STJJ6CEYPMZ1M0R3", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + URI: fmt.Sprintf("%s/follow/01FJ1S8DX3STJJ6CEYPMZ1M0R3", requestingAccount.URI), + AccountID: requestingAccount.ID, + TargetAccountID: targetAccount.ID, + } + + err := suite.db.Put(context.Background(), fr) + suite.NoError(err) + + recorder := httptest.NewRecorder() + ctx := suite.newContext(recorder, http.MethodPost, []byte{}, fmt.Sprintf("/api/v1/follow_requests/%s/authorize", requestingAccount.ID), "") + + ctx.Params = gin.Params{ + gin.Param{ + Key: followrequests.IDKey, + Value: requestingAccount.ID, + }, + } + + // call the handler + suite.followRequestModule.FollowRequestAuthorizePOSTHandler(ctx) + + // 1. we should have OK because our request was valid + suite.Equal(http.StatusOK, recorder.Code) + + // 2. we should have no error message in the result body + result := recorder.Result() + defer result.Body.Close() + + // check the response + b, err := ioutil.ReadAll(result.Body) + assert.NoError(suite.T(), err) + + suite.Equal(`{"id":"01FHMQX3GAABWSM0S2VZEC2SWC","following":false,"showing_reblogs":false,"notifying":false,"followed_by":true,"blocking":false,"blocked_by":false,"muting":false,"muting_notifications":false,"requested":false,"domain_blocking":false,"endorsed":false,"note":""}`, string(b)) +} + +func (suite *AuthorizeTestSuite) TestAuthorizeNoFR() { + requestingAccount := suite.testAccounts["remote_account_2"] + + recorder := httptest.NewRecorder() + ctx := suite.newContext(recorder, http.MethodPost, []byte{}, fmt.Sprintf("/api/v1/follow_requests/%s/authorize", requestingAccount.ID), "") + + ctx.Params = gin.Params{ + gin.Param{ + Key: followrequests.IDKey, + Value: requestingAccount.ID, + }, + } + + // call the handler + suite.followRequestModule.FollowRequestAuthorizePOSTHandler(ctx) + + suite.Equal(http.StatusNotFound, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + + // check the response + b, err := ioutil.ReadAll(result.Body) + assert.NoError(suite.T(), err) + + suite.Equal(`{"error":"Not Found"}`, string(b)) +} + +func TestAuthorizeTestSuite(t *testing.T) { + suite.Run(t, &AuthorizeTestSuite{}) +} diff --git a/internal/api/client/followrequests/followrequest.go b/internal/api/client/followrequests/followrequest.go @@ -0,0 +1,56 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 followrequests + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/processing" +) + +const ( + // IDKey is for account IDs + IDKey = "id" + // BasePath is the base path for serving the follow request API, minus the 'api' prefix + BasePath = "/v1/follow_requests" + // BasePathWithID is just the base path with the ID key in it. + // Use this anywhere you need to know the ID of the account that owns the follow request being queried. + BasePathWithID = BasePath + "/:" + IDKey + // AuthorizePath is used for authorizing follow requests + AuthorizePath = BasePathWithID + "/authorize" + // RejectPath is used for rejecting follow requests + RejectPath = BasePathWithID + "/reject" +) + +type Module struct { + processor processing.Processor +} + +func New(processor processing.Processor) *Module { + return &Module{ + processor: processor, + } +} + +func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) { + attachHandler(http.MethodGet, BasePath, m.FollowRequestGETHandler) + attachHandler(http.MethodPost, AuthorizePath, m.FollowRequestAuthorizePOSTHandler) + attachHandler(http.MethodPost, RejectPath, m.FollowRequestRejectPOSTHandler) +} diff --git a/internal/api/client/followrequests/followrequest_test.go b/internal/api/client/followrequests/followrequest_test.go @@ -0,0 +1,122 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 followrequests_test + +import ( + "bytes" + "fmt" + "net/http/httptest" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/followrequests" + "github.com/superseriousbusiness/gotosocial/internal/concurrency" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/email" + "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/messages" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/processing" + "github.com/superseriousbusiness/gotosocial/internal/storage" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type FollowRequestStandardTestSuite struct { + suite.Suite + db db.DB + storage *storage.Driver + mediaManager media.Manager + federator federation.Federator + processor processing.Processor + emailSender email.Sender + + // standard suite models + testTokens map[string]*gtsmodel.Token + testClients map[string]*gtsmodel.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 + followRequestModule *followrequests.Module +} + +func (suite *FollowRequestStandardTestSuite) 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 *FollowRequestStandardTestSuite) SetupTest() { + testrig.InitTestConfig() + testrig.InitTestLog() + + fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) + clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) + + suite.db = testrig.NewTestDB() + suite.storage = testrig.NewInMemoryStorage() + suite.mediaManager = testrig.NewTestMediaManager(suite.db, suite.storage) + suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker), suite.storage, suite.mediaManager, fedWorker) + suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil) + suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, suite.mediaManager, clientWorker, fedWorker) + suite.followRequestModule = followrequests.New(suite.processor) + testrig.StandardDBSetup(suite.db, nil) + testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") + + suite.NoError(suite.processor.Start()) +} + +func (suite *FollowRequestStandardTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) +} + +func (suite *FollowRequestStandardTestSuite) newContext(recorder *httptest.ResponseRecorder, requestMethod string, requestBody []byte, requestPath string, bodyContentType string) *gin.Context { + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"])) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + + protocol := config.GetProtocol() + host := config.GetHost() + + baseURI := fmt.Sprintf("%s://%s", protocol, host) + requestURI := fmt.Sprintf("%s/%s", baseURI, requestPath) + + ctx.Request = httptest.NewRequest(requestMethod, requestURI, bytes.NewReader(requestBody)) // the endpoint we're hitting + + if bodyContentType != "" { + ctx.Request.Header.Set("Content-Type", bodyContentType) + } + ctx.Request.Header.Set("accept", "application/json") + + return ctx +} diff --git a/internal/api/client/followrequests/get.go b/internal/api/client/followrequests/get.go @@ -0,0 +1,93 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 followrequests + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// FollowRequestGETHandler swagger:operation GET /api/v1/follow_requests getFollowRequests +// +// Get an array of accounts that have requested to follow you. +// Accounts will be sorted in order of follow request date descending (newest first). +// +// --- +// tags: +// - follow_requests +// +// produces: +// - application/json +// +// parameters: +// - +// name: limit +// type: integer +// description: Number of accounts to return. +// default: 40 +// in: query +// +// security: +// - OAuth2 Bearer: +// - read:follows +// +// responses: +// '200': +// headers: +// Link: +// type: string +// description: Links to the next and previous queries. +// schema: +// type: array +// items: +// "$ref": "#/definitions/account" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) FollowRequestGETHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + accts, errWithCode := m.processor.FollowRequestsGet(c.Request.Context(), authed) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + c.JSON(http.StatusOK, accts) +} diff --git a/internal/api/client/followrequests/get_test.go b/internal/api/client/followrequests/get_test.go @@ -0,0 +1,78 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 followrequests_test + +import ( + "context" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +type GetTestSuite struct { + FollowRequestStandardTestSuite +} + +func (suite *GetTestSuite) TestGet() { + requestingAccount := suite.testAccounts["remote_account_2"] + targetAccount := suite.testAccounts["local_account_1"] + + // put a follow request in the database + fr := &gtsmodel.FollowRequest{ + ID: "01FJ1S8DX3STJJ6CEYPMZ1M0R3", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + URI: fmt.Sprintf("%s/follow/01FJ1S8DX3STJJ6CEYPMZ1M0R3", requestingAccount.URI), + AccountID: requestingAccount.ID, + TargetAccountID: targetAccount.ID, + } + + err := suite.db.Put(context.Background(), fr) + suite.NoError(err) + + recorder := httptest.NewRecorder() + ctx := suite.newContext(recorder, http.MethodGet, []byte{}, "/api/v1/follow_requests", "") + + // call the handler + suite.followRequestModule.FollowRequestGETHandler(ctx) + + // 1. we should have OK because our request was valid + suite.Equal(http.StatusOK, recorder.Code) + + // 2. we should have no error message in the result body + result := recorder.Result() + defer result.Body.Close() + + // check the response + b, err := ioutil.ReadAll(result.Body) + assert.NoError(suite.T(), err) + + suite.Equal(`[{"id":"01FHMQX3GAABWSM0S2VZEC2SWC","username":"Some_User","acct":"Some_User@example.org","display_name":"some user","locked":true,"bot":false,"created_at":"2020-08-10T12:13:28.000Z","note":"i'm a real son of a gun","url":"http://example.org/@Some_User","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":0,"following_count":0,"statuses_count":0,"last_status_at":null,"emojis":[],"fields":[]}]`, string(b)) +} + +func TestGetTestSuite(t *testing.T) { + suite.Run(t, &GetTestSuite{}) +} diff --git a/internal/api/client/followrequests/reject.go b/internal/api/client/followrequests/reject.go @@ -0,0 +1,96 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 followrequests + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// FollowRequestRejectPOSTHandler swagger:operation POST /api/v1/follow_requests/{account_id}/reject rejectFollowRequest +// +// Reject/deny follow request from the given account ID. +// +// --- +// tags: +// - follow_requests +// +// produces: +// - application/json +// +// parameters: +// - +// name: account_id +// type: string +// description: ID of the account requesting to follow you. +// in: path +// required: true +// +// security: +// - OAuth2 Bearer: +// - write:follows +// +// responses: +// '200': +// name: account relationship +// description: Your relationship to this account. +// schema: +// "$ref": "#/definitions/accountRelationship" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) FollowRequestRejectPOSTHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + originAccountID := c.Param(IDKey) + if originAccountID == "" { + err := errors.New("no account id specified") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + relationship, errWithCode := m.processor.FollowRequestReject(c.Request.Context(), authed, originAccountID) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + c.JSON(http.StatusOK, relationship) +} diff --git a/internal/api/client/followrequests/reject_test.go b/internal/api/client/followrequests/reject_test.go @@ -0,0 +1,87 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 followrequests_test + +import ( + "context" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/followrequests" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +type RejectTestSuite struct { + FollowRequestStandardTestSuite +} + +func (suite *RejectTestSuite) TestReject() { + requestingAccount := suite.testAccounts["remote_account_2"] + targetAccount := suite.testAccounts["local_account_1"] + + // put a follow request in the database + fr := &gtsmodel.FollowRequest{ + ID: "01FJ1S8DX3STJJ6CEYPMZ1M0R3", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + URI: fmt.Sprintf("%s/follow/01FJ1S8DX3STJJ6CEYPMZ1M0R3", requestingAccount.URI), + AccountID: requestingAccount.ID, + TargetAccountID: targetAccount.ID, + } + + err := suite.db.Put(context.Background(), fr) + suite.NoError(err) + + recorder := httptest.NewRecorder() + ctx := suite.newContext(recorder, http.MethodPost, []byte{}, fmt.Sprintf("/api/v1/follow_requests/%s/reject", requestingAccount.ID), "") + + ctx.Params = gin.Params{ + gin.Param{ + Key: followrequests.IDKey, + Value: requestingAccount.ID, + }, + } + + // call the handler + suite.followRequestModule.FollowRequestRejectPOSTHandler(ctx) + + // 1. we should have OK because our request was valid + suite.Equal(http.StatusOK, recorder.Code) + + // 2. we should have no error message in the result body + result := recorder.Result() + defer result.Body.Close() + + // check the response + b, err := ioutil.ReadAll(result.Body) + assert.NoError(suite.T(), err) + + suite.Equal(`{"id":"01FHMQX3GAABWSM0S2VZEC2SWC","following":false,"showing_reblogs":false,"notifying":false,"followed_by":false,"blocking":false,"blocked_by":false,"muting":false,"muting_notifications":false,"requested":false,"domain_blocking":false,"endorsed":false,"note":""}`, string(b)) +} + +func TestRejectTestSuite(t *testing.T) { + suite.Run(t, &RejectTestSuite{}) +} diff --git a/internal/api/client/instance/instance.go b/internal/api/client/instance/instance.go @@ -21,36 +21,31 @@ package instance import ( "net/http" - "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/processing" - "github.com/superseriousbusiness/gotosocial/internal/router" ) const ( - // InstanceInformationPath is for serving instance info requests - InstanceInformationPath = "api/v1/instance" + // InstanceInformationPath is for serving instance info requests, minus the 'api' prefix. + InstanceInformationPath = "/v1/instance" // InstancePeersPath is for serving instance peers requests. InstancePeersPath = InstanceInformationPath + "/peers" // PeersFilterKey is used to provide filters to /api/v1/instance/peers PeersFilterKey = "filter" ) -// Module implements the ClientModule interface type Module struct { processor processing.Processor } -// New returns a new instance information module -func New(processor processing.Processor) api.ClientModule { +func New(processor processing.Processor) *Module { return &Module{ processor: processor, } } -// Route satisfies the ClientModule interface -func (m *Module) Route(s router.Router) error { - s.AttachHandler(http.MethodGet, InstanceInformationPath, m.InstanceInformationGETHandler) - s.AttachHandler(http.MethodPatch, InstanceInformationPath, m.InstanceUpdatePATCHHandler) - s.AttachHandler(http.MethodGet, InstancePeersPath, m.InstancePeersGETHandler) - return nil +func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) { + attachHandler(http.MethodGet, InstanceInformationPath, m.InstanceInformationGETHandler) + attachHandler(http.MethodPatch, InstanceInformationPath, m.InstanceUpdatePATCHHandler) + attachHandler(http.MethodGet, InstancePeersPath, m.InstancePeersGETHandler) } diff --git a/internal/api/client/instance/instance_test.go b/internal/api/client/instance/instance_test.go @@ -88,7 +88,7 @@ func (suite *InstanceStandardTestSuite) SetupTest() { suite.sentEmails = make(map[string]string) suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails) suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, suite.mediaManager, clientWorker, fedWorker) - suite.instanceModule = instance.New(suite.processor).(*instance.Module) + suite.instanceModule = instance.New(suite.processor) testrig.StandardDBSetup(suite.db, nil) testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") } diff --git a/internal/api/client/instance/instanceget.go b/internal/api/client/instance/instanceget.go @@ -21,7 +21,7 @@ package instance import ( "net/http" - "github.com/superseriousbusiness/gotosocial/internal/api" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/gtserror" @@ -49,14 +49,14 @@ import ( // '500': // description: internal error func (m *Module) InstanceInformationGETHandler(c *gin.Context) { - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } instance, errWithCode := m.processor.InstanceGet(c.Request.Context(), config.GetHost()) if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/instance/instancepatch.go b/internal/api/client/instance/instancepatch.go @@ -24,8 +24,8 @@ import ( "net/http" "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/api/model" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" @@ -130,42 +130,42 @@ import ( func (m *Module) InstanceUpdatePATCHHandler(c *gin.Context) { authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } if !*authed.User.Admin { err := errors.New("user is not an admin so cannot update instance settings") - api.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGet) return } - form := &model.InstanceSettingsUpdateRequest{} + form := &apimodel.InstanceSettingsUpdateRequest{} if err := c.ShouldBind(&form); err != nil { - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } if err := validateInstanceUpdate(form); err != nil { - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } i, errWithCode := m.processor.InstancePatch(c.Request.Context(), form) if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } c.JSON(http.StatusOK, i) } -func validateInstanceUpdate(form *model.InstanceSettingsUpdateRequest) error { +func validateInstanceUpdate(form *apimodel.InstanceSettingsUpdateRequest) error { if form.Title == nil && form.ContactUsername == nil && form.ContactEmail == nil && diff --git a/internal/api/client/instance/instancepeersget.go b/internal/api/client/instance/instancepeersget.go @@ -23,7 +23,7 @@ import ( "net/http" "strings" - "github.com/superseriousbusiness/gotosocial/internal/api" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" @@ -101,12 +101,12 @@ import ( func (m *Module) InstancePeersGETHandler(c *gin.Context) { authed, err := oauth.Authed(c, false, false, false, false) if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } @@ -124,7 +124,7 @@ func (m *Module) InstancePeersGETHandler(c *gin.Context) { includeOpen = true default: err := fmt.Errorf("filter %s not recognized; accepted values are 'open', 'suspended'", trimmed) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } } @@ -138,7 +138,7 @@ func (m *Module) InstancePeersGETHandler(c *gin.Context) { data, errWithCode := m.processor.InstancePeersGet(c.Request.Context(), authed, includeSuspended, includeOpen, flat) if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/list/list.go b/internal/api/client/list/list.go @@ -1,50 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 list - -import ( - "net/http" - - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/processing" - "github.com/superseriousbusiness/gotosocial/internal/router" -) - -const ( - // BasePath is the base path for serving the lists API - BasePath = "/api/v1/lists" -) - -// Module implements the ClientAPIModule interface for everything related to lists -type Module struct { - processor processing.Processor -} - -// New returns a new list module -func New(processor processing.Processor) api.ClientModule { - return &Module{ - processor: processor, - } -} - -// Route attaches all routes from this module to the given router -func (m *Module) Route(r router.Router) error { - r.AttachHandler(http.MethodGet, BasePath, m.ListsGETHandler) - return nil -} diff --git a/internal/api/client/list/listsgets.go b/internal/api/client/list/listsgets.go @@ -1,25 +0,0 @@ -package list - -import ( - "net/http" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// ListsGETHandler returns a list of lists created by/for the authed account -func (m *Module) ListsGETHandler(c *gin.Context) { - if _, err := oauth.Authed(c, true, true, true, true); err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) - return - } - - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - c.JSON(http.StatusOK, []string{}) -} diff --git a/internal/api/client/lists/list.go b/internal/api/client/lists/list.go @@ -0,0 +1,45 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 lists + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/processing" +) + +const ( + // BasePath is the base path for serving the lists API, minus the 'api' prefix + BasePath = "/v1/lists" +) + +type Module struct { + processor processing.Processor +} + +func New(processor processing.Processor) *Module { + return &Module{ + processor: processor, + } +} + +func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) { + attachHandler(http.MethodGet, BasePath, m.ListsGETHandler) +} diff --git a/internal/api/client/lists/listsgets.go b/internal/api/client/lists/listsgets.go @@ -0,0 +1,44 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 lists + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// ListsGETHandler returns a list of lists created by/for the authed account +func (m *Module) ListsGETHandler(c *gin.Context) { + if _, err := oauth.Authed(c, true, true, true, true); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + // todo: implement this; currently it's a no-op + c.JSON(http.StatusOK, []string{}) +} diff --git a/internal/api/client/media/media.go b/internal/api/client/media/media.go @@ -21,34 +21,31 @@ package media import ( "net/http" - "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/processing" - "github.com/superseriousbusiness/gotosocial/internal/router" ) const ( - IDKey = "id" // IDKey is the key for media attachment IDs - APIVersionKey = "api_version" // APIVersionKey is the key for which version of the API to use (v1 or v2) - BasePathWithAPIVersion = "/api/:" + APIVersionKey + "/media" // BasePathWithAPIVersion is the base API path for making media requests through v1 or v2 of the api (for mastodon API compatibility) - BasePathWithIDV1 = "/api/v1/media/:" + IDKey // BasePathWithID corresponds to a media attachment with the given ID + IDKey = "id" // IDKey is the key for media attachment IDs + APIVersionKey = "api_version" // APIVersionKey is the key for which version of the API to use (v1 or v2) + APIv1 = "v1" // APIV1 corresponds to version 1 of the api + APIv2 = "v2" // APIV2 corresponds to version 2 of the api + BasePath = "/:" + APIVersionKey + "/media" // BasePath is the base API path for making media requests through v1 or v2 of the api (for mastodon API compatibility) + AttachmentWithID = BasePath + "/:" + IDKey // BasePathWithID corresponds to a media attachment with the given ID ) -// Module implements the ClientAPIModule interface for media type Module struct { processor processing.Processor } -// New returns a new auth module -func New(processor processing.Processor) api.ClientModule { +func New(processor processing.Processor) *Module { return &Module{ processor: processor, } } -// Route satisfies the RESTAPIModule interface -func (m *Module) Route(s router.Router) error { - s.AttachHandler(http.MethodPost, BasePathWithAPIVersion, m.MediaCreatePOSTHandler) - s.AttachHandler(http.MethodGet, BasePathWithIDV1, m.MediaGETHandler) - s.AttachHandler(http.MethodPut, BasePathWithIDV1, m.MediaPUTHandler) - return nil +func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) { + attachHandler(http.MethodPost, BasePath, m.MediaCreatePOSTHandler) + attachHandler(http.MethodGet, AttachmentWithID, m.MediaGETHandler) + attachHandler(http.MethodPut, AttachmentWithID, m.MediaPUTHandler) } diff --git a/internal/api/client/media/mediacreate.go b/internal/api/client/media/mediacreate.go @@ -24,8 +24,8 @@ import ( "net/http" "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/api/model" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" @@ -94,42 +94,42 @@ import ( // '500': // description: internal server error func (m *Module) MediaCreatePOSTHandler(c *gin.Context) { - authed, err := oauth.Authed(c, true, true, true, true) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + apiVersion := c.Param(APIVersionKey) + if apiVersion != APIv1 && apiVersion != APIv2 { + err := errors.New("api version must be one of v1 or v2 for this path") + apiutil.ErrorHandler(c, gtserror.NewErrorNotFound(err, err.Error()), m.processor.InstanceGet) return } - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } - apiVersion := c.Param(APIVersionKey) - if apiVersion != "v1" && apiVersion != "v2" { - err := errors.New("api version must be one of v1 or v2") - api.ErrorHandler(c, gtserror.NewErrorNotFound(err, err.Error()), m.processor.InstanceGet) + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } - form := &model.AttachmentRequest{} + form := &apimodel.AttachmentRequest{} if err := c.ShouldBind(&form); err != nil { - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } if err := validateCreateMedia(form); err != nil { - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } apiAttachment, errWithCode := m.processor.MediaCreate(c.Request.Context(), authed, form) if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } - if apiVersion == "v2" { + if apiVersion == APIv2 { // the mastodon v2 media API specifies that the URL should be null // and that the client should call /api/v1/media/:id to get the URL // @@ -141,7 +141,7 @@ func (m *Module) MediaCreatePOSTHandler(c *gin.Context) { c.JSON(http.StatusOK, apiAttachment) } -func validateCreateMedia(form *model.AttachmentRequest) error { +func validateCreateMedia(form *apimodel.AttachmentRequest) error { // check there actually is a file attached and it's not size 0 if form.File == nil { return errors.New("no attachment given") diff --git a/internal/api/client/media/mediacreate_test.go b/internal/api/client/media/mediacreate_test.go @@ -30,10 +30,9 @@ import ( "net/http/httptest" "testing" - "github.com/gin-gonic/gin" "github.com/stretchr/testify/suite" mediamodule "github.com/superseriousbusiness/gotosocial/internal/api/client/media" - "github.com/superseriousbusiness/gotosocial/internal/api/model" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/concurrency" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" @@ -96,7 +95,7 @@ func (suite *MediaCreateTestSuite) SetupSuite() { suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, suite.mediaManager, clientWorker, fedWorker) // setup module being tested - suite.mediaModule = mediamodule.New(suite.processor).(*mediamodule.Module) + suite.mediaModule = mediamodule.New(suite.processor) } func (suite *MediaCreateTestSuite) TearDownSuite() { @@ -158,12 +157,7 @@ func (suite *MediaCreateTestSuite) TestMediaCreateSuccessful() { ctx.Request = httptest.NewRequest(http.MethodPost, "http://localhost:8080/api/v1/media", bytes.NewReader(buf.Bytes())) // the endpoint we're hitting ctx.Request.Header.Set("Content-Type", w.FormDataContentType()) ctx.Request.Header.Set("accept", "application/json") - ctx.Params = gin.Params{ - gin.Param{ - Key: mediamodule.APIVersionKey, - Value: "v1", - }, - } + ctx.AddParam(mediamodule.APIVersionKey, mediamodule.APIv1) // do the actual request suite.mediaModule.MediaCreatePOSTHandler(ctx) @@ -188,26 +182,26 @@ func (suite *MediaCreateTestSuite) TestMediaCreateSuccessful() { suite.NoError(err) fmt.Println(string(b)) - attachmentReply := &model.Attachment{} + attachmentReply := &apimodel.Attachment{} err = json.Unmarshal(b, attachmentReply) suite.NoError(err) suite.Equal("this is a test image -- a cool background from somewhere", *attachmentReply.Description) suite.Equal("image", attachmentReply.Type) - suite.EqualValues(model.MediaMeta{ - Original: model.MediaDimensions{ + suite.EqualValues(apimodel.MediaMeta{ + Original: apimodel.MediaDimensions{ Width: 1920, Height: 1080, Size: "1920x1080", Aspect: 1.7777778, }, - Small: model.MediaDimensions{ + Small: apimodel.MediaDimensions{ Width: 512, Height: 288, Size: "512x288", Aspect: 1.7777778, }, - Focus: model.MediaFocus{ + Focus: apimodel.MediaFocus{ X: -0.5, Y: 0.5, }, @@ -252,12 +246,7 @@ func (suite *MediaCreateTestSuite) TestMediaCreateSuccessfulV2() { ctx.Request = httptest.NewRequest(http.MethodPost, "http://localhost:8080/api/v2/media", bytes.NewReader(buf.Bytes())) // the endpoint we're hitting ctx.Request.Header.Set("Content-Type", w.FormDataContentType()) ctx.Request.Header.Set("accept", "application/json") - ctx.Params = gin.Params{ - gin.Param{ - Key: mediamodule.APIVersionKey, - Value: "v2", - }, - } + ctx.AddParam(mediamodule.APIVersionKey, mediamodule.APIv2) // do the actual request suite.mediaModule.MediaCreatePOSTHandler(ctx) @@ -282,26 +271,26 @@ func (suite *MediaCreateTestSuite) TestMediaCreateSuccessfulV2() { suite.NoError(err) fmt.Println(string(b)) - attachmentReply := &model.Attachment{} + attachmentReply := &apimodel.Attachment{} err = json.Unmarshal(b, attachmentReply) suite.NoError(err) suite.Equal("this is a test image -- a cool background from somewhere", *attachmentReply.Description) suite.Equal("image", attachmentReply.Type) - suite.EqualValues(model.MediaMeta{ - Original: model.MediaDimensions{ + suite.EqualValues(apimodel.MediaMeta{ + Original: apimodel.MediaDimensions{ Width: 1920, Height: 1080, Size: "1920x1080", Aspect: 1.7777778, }, - Small: model.MediaDimensions{ + Small: apimodel.MediaDimensions{ Width: 512, Height: 288, Size: "512x288", Aspect: 1.7777778, }, - Focus: model.MediaFocus{ + Focus: apimodel.MediaFocus{ X: -0.5, Y: 0.5, }, @@ -342,12 +331,7 @@ func (suite *MediaCreateTestSuite) TestMediaCreateLongDescription() { ctx.Request = httptest.NewRequest(http.MethodPost, "http://localhost:8080/api/v1/media", bytes.NewReader(buf.Bytes())) // the endpoint we're hitting ctx.Request.Header.Set("Content-Type", w.FormDataContentType()) ctx.Request.Header.Set("accept", "application/json") - ctx.Params = gin.Params{ - gin.Param{ - Key: mediamodule.APIVersionKey, - Value: "v1", - }, - } + ctx.AddParam(mediamodule.APIVersionKey, mediamodule.APIv1) // do the actual request suite.mediaModule.MediaCreatePOSTHandler(ctx) @@ -388,12 +372,7 @@ func (suite *MediaCreateTestSuite) TestMediaCreateTooShortDescription() { ctx.Request = httptest.NewRequest(http.MethodPost, "http://localhost:8080/api/v1/media", bytes.NewReader(buf.Bytes())) // the endpoint we're hitting ctx.Request.Header.Set("Content-Type", w.FormDataContentType()) ctx.Request.Header.Set("accept", "application/json") - ctx.Params = gin.Params{ - gin.Param{ - Key: mediamodule.APIVersionKey, - Value: "v1", - }, - } + ctx.AddParam(mediamodule.APIVersionKey, mediamodule.APIv1) // do the actual request suite.mediaModule.MediaCreatePOSTHandler(ctx) diff --git a/internal/api/client/media/mediaget.go b/internal/api/client/media/mediaget.go @@ -23,7 +23,7 @@ import ( "net/http" "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -67,27 +67,33 @@ import ( // '500': // description: internal server error func (m *Module) MediaGETHandler(c *gin.Context) { + if apiVersion := c.Param(APIVersionKey); apiVersion != APIv1 { + err := errors.New("api version must be one v1 for this path") + apiutil.ErrorHandler(c, gtserror.NewErrorNotFound(err, err.Error()), m.processor.InstanceGet) + return + } + authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } attachmentID := c.Param(IDKey) if attachmentID == "" { err := errors.New("no attachment id specified") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } attachment, errWithCode := m.processor.MediaGet(c.Request.Context(), authed, attachmentID) if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/media/mediaupdate.go b/internal/api/client/media/mediaupdate.go @@ -24,8 +24,8 @@ import ( "net/http" "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/api/model" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" @@ -99,45 +99,51 @@ import ( // '500': // description: internal server error func (m *Module) MediaPUTHandler(c *gin.Context) { + if apiVersion := c.Param(APIVersionKey); apiVersion != APIv1 { + err := errors.New("api version must be one v1 for this path") + apiutil.ErrorHandler(c, gtserror.NewErrorNotFound(err, err.Error()), m.processor.InstanceGet) + return + } + authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } attachmentID := c.Param(IDKey) if attachmentID == "" { err := errors.New("no attachment id specified") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } - form := &model.AttachmentUpdateRequest{} + form := &apimodel.AttachmentUpdateRequest{} if err := c.ShouldBind(form); err != nil { - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } if err := validateUpdateMedia(form); err != nil { - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } attachment, errWithCode := m.processor.MediaUpdate(c.Request.Context(), authed, attachmentID, form) if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } c.JSON(http.StatusOK, attachment) } -func validateUpdateMedia(form *model.AttachmentUpdateRequest) error { +func validateUpdateMedia(form *apimodel.AttachmentUpdateRequest) error { minDescriptionChars := config.GetMediaDescriptionMinChars() maxDescriptionChars := config.GetMediaDescriptionMaxChars() diff --git a/internal/api/client/media/mediaupdate_test.go b/internal/api/client/media/mediaupdate_test.go @@ -28,10 +28,9 @@ import ( "net/http/httptest" "testing" - "github.com/gin-gonic/gin" "github.com/stretchr/testify/suite" mediamodule "github.com/superseriousbusiness/gotosocial/internal/api/client/media" - "github.com/superseriousbusiness/gotosocial/internal/api/model" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/concurrency" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" @@ -94,7 +93,7 @@ func (suite *MediaUpdateTestSuite) SetupSuite() { suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, suite.mediaManager, clientWorker, fedWorker) // setup module being tested - suite.mediaModule = mediamodule.New(suite.processor).(*mediamodule.Module) + suite.mediaModule = mediamodule.New(suite.processor) } func (suite *MediaUpdateTestSuite) TearDownSuite() { @@ -148,12 +147,8 @@ func (suite *MediaUpdateTestSuite) TestUpdateImage() { ctx.Request = httptest.NewRequest(http.MethodPut, fmt.Sprintf("http://localhost:8080/api/v1/media/%s", toUpdate.ID), bytes.NewReader(buf.Bytes())) // the endpoint we're hitting ctx.Request.Header.Set("Content-Type", w.FormDataContentType()) ctx.Request.Header.Set("accept", "application/json") - ctx.Params = gin.Params{ - gin.Param{ - Key: mediamodule.IDKey, - Value: toUpdate.ID, - }, - } + ctx.AddParam(mediamodule.APIVersionKey, mediamodule.APIv1) + ctx.AddParam(mediamodule.IDKey, toUpdate.ID) // do the actual request suite.mediaModule.MediaPUTHandler(ctx) @@ -167,17 +162,17 @@ func (suite *MediaUpdateTestSuite) TestUpdateImage() { suite.NoError(err) // reply should be an attachment - attachmentReply := &model.Attachment{} + attachmentReply := &apimodel.Attachment{} err = json.Unmarshal(b, attachmentReply) suite.NoError(err) // the reply should contain the updated fields suite.Equal("new description!", *attachmentReply.Description) suite.EqualValues("image", attachmentReply.Type) - suite.EqualValues(model.MediaMeta{ - Original: model.MediaDimensions{Width: 800, Height: 450, FrameRate: "", Duration: 0, Bitrate: 0, Size: "800x450", Aspect: 1.7777778}, - Small: model.MediaDimensions{Width: 256, Height: 144, FrameRate: "", Duration: 0, Bitrate: 0, Size: "256x144", Aspect: 1.7777778}, - Focus: model.MediaFocus{X: -0.1, Y: 0.3}, + suite.EqualValues(apimodel.MediaMeta{ + Original: apimodel.MediaDimensions{Width: 800, Height: 450, FrameRate: "", Duration: 0, Bitrate: 0, Size: "800x450", Aspect: 1.7777778}, + Small: apimodel.MediaDimensions{Width: 256, Height: 144, FrameRate: "", Duration: 0, Bitrate: 0, Size: "256x144", Aspect: 1.7777778}, + Focus: apimodel.MediaFocus{X: -0.1, Y: 0.3}, }, attachmentReply.Meta) suite.Equal(toUpdate.Blurhash, attachmentReply.Blurhash) suite.Equal(toUpdate.ID, attachmentReply.ID) @@ -213,12 +208,8 @@ func (suite *MediaUpdateTestSuite) TestUpdateImageShortDescription() { ctx.Request = httptest.NewRequest(http.MethodPut, fmt.Sprintf("http://localhost:8080/api/v1/media/%s", toUpdate.ID), bytes.NewReader(buf.Bytes())) // the endpoint we're hitting ctx.Request.Header.Set("Content-Type", w.FormDataContentType()) ctx.Request.Header.Set("accept", "application/json") - ctx.Params = gin.Params{ - gin.Param{ - Key: mediamodule.IDKey, - Value: toUpdate.ID, - }, - } + ctx.AddParam(mediamodule.APIVersionKey, mediamodule.APIv1) + ctx.AddParam(mediamodule.IDKey, toUpdate.ID) // do the actual request suite.mediaModule.MediaPUTHandler(ctx) diff --git a/internal/api/client/notification/notification.go b/internal/api/client/notification/notification.go @@ -1,66 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 notification - -import ( - "net/http" - - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/processing" - "github.com/superseriousbusiness/gotosocial/internal/router" -) - -const ( - // IDKey is for notification UUIDs - IDKey = "id" - // BasePath is the base path for serving the notification API - BasePath = "/api/v1/notifications" - // BasePathWithID is just the base path with the ID key in it. - // Use this anywhere you need to know the ID of the notification being queried. - BasePathWithID = BasePath + "/:" + IDKey - BasePathWithClear = BasePath + "/clear" - - // ExcludeTypes is an array specifying notification types to exclude - ExcludeTypesKey = "exclude_types[]" - // MaxIDKey is the url query for setting a max notification ID to return - MaxIDKey = "max_id" - // LimitKey is for specifying maximum number of notifications to return. - LimitKey = "limit" - // SinceIDKey is for specifying the minimum notification ID to return. - SinceIDKey = "since_id" -) - -// Module implements the ClientAPIModule interface for every related to posting/deleting/interacting with notifications -type Module struct { - processor processing.Processor -} - -// New returns a new notification module -func New(processor processing.Processor) api.ClientModule { - return &Module{ - processor: processor, - } -} - -// Route attaches all routes from this module to the given router -func (m *Module) Route(r router.Router) error { - r.AttachHandler(http.MethodGet, BasePath, m.NotificationsGETHandler) - r.AttachHandler(http.MethodPost, BasePathWithClear, m.NotificationsClearPOSTHandler) - return nil -} diff --git a/internal/api/client/notification/notificationsclear.go b/internal/api/client/notification/notificationsclear.go @@ -1,80 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 notification - -import ( - "net/http" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// NotificationsClearPOSTHandler swagger:operation POST /api/v1/notifications clearNotifications -// -// Clear/delete all notifications for currently authorized user. -// -// Will return an empty object `{}` to indicate success. -// -// --- -// tags: -// - notifications -// -// produces: -// - application/json -// -// security: -// - OAuth2 Bearer: -// - read:notifications -// -// responses: -// '200': -// schema: -// type: object -// '400': -// description: bad request -// '401': -// description: unauthorized -// '404': -// description: not found -// '406': -// description: not acceptable -// '500': -// description: internal server error -func (m *Module) NotificationsClearPOSTHandler(c *gin.Context) { - authed, err := oauth.Authed(c, true, true, true, true) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) - return - } - - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - errWithCode := m.processor.NotificationsClear(c.Request.Context(), authed) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - c.JSON(http.StatusOK, struct{}{}) -} diff --git a/internal/api/client/notification/notificationsget.go b/internal/api/client/notification/notificationsget.go @@ -1,159 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 notification - -import ( - "fmt" - "net/http" - "strconv" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// NotificationsGETHandler swagger:operation GET /api/v1/notifications notifications -// -// Get notifications for currently authorized user. -// -// The notifications will be returned in descending chronological order (newest first), with sequential IDs (bigger = newer). -// -// The next and previous queries can be parsed from the returned Link header. -// Example: -// -// ``` -// <https://example.org/api/v1/notifications?limit=80&max_id=01FC0SKA48HNSVR6YKZCQGS2V8>; rel="next", <https://example.org/api/v1/notifications?limit=80&since_id=01FC0SKW5JK2Q4EVAV2B462YY0>; rel="prev" -// ```` -// -// --- -// tags: -// - notifications -// -// produces: -// - application/json -// -// parameters: -// - -// name: limit -// type: integer -// description: Number of notifications to return. -// default: 20 -// in: query -// required: false -// - -// name: exclude_types -// type: array -// items: -// type: string -// description: Array of types of notifications to exclude (follow, favourite, reblog, mention, poll, follow_request) -// in: query -// required: false -// - -// name: max_id -// type: string -// description: >- -// Return only notifications *OLDER* than the given max status ID. -// The status with the specified ID will not be included in the response. -// in: query -// required: false -// - -// name: since_id -// type: string -// description: |- -// Return only notifications *NEWER* than the given since status ID. -// The status with the specified ID will not be included in the response. -// in: query -// required: false -// -// security: -// - OAuth2 Bearer: -// - read:notifications -// -// responses: -// '200': -// headers: -// Link: -// type: string -// description: Links to the next and previous queries. -// name: notifications -// description: Array of notifications. -// schema: -// type: array -// items: -// "$ref": "#/definitions/notification" -// '400': -// description: bad request -// '401': -// description: unauthorized -// '404': -// description: not found -// '406': -// description: not acceptable -// '500': -// description: internal server error -func (m *Module) NotificationsGETHandler(c *gin.Context) { - authed, err := oauth.Authed(c, true, true, true, true) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) - return - } - - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - limit := 20 - limitString := c.Query(LimitKey) - if limitString != "" { - i, err := strconv.ParseInt(limitString, 10, 32) - if err != nil { - err := fmt.Errorf("error parsing %s: %s", LimitKey, err) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - limit = int(i) - } - - maxID := "" - maxIDString := c.Query(MaxIDKey) - if maxIDString != "" { - maxID = maxIDString - } - - sinceID := "" - sinceIDString := c.Query(SinceIDKey) - if sinceIDString != "" { - sinceID = sinceIDString - } - - excludeTypes := c.QueryArray(ExcludeTypesKey) - - resp, errWithCode := m.processor.NotificationsGet(c.Request.Context(), authed, excludeTypes, limit, maxID, sinceID) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - if resp.LinkHeader != "" { - c.Header("Link", resp.LinkHeader) - } - c.JSON(http.StatusOK, resp.Items) -} diff --git a/internal/api/client/notifications/notifications.go b/internal/api/client/notifications/notifications.go @@ -0,0 +1,61 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 notifications + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/processing" +) + +const ( + // IDKey is for notification UUIDs + IDKey = "id" + // BasePath is the base path for serving the notification API, minus the 'api' prefix. + BasePath = "/v1/notifications" + // BasePathWithID is just the base path with the ID key in it. + // Use this anywhere you need to know the ID of the notification being queried. + BasePathWithID = BasePath + "/:" + IDKey + BasePathWithClear = BasePath + "/clear" + + // ExcludeTypes is an array specifying notification types to exclude + ExcludeTypesKey = "exclude_types[]" + // MaxIDKey is the url query for setting a max notification ID to return + MaxIDKey = "max_id" + // LimitKey is for specifying maximum number of notifications to return. + LimitKey = "limit" + // SinceIDKey is for specifying the minimum notification ID to return. + SinceIDKey = "since_id" +) + +type Module struct { + processor processing.Processor +} + +func New(processor processing.Processor) *Module { + return &Module{ + processor: processor, + } +} + +func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) { + attachHandler(http.MethodGet, BasePath, m.NotificationsGETHandler) + attachHandler(http.MethodPost, BasePathWithClear, m.NotificationsClearPOSTHandler) +} diff --git a/internal/api/client/notifications/notificationsclear.go b/internal/api/client/notifications/notificationsclear.go @@ -0,0 +1,80 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 notifications + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// NotificationsClearPOSTHandler swagger:operation POST /api/v1/notifications clearNotifications +// +// Clear/delete all notifications for currently authorized user. +// +// Will return an empty object `{}` to indicate success. +// +// --- +// tags: +// - notifications +// +// produces: +// - application/json +// +// security: +// - OAuth2 Bearer: +// - read:notifications +// +// responses: +// '200': +// schema: +// type: object +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) NotificationsClearPOSTHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + errWithCode := m.processor.NotificationsClear(c.Request.Context(), authed) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + c.JSON(http.StatusOK, struct{}{}) +} diff --git a/internal/api/client/notifications/notificationsget.go b/internal/api/client/notifications/notificationsget.go @@ -0,0 +1,159 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 notifications + +import ( + "fmt" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// NotificationsGETHandler swagger:operation GET /api/v1/notifications notifications +// +// Get notifications for currently authorized user. +// +// The notifications will be returned in descending chronological order (newest first), with sequential IDs (bigger = newer). +// +// The next and previous queries can be parsed from the returned Link header. +// Example: +// +// ``` +// <https://example.org/api/v1/notifications?limit=80&max_id=01FC0SKA48HNSVR6YKZCQGS2V8>; rel="next", <https://example.org/api/v1/notifications?limit=80&since_id=01FC0SKW5JK2Q4EVAV2B462YY0>; rel="prev" +// ```` +// +// --- +// tags: +// - notifications +// +// produces: +// - application/json +// +// parameters: +// - +// name: limit +// type: integer +// description: Number of notifications to return. +// default: 20 +// in: query +// required: false +// - +// name: exclude_types +// type: array +// items: +// type: string +// description: Array of types of notifications to exclude (follow, favourite, reblog, mention, poll, follow_request) +// in: query +// required: false +// - +// name: max_id +// type: string +// description: >- +// Return only notifications *OLDER* than the given max status ID. +// The status with the specified ID will not be included in the response. +// in: query +// required: false +// - +// name: since_id +// type: string +// description: |- +// Return only notifications *NEWER* than the given since status ID. +// The status with the specified ID will not be included in the response. +// in: query +// required: false +// +// security: +// - OAuth2 Bearer: +// - read:notifications +// +// responses: +// '200': +// headers: +// Link: +// type: string +// description: Links to the next and previous queries. +// name: notifications +// description: Array of notifications. +// schema: +// type: array +// items: +// "$ref": "#/definitions/notification" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) NotificationsGETHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + limit := 20 + limitString := c.Query(LimitKey) + if limitString != "" { + i, err := strconv.ParseInt(limitString, 10, 32) + if err != nil { + err := fmt.Errorf("error parsing %s: %s", LimitKey, err) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + limit = int(i) + } + + maxID := "" + maxIDString := c.Query(MaxIDKey) + if maxIDString != "" { + maxID = maxIDString + } + + sinceID := "" + sinceIDString := c.Query(SinceIDKey) + if sinceIDString != "" { + sinceID = sinceIDString + } + + excludeTypes := c.QueryArray(ExcludeTypesKey) + + resp, errWithCode := m.processor.NotificationsGet(c.Request.Context(), authed, excludeTypes, limit, maxID, sinceID) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + if resp.LinkHeader != "" { + c.Header("Link", resp.LinkHeader) + } + c.JSON(http.StatusOK, resp.Items) +} diff --git a/internal/api/client/search/search.go b/internal/api/client/search/search.go @@ -21,17 +21,16 @@ package search import ( "net/http" - "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/processing" - "github.com/superseriousbusiness/gotosocial/internal/router" ) const ( - // BasePathV1 is the base path for serving v1 of the search API - BasePathV1 = "/api/v1/search" + // BasePathV1 is the base path for serving v1 of the search API, minus the 'api' prefix + BasePathV1 = "/v1/search" - // BasePathV2 is the base path for serving v2 of the search API - BasePathV2 = "/api/v2/search" + // BasePathV2 is the base path for serving v2 of the search API, minus the 'api' prefix + BasePathV2 = "/v2/search" // AccountIDKey -- If provided, statuses returned will be authored only by this account AccountIDKey = "account_id" @@ -62,21 +61,17 @@ const ( TypeStatuses = "statuses" ) -// Module implements the ClientAPIModule interface for everything related to searching type Module struct { processor processing.Processor } -// New returns a new search module -func New(processor processing.Processor) api.ClientModule { +func New(processor processing.Processor) *Module { return &Module{ processor: processor, } } -// Route attaches all routes from this module to the given router -func (m *Module) Route(r router.Router) error { - r.AttachHandler(http.MethodGet, BasePathV1, m.SearchGETHandler) - r.AttachHandler(http.MethodGet, BasePathV2, m.SearchGETHandler) - return nil +func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) { + attachHandler(http.MethodGet, BasePathV1, m.SearchGETHandler) + attachHandler(http.MethodGet, BasePathV2, m.SearchGETHandler) } diff --git a/internal/api/client/search/search_test.go b/internal/api/client/search/search_test.go @@ -84,7 +84,7 @@ func (suite *SearchStandardTestSuite) SetupTest() { suite.sentEmails = make(map[string]string) suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails) suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, suite.mediaManager, clientWorker, fedWorker) - suite.searchModule = search.New(suite.processor).(*search.Module) + suite.searchModule = search.New(suite.processor) testrig.StandardDBSetup(suite.db, nil) testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") diff --git a/internal/api/client/search/searchget.go b/internal/api/client/search/searchget.go @@ -25,8 +25,8 @@ import ( "strconv" "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/api/model" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -66,12 +66,12 @@ import ( func (m *Module) SearchGETHandler(c *gin.Context) { authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } @@ -82,7 +82,7 @@ func (m *Module) SearchGETHandler(c *gin.Context) { excludeUnreviewed, err = strconv.ParseBool(excludeUnreviewedString) if err != nil { err := fmt.Errorf("error parsing %s: %s", ExcludeUnreviewedKey, err) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } } @@ -90,7 +90,7 @@ func (m *Module) SearchGETHandler(c *gin.Context) { query := c.Query(QueryKey) if query == "" { err := errors.New("query parameter q was empty") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } @@ -101,7 +101,7 @@ func (m *Module) SearchGETHandler(c *gin.Context) { resolve, err = strconv.ParseBool(resolveString) if err != nil { err := fmt.Errorf("error parsing %s: %s", ResolveKey, err) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } } @@ -112,7 +112,7 @@ func (m *Module) SearchGETHandler(c *gin.Context) { i, err := strconv.ParseInt(limitString, 10, 32) if err != nil { err := fmt.Errorf("error parsing %s: %s", LimitKey, err) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } limit = int(i) @@ -130,7 +130,7 @@ func (m *Module) SearchGETHandler(c *gin.Context) { i, err := strconv.ParseInt(offsetString, 10, 32) if err != nil { err := fmt.Errorf("error parsing %s: %s", OffsetKey, err) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } offset = int(i) @@ -143,12 +143,12 @@ func (m *Module) SearchGETHandler(c *gin.Context) { following, err = strconv.ParseBool(followingString) if err != nil { err := fmt.Errorf("error parsing %s: %s", FollowingKey, err) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } } - searchQuery := &model.SearchQuery{ + searchQuery := &apimodel.SearchQuery{ AccountID: c.Query(AccountIDKey), MaxID: c.Query(MaxIDKey), MinID: c.Query(MinIDKey), @@ -163,7 +163,7 @@ func (m *Module) SearchGETHandler(c *gin.Context) { results, errWithCode := m.processor.SearchGet(c.Request.Context(), authed, searchQuery) if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/status/status.go b/internal/api/client/status/status.go @@ -1,123 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/log" - "github.com/superseriousbusiness/gotosocial/internal/processing" - "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 { - processor processing.Processor -} - -// New returns a new account module -func New(processor processing.Processor) api.ClientModule { - return &Module{ - processor: processor, - } -} - -// 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, FavouritedPath, m.StatusFavedByGETHandler) - - r.AttachHandler(http.MethodPost, ReblogPath, m.StatusBoostPOSTHandler) - r.AttachHandler(http.MethodPost, UnreblogPath, m.StatusUnboostPOSTHandler) - r.AttachHandler(http.MethodGet, RebloggedPath, m.StatusBoostedByGETHandler) - - r.AttachHandler(http.MethodPost, BookmarkPath, m.StatusBookmarkPOSTHandler) - r.AttachHandler(http.MethodPost, UnbookmarkPath, m.StatusUnbookmarkPOSTHandler) - - r.AttachHandler(http.MethodGet, ContextPath, m.StatusContextGETHandler) - - 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) { - log.Debug("entering mux handler") - ru := c.Request.RequestURI - - if c.Request.Method == http.MethodGet { - switch { - case strings.HasPrefix(ru, ContextPath): - // TODO - case strings.HasPrefix(ru, FavouritedPath): - m.StatusFavedByGETHandler(c) - default: - m.StatusGETHandler(c) - } - } -} diff --git a/internal/api/client/status/status_test.go b/internal/api/client/status/status_test.go @@ -1,98 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/api/client/status" - "github.com/superseriousbusiness/gotosocial/internal/concurrency" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/email" - "github.com/superseriousbusiness/gotosocial/internal/federation" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/messages" - "github.com/superseriousbusiness/gotosocial/internal/processing" - "github.com/superseriousbusiness/gotosocial/internal/storage" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type StatusStandardTestSuite struct { - // standard suite interfaces - suite.Suite - db db.DB - tc typeutils.TypeConverter - mediaManager media.Manager - federator federation.Federator - emailSender email.Sender - processor processing.Processor - storage *storage.Driver - - // standard suite models - testTokens map[string]*gtsmodel.Token - testClients map[string]*gtsmodel.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 - testFollows map[string]*gtsmodel.Follow - - // module being tested - statusModule *status.Module -} - -func (suite *StatusStandardTestSuite) 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() - suite.testFollows = testrig.NewTestFollows() -} - -func (suite *StatusStandardTestSuite) SetupTest() { - testrig.InitTestConfig() - testrig.InitTestLog() - - suite.db = testrig.NewTestDB() - suite.tc = testrig.NewTestTypeConverter(suite.db) - suite.storage = testrig.NewInMemoryStorage() - testrig.StandardDBSetup(suite.db, nil) - testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") - - fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) - clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) - - suite.mediaManager = testrig.NewTestMediaManager(suite.db, suite.storage) - suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker), suite.storage, suite.mediaManager, fedWorker) - suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil) - suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, suite.mediaManager, clientWorker, fedWorker) - suite.statusModule = status.New(suite.processor).(*status.Module) - - suite.NoError(suite.processor.Start()) -} - -func (suite *StatusStandardTestSuite) TearDownTest() { - testrig.StandardDBTeardown(suite.db) - testrig.StandardStorageTeardown(suite.storage) -} diff --git a/internal/api/client/status/statusbookmark.go b/internal/api/client/status/statusbookmark.go @@ -1,98 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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" - "net/http" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// StatusBookmarkPOSTHandler swagger:operation POST /api/v1/statuses/{id}/bookmark statusBookmark -// -// Bookmark status with the given ID. -// -// --- -// tags: -// - statuses -// -// produces: -// - application/json -// -// parameters: -// - -// name: id -// type: string -// description: Target status ID. -// in: path -// required: true -// -// security: -// - OAuth2 Bearer: -// - write:statuses -// -// responses: -// '200': -// name: status -// description: The status. -// schema: -// "$ref": "#/definitions/status" -// '400': -// description: bad request -// '401': -// description: unauthorized -// '403': -// description: forbidden -// '404': -// description: not found -// '406': -// description: not acceptable -// '500': -// description: internal server error -func (m *Module) StatusBookmarkPOSTHandler(c *gin.Context) { - authed, err := oauth.Authed(c, true, true, true, true) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) - return - } - - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - targetStatusID := c.Param(IDKey) - if targetStatusID == "" { - err := errors.New("no status id specified") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - apiStatus, errWithCode := m.processor.StatusBookmark(c.Request.Context(), authed, targetStatusID) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - c.JSON(http.StatusOK, apiStatus) -} diff --git a/internal/api/client/status/statusbookmark_test.go b/internal/api/client/status/statusbookmark_test.go @@ -1,83 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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/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 StatusBookmarkTestSuite struct { - StatusStandardTestSuite -} - -func (suite *StatusBookmarkTestSuite) TestPostBookmark() { - t := suite.testTokens["local_account_1"] - oauthToken := oauth.DBTokenToToken(t) - - targetStatus := suite.testStatuses["admin_account_status_1"] - - // setup - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - 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.BookmarkPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting - ctx.Request.Header.Set("accept", "application/json") - - // 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.StatusBookmarkPOSTHandler(ctx) - - // check response - suite.EqualValues(http.StatusOK, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - suite.NoError(err) - - statusReply := &model.Status{} - err = json.Unmarshal(b, statusReply) - suite.NoError(err) - - suite.True(statusReply.Bookmarked) -} - -func TestStatusBookmarkTestSuite(t *testing.T) { - suite.Run(t, new(StatusBookmarkTestSuite)) -} diff --git a/internal/api/client/status/statusboost.go b/internal/api/client/status/statusboost.go @@ -1,101 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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" - "net/http" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// StatusBoostPOSTHandler swagger:operation POST /api/v1/statuses/{id}/reblog statusReblog -// -// Reblog/boost status with the given ID. -// -// If the target status is rebloggable/boostable, it will be shared with your followers. -// This is equivalent to an ActivityPub 'Announce' activity. -// -// --- -// tags: -// - statuses -// -// produces: -// - application/json -// -// parameters: -// - -// name: id -// type: string -// description: Target status ID. -// in: path -// required: true -// -// security: -// - OAuth2 Bearer: -// - write:statuses -// -// responses: -// '200': -// name: status -// description: The boost of the status. -// schema: -// "$ref": "#/definitions/status" -// '400': -// description: bad request -// '401': -// description: unauthorized -// '403': -// description: forbidden -// '404': -// description: not found -// '406': -// description: not acceptable -// '500': -// description: internal server error -func (m *Module) StatusBoostPOSTHandler(c *gin.Context) { - authed, err := oauth.Authed(c, true, true, true, true) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) - return - } - - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - targetStatusID := c.Param(IDKey) - if targetStatusID == "" { - err := errors.New("no status id specified") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - apiStatus, errWithCode := m.processor.StatusBoost(c.Request.Context(), authed, targetStatusID) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - c.JSON(http.StatusOK, apiStatus) -} diff --git a/internal/api/client/status/statusboost_test.go b/internal/api/client/status/statusboost_test.go @@ -1,247 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 ( - "context" - "encoding/json" - "fmt" - "io/ioutil" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/gin-gonic/gin" - "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 StatusBoostTestSuite struct { - StatusStandardTestSuite -} - -func (suite *StatusBoostTestSuite) TestPostBoost() { - t := suite.testTokens["local_account_1"] - oauthToken := oauth.DBTokenToToken(t) - - targetStatus := suite.testStatuses["admin_account_status_1"] - - // setup - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - 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.ReblogPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting - ctx.Request.Header.Set("accept", "application/json") - - // 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.StatusBoostPOSTHandler(ctx) - - // check response - suite.EqualValues(http.StatusOK, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - suite.NoError(err) - - statusReply := &model.Status{} - err = json.Unmarshal(b, statusReply) - suite.NoError(err) - - suite.False(statusReply.Sensitive) - suite.Equal(model.VisibilityPublic, statusReply.Visibility) - - suite.Equal(targetStatus.ContentWarning, statusReply.SpoilerText) - suite.Equal(targetStatus.Content, statusReply.Content) - suite.Equal("the_mighty_zork", statusReply.Account.Username) - suite.Len(statusReply.MediaAttachments, 0) - suite.Len(statusReply.Mentions, 0) - suite.Len(statusReply.Emojis, 0) - suite.Len(statusReply.Tags, 0) - - suite.NotNil(statusReply.Application) - suite.Equal("really cool gts application", statusReply.Application.Name) - - suite.NotNil(statusReply.Reblog) - suite.Equal(1, statusReply.Reblog.ReblogsCount) - suite.Equal(1, statusReply.Reblog.FavouritesCount) - suite.Equal(targetStatus.Content, statusReply.Reblog.Content) - suite.Equal(targetStatus.ContentWarning, statusReply.Reblog.SpoilerText) - suite.Equal(targetStatus.AccountID, statusReply.Reblog.Account.ID) - suite.Len(statusReply.Reblog.MediaAttachments, 1) - suite.Len(statusReply.Reblog.Tags, 1) - suite.Len(statusReply.Reblog.Emojis, 1) - suite.Equal("superseriousbusiness", statusReply.Reblog.Application.Name) -} - -func (suite *StatusBoostTestSuite) TestPostBoostOwnFollowersOnly() { - t := suite.testTokens["local_account_1"] - oauthToken := oauth.DBTokenToToken(t) - - testStatus := suite.testStatuses["local_account_1_status_5"] - testAccount := suite.testAccounts["local_account_1"] - testUser := suite.testUsers["local_account_1"] - - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) - ctx.Set(oauth.SessionAuthorizedToken, oauthToken) - ctx.Set(oauth.SessionAuthorizedUser, testUser) - ctx.Set(oauth.SessionAuthorizedAccount, testAccount) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.ReblogPath, ":id", testStatus.ID, 1)), nil) - ctx.Request.Header.Set("accept", "application/json") - - ctx.Params = gin.Params{ - gin.Param{ - Key: status.IDKey, - Value: testStatus.ID, - }, - } - - suite.statusModule.StatusBoostPOSTHandler(ctx) - - // check response - suite.EqualValues(http.StatusOK, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - suite.NoError(err) - - responseStatus := &model.Status{} - err = json.Unmarshal(b, responseStatus) - suite.NoError(err) - - suite.False(responseStatus.Sensitive) - suite.Equal(suite.tc.VisToAPIVis(context.Background(), testStatus.Visibility), responseStatus.Visibility) - - suite.Equal(testStatus.ContentWarning, responseStatus.SpoilerText) - suite.Equal(testStatus.Content, responseStatus.Content) - suite.Equal("the_mighty_zork", responseStatus.Account.Username) - suite.Len(responseStatus.MediaAttachments, 0) - suite.Len(responseStatus.Mentions, 0) - suite.Len(responseStatus.Emojis, 0) - suite.Len(responseStatus.Tags, 0) - - suite.NotNil(responseStatus.Application) - suite.Equal("really cool gts application", responseStatus.Application.Name) - - suite.NotNil(responseStatus.Reblog) - suite.Equal(1, responseStatus.Reblog.ReblogsCount) - suite.Equal(0, responseStatus.Reblog.FavouritesCount) - suite.Equal(testStatus.Content, responseStatus.Reblog.Content) - suite.Equal(testStatus.ContentWarning, responseStatus.Reblog.SpoilerText) - suite.Equal(testStatus.AccountID, responseStatus.Reblog.Account.ID) - suite.Equal(suite.tc.VisToAPIVis(context.Background(), testStatus.Visibility), responseStatus.Reblog.Visibility) - suite.Empty(responseStatus.Reblog.MediaAttachments) - suite.Empty(responseStatus.Reblog.Tags) - suite.Empty(responseStatus.Reblog.Emojis) - suite.Equal("really cool gts application", responseStatus.Reblog.Application.Name) -} - -// try to boost a status that's not boostable -func (suite *StatusBoostTestSuite) TestPostUnboostable() { - t := suite.testTokens["local_account_1"] - oauthToken := oauth.DBTokenToToken(t) - - targetStatus := suite.testStatuses["local_account_2_status_4"] - - // setup - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - 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.ReblogPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting - ctx.Request.Header.Set("accept", "application/json") - - // 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.StatusBoostPOSTHandler(ctx) - - // check response - suite.Equal(http.StatusForbidden, recorder.Code) // we 403 unboostable statuses - - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - suite.NoError(err) - suite.Equal(`{"error":"Forbidden"}`, string(b)) -} - -// try to boost a status that's not visible to the user -func (suite *StatusBoostTestSuite) TestPostNotVisible() { - // stop local_account_2 following zork - err := suite.db.DeleteByID(context.Background(), suite.testFollows["local_account_2_local_account_1"].ID, &gtsmodel.Follow{}) - suite.NoError(err) - - t := suite.testTokens["local_account_2"] - oauthToken := oauth.DBTokenToToken(t) - - targetStatus := suite.testStatuses["local_account_1_status_3"] // this is a mutual only status and these accounts aren't mutuals - - // setup - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) - 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.ReblogPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting - ctx.Request.Header.Set("accept", "application/json") - - // 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.StatusBoostPOSTHandler(ctx) - - // check response - suite.Equal(http.StatusNotFound, recorder.Code) // we 404 statuses that aren't visible -} - -func TestStatusBoostTestSuite(t *testing.T) { - suite.Run(t, new(StatusBoostTestSuite)) -} diff --git a/internal/api/client/status/statusboostedby.go b/internal/api/client/status/statusboostedby.go @@ -1,89 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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" - "net/http" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// StatusBoostedByGETHandler swagger:operation GET /api/v1/statuses/{id}/reblogged_by statusBoostedBy -// -// View accounts that have reblogged/boosted the target status. -// -// --- -// tags: -// - statuses -// -// produces: -// - application/json -// -// parameters: -// - -// name: id -// type: string -// description: Target status ID. -// in: path -// required: true -// -// security: -// - OAuth2 Bearer: -// - read:accounts -// -// responses: -// '200': -// schema: -// type: array -// items: -// "$ref": "#/definitions/account" -// '400': -// description: bad request -// '401': -// description: unauthorized -// '403': -// description: forbidden -// '404': -// description: not found -func (m *Module) StatusBoostedByGETHandler(c *gin.Context) { - authed, err := oauth.Authed(c, true, true, true, true) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) - return - } - - targetStatusID := c.Param(IDKey) - if targetStatusID == "" { - err := errors.New("no status id specified") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - apiAccounts, errWithCode := m.processor.StatusBoostedBy(c.Request.Context(), authed, targetStatusID) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - c.JSON(http.StatusOK, apiAccounts) -} diff --git a/internal/api/client/status/statusboostedby_test.go b/internal/api/client/status/statusboostedby_test.go @@ -1,112 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/api/client/status" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type StatusBoostedByTestSuite struct { - StatusStandardTestSuite -} - -func (suite *StatusBoostedByTestSuite) TestRebloggedByOK() { - t := suite.testTokens["local_account_1"] - oauthToken := oauth.DBTokenToToken(t) - targetStatus := suite.testStatuses["local_account_1_status_1"] - - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - 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.MethodGet, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.RebloggedPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting - ctx.Request.Header.Set("accept", "application/json") - ctx.AddParam("id", targetStatus.ID) - - suite.statusModule.StatusBoostedByGETHandler(ctx) - - suite.EqualValues(http.StatusOK, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - - b, err := ioutil.ReadAll(result.Body) - suite.NoError(err) - - accounts := []*gtsmodel.Account{} - err = json.Unmarshal(b, &accounts) - suite.NoError(err) - - if !suite.Len(accounts, 1) { - suite.FailNow("should have had 1 account") - } - - suite.Equal(accounts[0].ID, suite.testAccounts["admin_account"].ID) -} - -func (suite *StatusBoostedByTestSuite) TestRebloggedByUseBoostWrapperID() { - t := suite.testTokens["local_account_1"] - oauthToken := oauth.DBTokenToToken(t) - targetStatus := suite.testStatuses["admin_account_status_4"] // admin_account_status_4 is a boost of local_account_1_status_1 - - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - 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.MethodGet, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.RebloggedPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting - ctx.Request.Header.Set("accept", "application/json") - ctx.AddParam("id", targetStatus.ID) - - suite.statusModule.StatusBoostedByGETHandler(ctx) - - suite.EqualValues(http.StatusOK, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - - b, err := ioutil.ReadAll(result.Body) - suite.NoError(err) - - accounts := []*gtsmodel.Account{} - err = json.Unmarshal(b, &accounts) - suite.NoError(err) - - if !suite.Len(accounts, 1) { - suite.FailNow("should have had 1 account") - } - - suite.Equal(accounts[0].ID, suite.testAccounts["admin_account"].ID) -} - -func TestStatusBoostedByTestSuite(t *testing.T) { - suite.Run(t, new(StatusBoostedByTestSuite)) -} diff --git a/internal/api/client/status/statuscontext.go b/internal/api/client/status/statuscontext.go @@ -1,100 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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" - "net/http" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// StatusContextGETHandler swagger:operation GET /api/v1/statuses/{id}/context statusContext -// -// Return ancestors and descendants of the given status. -// -// The returned statuses will be ordered in a thread structure, so they are suitable to be displayed in the order in which they were returned. -// -// --- -// tags: -// - statuses -// -// produces: -// - application/json -// -// parameters: -// - -// name: id -// type: string -// description: Target status ID. -// in: path -// required: true -// -// security: -// - OAuth2 Bearer: -// - read:statuses -// -// responses: -// '200': -// name: statuses -// description: Status context object. -// schema: -// "$ref": "#/definitions/statusContext" -// '400': -// description: bad request -// '401': -// description: unauthorized -// '403': -// description: forbidden -// '404': -// description: not found -// '406': -// description: not acceptable -// '500': -// description: internal server error -func (m *Module) StatusContextGETHandler(c *gin.Context) { - authed, err := oauth.Authed(c, true, true, true, true) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) - return - } - - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - targetStatusID := c.Param(IDKey) - if targetStatusID == "" { - err := errors.New("no status id specified") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - statusContext, errWithCode := m.processor.StatusGetContext(c.Request.Context(), authed, targetStatusID) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - c.JSON(http.StatusOK, statusContext) -} diff --git a/internal/api/client/status/statuscreate.go b/internal/api/client/status/statuscreate.go @@ -1,172 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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" - "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/validate" -) - -// StatusCreatePOSTHandler swagger:operation POST /api/v1/statuses statusCreate -// -// Create a new status. -// -// The parameters can also be given in the body of the request, as JSON, if the content-type is set to 'application/json'. -// The parameters can also be given in the body of the request, as XML, if the content-type is set to 'application/xml'. -// -// --- -// tags: -// - statuses -// -// consumes: -// - application/json -// - application/xml -// - application/x-www-form-urlencoded -// -// produces: -// - application/json -// -// security: -// - OAuth2 Bearer: -// - write:statuses -// -// responses: -// '200': -// description: "The newly created status." -// schema: -// "$ref": "#/definitions/status" -// '400': -// description: bad request -// '401': -// description: unauthorized -// '403': -// description: forbidden -// '404': -// description: not found -// '406': -// description: not acceptable -// '500': -// description: internal server error -func (m *Module) StatusCreatePOSTHandler(c *gin.Context) { - authed, err := oauth.Authed(c, true, true, true, true) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) - return - } - - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - form := &model.AdvancedStatusCreateForm{} - if err := c.ShouldBind(form); err != nil { - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - // DO NOT COMMIT THIS UNCOMMENTED, IT WILL CAUSE MASS CHAOS. - // this is being left in as an ode to kim's shitposting. - // - // user := authed.Account.DisplayName - // if user == "" { - // user = authed.Account.Username - // } - // form.Status += "\n\nsent from " + user + "'s iphone\n" - - if err := validateCreateStatus(form); err != nil { - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - apiStatus, errWithCode := m.processor.StatusCreate(c.Request.Context(), authed, form) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - c.JSON(http.StatusOK, apiStatus) -} - -func validateCreateStatus(form *model.AdvancedStatusCreateForm) error { - hasStatus := form.Status != "" - hasMedia := len(form.MediaIDs) != 0 - hasPoll := form.Poll != nil - - if !hasStatus && !hasMedia && !hasPoll { - return errors.New("no status, media, or poll provided") - } - - if hasMedia && hasPoll { - return errors.New("can't post media + poll in same status") - } - - maxChars := config.GetStatusesMaxChars() - maxMediaFiles := config.GetStatusesMediaMaxFiles() - maxPollOptions := config.GetStatusesPollMaxOptions() - maxPollChars := config.GetStatusesPollOptionMaxChars() - maxCwChars := config.GetStatusesCWMaxChars() - - if form.Status != "" { - if length := len([]rune(form.Status)); length > maxChars { - return fmt.Errorf("status too long, %d characters provided but limit is %d", length, maxChars) - } - } - - if len(form.MediaIDs) > maxMediaFiles { - return fmt.Errorf("too many media files attached to status, %d attached but limit is %d", len(form.MediaIDs), maxMediaFiles) - } - - if form.Poll != nil { - if form.Poll.Options == nil { - return errors.New("poll with no options") - } - if len(form.Poll.Options) > maxPollOptions { - return fmt.Errorf("too many poll options provided, %d provided but limit is %d", len(form.Poll.Options), maxPollOptions) - } - for _, p := range form.Poll.Options { - if length := len([]rune(p)); length > maxPollChars { - return fmt.Errorf("poll option too long, %d characters provided but limit is %d", length, maxPollChars) - } - } - } - - if form.SpoilerText != "" { - if length := len([]rune(form.SpoilerText)); length > maxCwChars { - return fmt.Errorf("content-warning/spoilertext too long, %d characters provided but limit is %d", length, maxCwChars) - } - } - - if form.Language != "" { - if err := validate.Language(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 @@ -1,398 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 ( - "context" - "encoding/json" - "fmt" - "io/ioutil" - "net/http" - "net/http/httptest" - "net/url" - "testing" - - "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/db" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type StatusCreateTestSuite struct { - StatusStandardTestSuite -} - -const ( - statusWithLinksAndTags = "#test alright, should be able to post #links with fragments in them now, let's see........\n\nhttps://docs.gotosocial.org/en/latest/user_guide/posts/#links\n\n#gotosocial\n\n(tobi remember to pull the docker image challenge)" - statusMarkdown = "# Title\n\n## Smaller title\n\nThis is a post written in [markdown](https://www.markdownguide.org/)\n\n<img src=\"https://d33wubrfki0l68.cloudfront.net/f1f475a6fda1c2c4be4cac04033db5c3293032b4/513a4/assets/images/markdown-mark-white.svg\"/>" - statusMarkdownExpected = "<h1>Title</h1><h2>Smaller title</h2><p>This is a post written in <a href=\"https://www.markdownguide.org/\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">markdown</a></p><img src=\"https://d33wubrfki0l68.cloudfront.net/f1f475a6fda1c2c4be4cac04033db5c3293032b4/513a4/assets/images/markdown-mark-white.svg\" crossorigin=\"anonymous\">" -) - -// Post a new status with some custom visibility settings -func (suite *StatusCreateTestSuite) TestPostNewStatus() { - t := suite.testTokens["local_account_1"] - oauthToken := oauth.DBTokenToToken(t) - - // setup - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - 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.Header.Set("accept", "application/json") - ctx.Request.Form = url.Values{ - "status": {"this is a brand new status! #helloworld"}, - "spoiler_text": {"hello hello"}, - "sensitive": {"true"}, - "visibility": {string(model.VisibilityMutualsOnly)}, - "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) - suite.NoError(err) - - statusReply := &model.Status{} - err = json.Unmarshal(b, statusReply) - suite.NoError(err) - - suite.Equal("hello hello", statusReply.SpoilerText) - suite.Equal("<p>this is a brand new status! <a href=\"http://localhost:8080/tags/helloworld\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>helloworld</span></a></p>", statusReply.Content) - suite.True(statusReply.Sensitive) - suite.Equal(model.VisibilityPrivate, statusReply.Visibility) // even though we set this status to mutuals only, it should serialize to private, because the mastodon api has no idea about mutuals_only - suite.Len(statusReply.Tags, 1) - suite.Equal(model.Tag{ - Name: "helloworld", - URL: "http://localhost:8080/tags/helloworld", - }, statusReply.Tags[0]) - - gtsTag := &gtsmodel.Tag{} - err = suite.db.GetWhere(context.Background(), []db.Where{{Key: "name", Value: "helloworld"}}, gtsTag) - suite.NoError(err) - suite.Equal(statusReply.Account.ID, gtsTag.FirstSeenFromAccountID) -} - -func (suite *StatusCreateTestSuite) TestPostNewStatusMarkdown() { - // set default post language of account 1 to markdown - testAccount := suite.testAccounts["local_account_1"] - testAccount.StatusFormat = "markdown" - a := testAccount - - err := suite.db.UpdateAccount(context.Background(), a) - if err != nil { - suite.FailNow(err.Error()) - } - suite.Equal(a.StatusFormat, "markdown") - - t := suite.testTokens["local_account_1"] - oauthToken := oauth.DBTokenToToken(t) - - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - 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, a) - - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) - ctx.Request.Header.Set("accept", "application/json") - ctx.Request.Form = url.Values{ - "status": {statusMarkdown}, - "visibility": {string(model.VisibilityPublic)}, - } - suite.statusModule.StatusCreatePOSTHandler(ctx) - - suite.EqualValues(http.StatusOK, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - suite.NoError(err) - - statusReply := &model.Status{} - err = json.Unmarshal(b, statusReply) - suite.NoError(err) - - suite.Equal(statusMarkdownExpected, statusReply.Content) -} - -// mention an account that is not yet known to the instance -- it should be looked up and put in the db -func (suite *StatusCreateTestSuite) TestMentionUnknownAccount() { - // first remove remote account 1 from the database so it gets looked up again - remoteAccount := suite.testAccounts["remote_account_1"] - err := suite.db.DeleteAccount(context.Background(), remoteAccount.ID) - suite.NoError(err) - - t := suite.testTokens["local_account_1"] - oauthToken := oauth.DBTokenToToken(t) - - // setup - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - 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.Header.Set("accept", "application/json") - ctx.Request.Form = url.Values{ - "status": {"hello @brand_new_person@unknown-instance.com"}, - "visibility": {string(model.VisibilityPublic)}, - } - suite.statusModule.StatusCreatePOSTHandler(ctx) - - suite.EqualValues(http.StatusOK, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - suite.NoError(err) - - statusReply := &model.Status{} - err = json.Unmarshal(b, statusReply) - suite.NoError(err) - - // if the status is properly formatted, that means the account has been put in the db - suite.Equal(`<p>hello <span class="h-card"><a href="https://unknown-instance.com/@brand_new_person" class="u-url mention" rel="nofollow noreferrer noopener" target="_blank">@<span>brand_new_person</span></a></span></p>`, statusReply.Content) - suite.Equal(model.VisibilityPublic, statusReply.Visibility) -} - -func (suite *StatusCreateTestSuite) TestPostAnotherNewStatus() { - t := suite.testTokens["local_account_1"] - oauthToken := oauth.DBTokenToToken(t) - - // setup - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - 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.Header.Set("accept", "application/json") - ctx.Request.Form = url.Values{ - "status": {statusWithLinksAndTags}, - } - 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) - suite.NoError(err) - - statusReply := &model.Status{} - err = json.Unmarshal(b, statusReply) - suite.NoError(err) - - suite.Equal("<p><a href=\"http://localhost:8080/tags/test\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>test</span></a> alright, should be able to post <a href=\"http://localhost:8080/tags/links\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>links</span></a> with fragments in them now, let&#39;s see........<br/><br/><a href=\"https://docs.gotosocial.org/en/latest/user_guide/posts/#links\" rel=\"noopener nofollow noreferrer\" target=\"_blank\">docs.gotosocial.org/en/latest/user_guide/posts/#links</a><br/><br/><a href=\"http://localhost:8080/tags/gotosocial\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>gotosocial</span></a><br/><br/>(tobi remember to pull the docker image challenge)</p>", statusReply.Content) -} - -func (suite *StatusCreateTestSuite) TestPostNewStatusWithEmoji() { - t := suite.testTokens["local_account_1"] - oauthToken := oauth.DBTokenToToken(t) - - // setup - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - 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.Header.Set("accept", "application/json") - 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) - suite.NoError(err) - - statusReply := &model.Status{} - err = json.Unmarshal(b, statusReply) - suite.NoError(err) - - suite.Equal("", statusReply.SpoilerText) - suite.Equal("<p>here is a rainbow emoji a few times! :rainbow: :rainbow: :rainbow: <br/> here&#39;s an emoji that isn&#39;t in the db: :test_emoji:</p>", statusReply.Content) - - suite.Len(statusReply.Emojis, 1) - apiEmoji := statusReply.Emojis[0] - gtsEmoji := testrig.NewTestEmojis()["rainbow"] - - suite.Equal(gtsEmoji.Shortcode, apiEmoji.Shortcode) - suite.Equal(gtsEmoji.ImageURL, apiEmoji.URL) - suite.Equal(gtsEmoji.ImageStaticURL, apiEmoji.StaticURL) -} - -// Try to reply to a status that doesn't exist -func (suite *StatusCreateTestSuite) TestReplyToNonexistentStatus() { - t := suite.testTokens["local_account_1"] - oauthToken := oauth.DBTokenToToken(t) - - // setup - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - 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.Header.Set("accept", "application/json") - 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) - suite.NoError(err) - suite.Equal(`{"error":"Bad Request: 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.DBTokenToToken(t) - - // setup - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - 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.Header.Set("accept", "application/json") - 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) - suite.NoError(err) - - statusReply := &model.Status{} - err = json.Unmarshal(b, statusReply) - suite.NoError(err) - - suite.Equal("", statusReply.SpoilerText) - suite.Equal(fmt.Sprintf("<p>hello <span class=\"h-card\"><a href=\"http://localhost:8080/@%s\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>%s</span></a></span> this reply should work!</p>", testrig.NewTestAccounts()["local_account_2"].Username, testrig.NewTestAccounts()["local_account_2"].Username), statusReply.Content) - suite.False(statusReply.Sensitive) - suite.Equal(model.VisibilityPublic, statusReply.Visibility) - suite.Equal(testrig.NewTestStatuses()["local_account_2_status_1"].ID, *statusReply.InReplyToID) - suite.Equal(testrig.NewTestAccounts()["local_account_2"].ID, *statusReply.InReplyToAccountID) - suite.Len(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.DBTokenToToken(t) - - attachment := suite.testAttachments["local_account_1_unattached_1"] - - // setup - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - 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.Header.Set("accept", "application/json") - ctx.Request.Form = url.Values{ - "status": {"here's an image attachment"}, - "media_ids[]": {attachment.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) - suite.NoError(err) - - statusResponse := &model.Status{} - err = json.Unmarshal(b, statusResponse) - suite.NoError(err) - - suite.Equal("", statusResponse.SpoilerText) - suite.Equal("<p>here&#39;s an image attachment</p>", statusResponse.Content) - suite.False(statusResponse.Sensitive) - suite.Equal(model.VisibilityPublic, statusResponse.Visibility) - - // there should be one media attachment - suite.Len(statusResponse.MediaAttachments, 1) - - // get the updated media attachment from the database - gtsAttachment, err := suite.db.GetAttachmentByID(context.Background(), statusResponse.MediaAttachments[0].ID) - suite.NoError(err) - - // convert it to a api attachment - gtsAttachmentAsapi, err := suite.tc.AttachmentToAPIAttachment(context.Background(), gtsAttachment) - suite.NoError(err) - - // compare it with what we have now - suite.EqualValues(statusResponse.MediaAttachments[0], gtsAttachmentAsapi) - - // the status id of the attachment should now be set to the id of the status we just created - suite.Equal(statusResponse.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 @@ -1,100 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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" - "net/http" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// StatusDELETEHandler swagger:operation DELETE /api/v1/statuses/{id} statusDelete -// -// Delete status with the given ID. The status must belong to you. -// -// The deleted status will be returned in the response. The `text` field will contain the original text of the status as it was submitted. -// This is useful when doing a 'delete and redraft' type operation. -// -// --- -// tags: -// - statuses -// -// produces: -// - application/json -// -// parameters: -// - -// name: id -// type: string -// description: Target status ID. -// in: path -// required: true -// -// security: -// - OAuth2 Bearer: -// - write:statuses -// -// responses: -// '200': -// description: "The status that was just deleted." -// schema: -// "$ref": "#/definitions/status" -// '400': -// description: bad request -// '401': -// description: unauthorized -// '403': -// description: forbidden -// '404': -// description: not found -// '406': -// description: not acceptable -// '500': -// description: internal server error -func (m *Module) StatusDELETEHandler(c *gin.Context) { - authed, err := oauth.Authed(c, true, true, true, true) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) - return - } - - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - targetStatusID := c.Param(IDKey) - if targetStatusID == "" { - err := errors.New("no status id specified") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - apiStatus, errWithCode := m.processor.StatusDelete(c.Request.Context(), authed, targetStatusID) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - c.JSON(http.StatusOK, apiStatus) -} diff --git a/internal/api/client/status/statusdelete_test.go b/internal/api/client/status/statusdelete_test.go @@ -1,91 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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" - "errors" - "fmt" - "io/ioutil" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/gin-gonic/gin" - "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/db" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type StatusDeleteTestSuite struct { - StatusStandardTestSuite -} - -func (suite *StatusDeleteTestSuite) TestPostDelete() { - t := suite.testTokens["local_account_1"] - oauthToken := oauth.DBTokenToToken(t) - targetStatus := suite.testStatuses["local_account_1_status_1"] - - // setup - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - 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.MethodDelete, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.BasePathWithID, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting - ctx.Request.Header.Set("accept", "application/json") - - // 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.StatusDELETEHandler(ctx) - - // check response - suite.EqualValues(http.StatusOK, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - suite.NoError(err) - - statusReply := &model.Status{} - err = json.Unmarshal(b, statusReply) - suite.NoError(err) - suite.NotNil(statusReply) - - if !testrig.WaitFor(func() bool { - _, err := suite.db.GetStatusByID(ctx, targetStatus.ID) - return errors.Is(err, db.ErrNoEntries) - }) { - suite.FailNow("time out waiting for status to be deleted") - } - -} - -func TestStatusDeleteTestSuite(t *testing.T) { - suite.Run(t, new(StatusDeleteTestSuite)) -} diff --git a/internal/api/client/status/statusfave.go b/internal/api/client/status/statusfave.go @@ -1,97 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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" - "net/http" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// StatusFavePOSTHandler swagger:operation POST /api/v1/statuses/{id}/favourite statusFave -// -// Star/like/favourite the given status, if permitted. -// -// --- -// tags: -// - statuses -// -// produces: -// - application/json -// -// parameters: -// - -// name: id -// type: string -// description: Target status ID. -// in: path -// required: true -// -// security: -// - OAuth2 Bearer: -// - write:statuses -// -// responses: -// '200': -// description: "The newly faved status." -// schema: -// "$ref": "#/definitions/status" -// '400': -// description: bad request -// '401': -// description: unauthorized -// '403': -// description: forbidden -// '404': -// description: not found -// '406': -// description: not acceptable -// '500': -// description: internal server error -func (m *Module) StatusFavePOSTHandler(c *gin.Context) { - authed, err := oauth.Authed(c, true, true, true, true) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) - return - } - - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - targetStatusID := c.Param(IDKey) - if targetStatusID == "" { - err := errors.New("no status id specified") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - apiStatus, errWithCode := m.processor.StatusFave(c.Request.Context(), authed, targetStatusID) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - c.JSON(http.StatusOK, apiStatus) -} diff --git a/internal/api/client/status/statusfave_test.go b/internal/api/client/status/statusfave_test.go @@ -1,131 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 -} - -// fave a status -func (suite *StatusFaveTestSuite) TestPostFave() { - t := suite.testTokens["local_account_1"] - oauthToken := oauth.DBTokenToToken(t) - - targetStatus := suite.testStatuses["admin_account_status_2"] - - // setup - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - 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 - ctx.Request.Header.Set("accept", "application/json") - - // 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.DBTokenToToken(t) - - targetStatus := suite.testStatuses["local_account_2_status_3"] // this one is unlikeable and unreplyable - - // setup - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - 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 - ctx.Request.Header.Set("accept", "application/json") - - // 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) - - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) - assert.Equal(suite.T(), `{"error":"Forbidden"}`, 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 @@ -1,98 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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" - "net/http" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// StatusFavedByGETHandler swagger:operation GET /api/v1/statuses/{id}/favourited_by statusFavedBy -// -// View accounts that have faved/starred/liked the target status. -// -// --- -// tags: -// - statuses -// -// produces: -// - application/json -// -// parameters: -// - -// name: id -// type: string -// description: Target status ID. -// in: path -// required: true -// -// security: -// - OAuth2 Bearer: -// - read:accounts -// -// responses: -// '200': -// schema: -// type: array -// items: -// "$ref": "#/definitions/account" -// '400': -// description: bad request -// '401': -// description: unauthorized -// '403': -// description: forbidden -// '404': -// description: not found -// '406': -// description: not acceptable -// '500': -// description: internal server error -func (m *Module) StatusFavedByGETHandler(c *gin.Context) { - authed, err := oauth.Authed(c, true, true, true, true) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) - return - } - - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - targetStatusID := c.Param(IDKey) - if targetStatusID == "" { - err := errors.New("no status id specified") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - apiAccounts, errWithCode := m.processor.StatusFavedBy(c.Request.Context(), authed, targetStatusID) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - c.JSON(http.StatusOK, apiAccounts) -} diff --git a/internal/api/client/status/statusfavedby_test.go b/internal/api/client/status/statusfavedby_test.go @@ -1,88 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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) TestGetFavedBy() { - t := suite.testTokens["local_account_2"] - oauthToken := oauth.DBTokenToToken(t) - - targetStatus := suite.testStatuses["admin_account_status_1"] // this status is faved by local_account_1 - - // setup - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - 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 - ctx.Request.Header.Set("accept", "application/json") - - // 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 @@ -1,97 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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" - "net/http" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// StatusGETHandler swagger:operation GET /api/v1/statuses/{id} statusGet -// -// View status with the given ID. -// -// --- -// tags: -// - statuses -// -// produces: -// - application/json -// -// parameters: -// - -// name: id -// type: string -// description: Target status ID. -// in: path -// required: true -// -// security: -// - OAuth2 Bearer: -// - read:statuses -// -// responses: -// '200': -// description: "The requested status." -// schema: -// "$ref": "#/definitions/status" -// '400': -// description: bad request -// '401': -// description: unauthorized -// '403': -// description: forbidden -// '404': -// description: not found -// '406': -// description: not acceptable -// '500': -// description: internal server error -func (m *Module) StatusGETHandler(c *gin.Context) { - authed, err := oauth.Authed(c, true, true, true, true) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) - return - } - - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - targetStatusID := c.Param(IDKey) - if targetStatusID == "" { - err := errors.New("no status id specified") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - apiStatus, errWithCode := m.processor.StatusGet(c.Request.Context(), authed, targetStatusID) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - c.JSON(http.StatusOK, apiStatus) -} diff --git a/internal/api/client/status/statusget_test.go b/internal/api/client/status/statusget_test.go @@ -1,33 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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" -) - -type StatusGetTestSuite struct { - StatusStandardTestSuite -} - -func TestStatusGetTestSuite(t *testing.T) { - suite.Run(t, new(StatusGetTestSuite)) -} diff --git a/internal/api/client/status/statusunbookmark.go b/internal/api/client/status/statusunbookmark.go @@ -1,98 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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" - "net/http" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// StatusUnbookmarkPOSTHandler swagger:operation POST /api/v1/statuses/{id}/unbookmark statusUnbookmark -// -// Unbookmark status with the given ID. -// -// --- -// tags: -// - statuses -// -// produces: -// - application/json -// -// parameters: -// - -// name: id -// type: string -// description: Target status ID. -// in: path -// required: true -// -// security: -// - OAuth2 Bearer: -// - write:statuses -// -// responses: -// '200': -// name: status -// description: The status. -// schema: -// "$ref": "#/definitions/status" -// '400': -// description: bad request -// '401': -// description: unauthorized -// '403': -// description: forbidden -// '404': -// description: not found -// '406': -// description: not acceptable -// '500': -// description: internal server error -func (m *Module) StatusUnbookmarkPOSTHandler(c *gin.Context) { - authed, err := oauth.Authed(c, true, true, true, true) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) - return - } - - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - targetStatusID := c.Param(IDKey) - if targetStatusID == "" { - err := errors.New("no status id specified") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - apiStatus, errWithCode := m.processor.StatusUnbookmark(c.Request.Context(), authed, targetStatusID) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - c.JSON(http.StatusOK, apiStatus) -} diff --git a/internal/api/client/status/statusunbookmark_test.go b/internal/api/client/status/statusunbookmark_test.go @@ -1,78 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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/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 StatusUnbookmarkTestSuite struct { - StatusStandardTestSuite -} - -func (suite *StatusUnbookmarkTestSuite) TestPostUnbookmark() { - t := suite.testTokens["local_account_1"] - oauthToken := oauth.DBTokenToToken(t) - - targetStatus := suite.testStatuses["admin_account_status_1"] - - // setup - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - 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.UnbookmarkPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting - ctx.Request.Header.Set("accept", "application/json") - - ctx.Params = gin.Params{ - gin.Param{ - Key: status.IDKey, - Value: targetStatus.ID, - }, - } - - suite.statusModule.StatusUnbookmarkPOSTHandler(ctx) - - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - suite.NoError(err) - - statusReply := &model.Status{} - err = json.Unmarshal(b, statusReply) - suite.NoError(err) - - suite.False(statusReply.Bookmarked) -} - -func TestStatusUnbookmarkTestSuite(t *testing.T) { - suite.Run(t, new(StatusUnbookmarkTestSuite)) -} diff --git a/internal/api/client/status/statusunboost.go b/internal/api/client/status/statusunboost.go @@ -1,98 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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" - "net/http" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// StatusUnboostPOSTHandler swagger:operation POST /api/v1/statuses/{id}/unreblog statusUnreblog -// -// Unreblog/unboost status with the given ID. -// -// --- -// tags: -// - statuses -// -// produces: -// - application/json -// -// parameters: -// - -// name: id -// type: string -// description: Target status ID. -// in: path -// required: true -// -// security: -// - OAuth2 Bearer: -// - write:statuses -// -// responses: -// '200': -// name: status -// description: The unboosted status. -// schema: -// "$ref": "#/definitions/status" -// '400': -// description: bad request -// '401': -// description: unauthorized -// '403': -// description: forbidden -// '404': -// description: not found -// '406': -// description: not acceptable -// '500': -// description: internal server error -func (m *Module) StatusUnboostPOSTHandler(c *gin.Context) { - authed, err := oauth.Authed(c, true, true, true, true) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) - return - } - - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - targetStatusID := c.Param(IDKey) - if targetStatusID == "" { - err := errors.New("no status id specified") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - apiStatus, errWithCode := m.processor.StatusUnboost(c.Request.Context(), authed, targetStatusID) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - c.JSON(http.StatusOK, apiStatus) -} diff --git a/internal/api/client/status/statusunfave.go b/internal/api/client/status/statusunfave.go @@ -1,97 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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" - "net/http" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// StatusUnfavePOSTHandler swagger:operation POST /api/v1/statuses/{id}/unfavourite statusUnfave -// -// Unstar/unlike/unfavourite the given status. -// -// --- -// tags: -// - statuses -// -// produces: -// - application/json -// -// parameters: -// - -// name: id -// type: string -// description: Target status ID. -// in: path -// required: true -// -// security: -// - OAuth2 Bearer: -// - write:statuses -// -// responses: -// '200': -// description: "The unfaved status." -// schema: -// "$ref": "#/definitions/status" -// '400': -// description: bad request -// '401': -// description: unauthorized -// '403': -// description: forbidden -// '404': -// description: not found -// '406': -// description: not acceptable -// '500': -// description: internal server error -func (m *Module) StatusUnfavePOSTHandler(c *gin.Context) { - authed, err := oauth.Authed(c, true, true, true, true) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) - return - } - - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - targetStatusID := c.Param(IDKey) - if targetStatusID == "" { - err := errors.New("no status id specified") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - apiStatus, errWithCode := m.processor.StatusUnfave(c.Request.Context(), authed, targetStatusID) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - c.JSON(http.StatusOK, apiStatus) -} diff --git a/internal/api/client/status/statusunfave_test.go b/internal/api/client/status/statusunfave_test.go @@ -1,143 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 -} - -// unfave a status -func (suite *StatusUnfaveTestSuite) TestPostUnfave() { - t := suite.testTokens["local_account_1"] - oauthToken := oauth.DBTokenToToken(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, _ := testrig.CreateGinTestContext(recorder, nil) - 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 - ctx.Request.Header.Set("accept", "application/json") - - // 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.DBTokenToToken(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, _ := testrig.CreateGinTestContext(recorder, nil) - 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 - ctx.Request.Header.Set("accept", "application/json") - - // 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/client/statuses/status.go b/internal/api/client/statuses/status.go @@ -0,0 +1,100 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 statuses + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/processing" +) + +const ( + // IDKey is for status UUIDs + IDKey = "id" + // BasePath is the base path for serving the statuses API, minus the 'api' prefix + BasePath = "/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 + + // 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" + + // ContextPath is used for fetching context of posts + ContextPath = BasePathWithID + "/context" +) + +type Module struct { + processor processing.Processor +} + +func New(processor processing.Processor) *Module { + return &Module{ + processor: processor, + } +} + +func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) { + // create / get / delete status + attachHandler(http.MethodPost, BasePath, m.StatusCreatePOSTHandler) + attachHandler(http.MethodGet, BasePathWithID, m.StatusGETHandler) + attachHandler(http.MethodDelete, BasePathWithID, m.StatusDELETEHandler) + + // fave stuff + attachHandler(http.MethodPost, FavouritePath, m.StatusFavePOSTHandler) + attachHandler(http.MethodPost, UnfavouritePath, m.StatusUnfavePOSTHandler) + attachHandler(http.MethodGet, FavouritedPath, m.StatusFavedByGETHandler) + + // reblog stuff + attachHandler(http.MethodPost, ReblogPath, m.StatusBoostPOSTHandler) + attachHandler(http.MethodPost, UnreblogPath, m.StatusUnboostPOSTHandler) + attachHandler(http.MethodGet, RebloggedPath, m.StatusBoostedByGETHandler) + attachHandler(http.MethodPost, BookmarkPath, m.StatusBookmarkPOSTHandler) + attachHandler(http.MethodPost, UnbookmarkPath, m.StatusUnbookmarkPOSTHandler) + + // context / status thread + attachHandler(http.MethodGet, ContextPath, m.StatusContextGETHandler) +} diff --git a/internal/api/client/statuses/status_test.go b/internal/api/client/statuses/status_test.go @@ -0,0 +1,98 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 statuses_test + +import ( + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/statuses" + "github.com/superseriousbusiness/gotosocial/internal/concurrency" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/email" + "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/messages" + "github.com/superseriousbusiness/gotosocial/internal/processing" + "github.com/superseriousbusiness/gotosocial/internal/storage" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type StatusStandardTestSuite struct { + // standard suite interfaces + suite.Suite + db db.DB + tc typeutils.TypeConverter + mediaManager media.Manager + federator federation.Federator + emailSender email.Sender + processor processing.Processor + storage *storage.Driver + + // standard suite models + testTokens map[string]*gtsmodel.Token + testClients map[string]*gtsmodel.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 + testFollows map[string]*gtsmodel.Follow + + // module being tested + statusModule *statuses.Module +} + +func (suite *StatusStandardTestSuite) 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() + suite.testFollows = testrig.NewTestFollows() +} + +func (suite *StatusStandardTestSuite) SetupTest() { + testrig.InitTestConfig() + testrig.InitTestLog() + + suite.db = testrig.NewTestDB() + suite.tc = testrig.NewTestTypeConverter(suite.db) + suite.storage = testrig.NewInMemoryStorage() + testrig.StandardDBSetup(suite.db, nil) + testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") + + fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) + clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) + + suite.mediaManager = testrig.NewTestMediaManager(suite.db, suite.storage) + suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker), suite.storage, suite.mediaManager, fedWorker) + suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil) + suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, suite.mediaManager, clientWorker, fedWorker) + suite.statusModule = statuses.New(suite.processor) + + suite.NoError(suite.processor.Start()) +} + +func (suite *StatusStandardTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) +} diff --git a/internal/api/client/statuses/statusbookmark.go b/internal/api/client/statuses/statusbookmark.go @@ -0,0 +1,98 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 statuses + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// StatusBookmarkPOSTHandler swagger:operation POST /api/v1/statuses/{id}/bookmark statusBookmark +// +// Bookmark status with the given ID. +// +// --- +// tags: +// - statuses +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: string +// description: Target status ID. +// in: path +// required: true +// +// security: +// - OAuth2 Bearer: +// - write:statuses +// +// responses: +// '200': +// name: status +// description: The status. +// schema: +// "$ref": "#/definitions/status" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) StatusBookmarkPOSTHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + targetStatusID := c.Param(IDKey) + if targetStatusID == "" { + err := errors.New("no status id specified") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + apiStatus, errWithCode := m.processor.StatusBookmark(c.Request.Context(), authed, targetStatusID) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + c.JSON(http.StatusOK, apiStatus) +} diff --git a/internal/api/client/statuses/statusbookmark_test.go b/internal/api/client/statuses/statusbookmark_test.go @@ -0,0 +1,83 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 statuses_test + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/statuses" + "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type StatusBookmarkTestSuite struct { + StatusStandardTestSuite +} + +func (suite *StatusBookmarkTestSuite) TestPostBookmark() { + t := suite.testTokens["local_account_1"] + oauthToken := oauth.DBTokenToToken(t) + + targetStatus := suite.testStatuses["admin_account_status_1"] + + // setup + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + 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(statuses.BookmarkPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/json") + + // 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: statuses.IDKey, + Value: targetStatus.ID, + }, + } + + suite.statusModule.StatusBookmarkPOSTHandler(ctx) + + // check response + suite.EqualValues(http.StatusOK, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + suite.NoError(err) + + statusReply := &model.Status{} + err = json.Unmarshal(b, statusReply) + suite.NoError(err) + + suite.True(statusReply.Bookmarked) +} + +func TestStatusBookmarkTestSuite(t *testing.T) { + suite.Run(t, new(StatusBookmarkTestSuite)) +} diff --git a/internal/api/client/statuses/statusboost.go b/internal/api/client/statuses/statusboost.go @@ -0,0 +1,101 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 statuses + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// StatusBoostPOSTHandler swagger:operation POST /api/v1/statuses/{id}/reblog statusReblog +// +// Reblog/boost status with the given ID. +// +// If the target status is rebloggable/boostable, it will be shared with your followers. +// This is equivalent to an ActivityPub 'Announce' activity. +// +// --- +// tags: +// - statuses +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: string +// description: Target status ID. +// in: path +// required: true +// +// security: +// - OAuth2 Bearer: +// - write:statuses +// +// responses: +// '200': +// name: status +// description: The boost of the status. +// schema: +// "$ref": "#/definitions/status" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) StatusBoostPOSTHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + targetStatusID := c.Param(IDKey) + if targetStatusID == "" { + err := errors.New("no status id specified") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + apiStatus, errWithCode := m.processor.StatusBoost(c.Request.Context(), authed, targetStatusID) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + c.JSON(http.StatusOK, apiStatus) +} diff --git a/internal/api/client/statuses/statusboost_test.go b/internal/api/client/statuses/statusboost_test.go @@ -0,0 +1,247 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 statuses_test + +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/statuses" + apimodel "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 StatusBoostTestSuite struct { + StatusStandardTestSuite +} + +func (suite *StatusBoostTestSuite) TestPostBoost() { + t := suite.testTokens["local_account_1"] + oauthToken := oauth.DBTokenToToken(t) + + targetStatus := suite.testStatuses["admin_account_status_1"] + + // setup + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + 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(statuses.ReblogPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/json") + + // 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: statuses.IDKey, + Value: targetStatus.ID, + }, + } + + suite.statusModule.StatusBoostPOSTHandler(ctx) + + // check response + suite.EqualValues(http.StatusOK, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + suite.NoError(err) + + statusReply := &apimodel.Status{} + err = json.Unmarshal(b, statusReply) + suite.NoError(err) + + suite.False(statusReply.Sensitive) + suite.Equal(apimodel.VisibilityPublic, statusReply.Visibility) + + suite.Equal(targetStatus.ContentWarning, statusReply.SpoilerText) + suite.Equal(targetStatus.Content, statusReply.Content) + suite.Equal("the_mighty_zork", statusReply.Account.Username) + suite.Len(statusReply.MediaAttachments, 0) + suite.Len(statusReply.Mentions, 0) + suite.Len(statusReply.Emojis, 0) + suite.Len(statusReply.Tags, 0) + + suite.NotNil(statusReply.Application) + suite.Equal("really cool gts application", statusReply.Application.Name) + + suite.NotNil(statusReply.Reblog) + suite.Equal(1, statusReply.Reblog.ReblogsCount) + suite.Equal(1, statusReply.Reblog.FavouritesCount) + suite.Equal(targetStatus.Content, statusReply.Reblog.Content) + suite.Equal(targetStatus.ContentWarning, statusReply.Reblog.SpoilerText) + suite.Equal(targetStatus.AccountID, statusReply.Reblog.Account.ID) + suite.Len(statusReply.Reblog.MediaAttachments, 1) + suite.Len(statusReply.Reblog.Tags, 1) + suite.Len(statusReply.Reblog.Emojis, 1) + suite.Equal("superseriousbusiness", statusReply.Reblog.Application.Name) +} + +func (suite *StatusBoostTestSuite) TestPostBoostOwnFollowersOnly() { + t := suite.testTokens["local_account_1"] + oauthToken := oauth.DBTokenToToken(t) + + testStatus := suite.testStatuses["local_account_1_status_5"] + testAccount := suite.testAccounts["local_account_1"] + testUser := suite.testUsers["local_account_1"] + + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedToken, oauthToken) + ctx.Set(oauth.SessionAuthorizedUser, testUser) + ctx.Set(oauth.SessionAuthorizedAccount, testAccount) + ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(statuses.ReblogPath, ":id", testStatus.ID, 1)), nil) + ctx.Request.Header.Set("accept", "application/json") + + ctx.Params = gin.Params{ + gin.Param{ + Key: statuses.IDKey, + Value: testStatus.ID, + }, + } + + suite.statusModule.StatusBoostPOSTHandler(ctx) + + // check response + suite.EqualValues(http.StatusOK, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + suite.NoError(err) + + responseStatus := &apimodel.Status{} + err = json.Unmarshal(b, responseStatus) + suite.NoError(err) + + suite.False(responseStatus.Sensitive) + suite.Equal(suite.tc.VisToAPIVis(context.Background(), testStatus.Visibility), responseStatus.Visibility) + + suite.Equal(testStatus.ContentWarning, responseStatus.SpoilerText) + suite.Equal(testStatus.Content, responseStatus.Content) + suite.Equal("the_mighty_zork", responseStatus.Account.Username) + suite.Len(responseStatus.MediaAttachments, 0) + suite.Len(responseStatus.Mentions, 0) + suite.Len(responseStatus.Emojis, 0) + suite.Len(responseStatus.Tags, 0) + + suite.NotNil(responseStatus.Application) + suite.Equal("really cool gts application", responseStatus.Application.Name) + + suite.NotNil(responseStatus.Reblog) + suite.Equal(1, responseStatus.Reblog.ReblogsCount) + suite.Equal(0, responseStatus.Reblog.FavouritesCount) + suite.Equal(testStatus.Content, responseStatus.Reblog.Content) + suite.Equal(testStatus.ContentWarning, responseStatus.Reblog.SpoilerText) + suite.Equal(testStatus.AccountID, responseStatus.Reblog.Account.ID) + suite.Equal(suite.tc.VisToAPIVis(context.Background(), testStatus.Visibility), responseStatus.Reblog.Visibility) + suite.Empty(responseStatus.Reblog.MediaAttachments) + suite.Empty(responseStatus.Reblog.Tags) + suite.Empty(responseStatus.Reblog.Emojis) + suite.Equal("really cool gts application", responseStatus.Reblog.Application.Name) +} + +// try to boost a status that's not boostable +func (suite *StatusBoostTestSuite) TestPostUnboostable() { + t := suite.testTokens["local_account_1"] + oauthToken := oauth.DBTokenToToken(t) + + targetStatus := suite.testStatuses["local_account_2_status_4"] + + // setup + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + 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(statuses.ReblogPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/json") + + // 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: statuses.IDKey, + Value: targetStatus.ID, + }, + } + + suite.statusModule.StatusBoostPOSTHandler(ctx) + + // check response + suite.Equal(http.StatusForbidden, recorder.Code) // we 403 unboostable statuses + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + suite.NoError(err) + suite.Equal(`{"error":"Forbidden"}`, string(b)) +} + +// try to boost a status that's not visible to the user +func (suite *StatusBoostTestSuite) TestPostNotVisible() { + // stop local_account_2 following zork + err := suite.db.DeleteByID(context.Background(), suite.testFollows["local_account_2_local_account_1"].ID, &gtsmodel.Follow{}) + suite.NoError(err) + + t := suite.testTokens["local_account_2"] + oauthToken := oauth.DBTokenToToken(t) + + targetStatus := suite.testStatuses["local_account_1_status_3"] // this is a mutual only status and these accounts aren't mutuals + + // setup + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + 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(statuses.ReblogPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/json") + + // 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: statuses.IDKey, + Value: targetStatus.ID, + }, + } + + suite.statusModule.StatusBoostPOSTHandler(ctx) + + // check response + suite.Equal(http.StatusNotFound, recorder.Code) // we 404 statuses that aren't visible +} + +func TestStatusBoostTestSuite(t *testing.T) { + suite.Run(t, new(StatusBoostTestSuite)) +} diff --git a/internal/api/client/statuses/statusboostedby.go b/internal/api/client/statuses/statusboostedby.go @@ -0,0 +1,89 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 statuses + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// StatusBoostedByGETHandler swagger:operation GET /api/v1/statuses/{id}/reblogged_by statusBoostedBy +// +// View accounts that have reblogged/boosted the target status. +// +// --- +// tags: +// - statuses +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: string +// description: Target status ID. +// in: path +// required: true +// +// security: +// - OAuth2 Bearer: +// - read:accounts +// +// responses: +// '200': +// schema: +// type: array +// items: +// "$ref": "#/definitions/account" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '404': +// description: not found +func (m *Module) StatusBoostedByGETHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + + targetStatusID := c.Param(IDKey) + if targetStatusID == "" { + err := errors.New("no status id specified") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + apiAccounts, errWithCode := m.processor.StatusBoostedBy(c.Request.Context(), authed, targetStatusID) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + c.JSON(http.StatusOK, apiAccounts) +} diff --git a/internal/api/client/statuses/statusboostedby_test.go b/internal/api/client/statuses/statusboostedby_test.go @@ -0,0 +1,112 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 statuses_test + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/statuses" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type StatusBoostedByTestSuite struct { + StatusStandardTestSuite +} + +func (suite *StatusBoostedByTestSuite) TestRebloggedByOK() { + t := suite.testTokens["local_account_1"] + oauthToken := oauth.DBTokenToToken(t) + targetStatus := suite.testStatuses["local_account_1_status_1"] + + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + 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.MethodGet, fmt.Sprintf("http://localhost:8080%s", strings.Replace(statuses.RebloggedPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/json") + ctx.AddParam("id", targetStatus.ID) + + suite.statusModule.StatusBoostedByGETHandler(ctx) + + suite.EqualValues(http.StatusOK, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + + b, err := ioutil.ReadAll(result.Body) + suite.NoError(err) + + accounts := []*gtsmodel.Account{} + err = json.Unmarshal(b, &accounts) + suite.NoError(err) + + if !suite.Len(accounts, 1) { + suite.FailNow("should have had 1 account") + } + + suite.Equal(accounts[0].ID, suite.testAccounts["admin_account"].ID) +} + +func (suite *StatusBoostedByTestSuite) TestRebloggedByUseBoostWrapperID() { + t := suite.testTokens["local_account_1"] + oauthToken := oauth.DBTokenToToken(t) + targetStatus := suite.testStatuses["admin_account_status_4"] // admin_account_status_4 is a boost of local_account_1_status_1 + + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + 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.MethodGet, fmt.Sprintf("http://localhost:8080%s", strings.Replace(statuses.RebloggedPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/json") + ctx.AddParam("id", targetStatus.ID) + + suite.statusModule.StatusBoostedByGETHandler(ctx) + + suite.EqualValues(http.StatusOK, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + + b, err := ioutil.ReadAll(result.Body) + suite.NoError(err) + + accounts := []*gtsmodel.Account{} + err = json.Unmarshal(b, &accounts) + suite.NoError(err) + + if !suite.Len(accounts, 1) { + suite.FailNow("should have had 1 account") + } + + suite.Equal(accounts[0].ID, suite.testAccounts["admin_account"].ID) +} + +func TestStatusBoostedByTestSuite(t *testing.T) { + suite.Run(t, new(StatusBoostedByTestSuite)) +} diff --git a/internal/api/client/statuses/statuscontext.go b/internal/api/client/statuses/statuscontext.go @@ -0,0 +1,100 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 statuses + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// StatusContextGETHandler swagger:operation GET /api/v1/statuses/{id}/context statusContext +// +// Return ancestors and descendants of the given status. +// +// The returned statuses will be ordered in a thread structure, so they are suitable to be displayed in the order in which they were returned. +// +// --- +// tags: +// - statuses +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: string +// description: Target status ID. +// in: path +// required: true +// +// security: +// - OAuth2 Bearer: +// - read:statuses +// +// responses: +// '200': +// name: statuses +// description: Status context object. +// schema: +// "$ref": "#/definitions/statusContext" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) StatusContextGETHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + targetStatusID := c.Param(IDKey) + if targetStatusID == "" { + err := errors.New("no status id specified") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + statusContext, errWithCode := m.processor.StatusGetContext(c.Request.Context(), authed, targetStatusID) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + c.JSON(http.StatusOK, statusContext) +} diff --git a/internal/api/client/statuses/statuscreate.go b/internal/api/client/statuses/statuscreate.go @@ -0,0 +1,172 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 statuses + +import ( + "errors" + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/validate" +) + +// StatusCreatePOSTHandler swagger:operation POST /api/v1/statuses statusCreate +// +// Create a new status. +// +// The parameters can also be given in the body of the request, as JSON, if the content-type is set to 'application/json'. +// The parameters can also be given in the body of the request, as XML, if the content-type is set to 'application/xml'. +// +// --- +// tags: +// - statuses +// +// consumes: +// - application/json +// - application/xml +// - application/x-www-form-urlencoded +// +// produces: +// - application/json +// +// security: +// - OAuth2 Bearer: +// - write:statuses +// +// responses: +// '200': +// description: "The newly created status." +// schema: +// "$ref": "#/definitions/status" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) StatusCreatePOSTHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + form := &apimodel.AdvancedStatusCreateForm{} + if err := c.ShouldBind(form); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + // DO NOT COMMIT THIS UNCOMMENTED, IT WILL CAUSE MASS CHAOS. + // this is being left in as an ode to kim's shitposting. + // + // user := authed.Account.DisplayName + // if user == "" { + // user = authed.Account.Username + // } + // form.Status += "\n\nsent from " + user + "'s iphone\n" + + if err := validateCreateStatus(form); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + apiStatus, errWithCode := m.processor.StatusCreate(c.Request.Context(), authed, form) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + c.JSON(http.StatusOK, apiStatus) +} + +func validateCreateStatus(form *apimodel.AdvancedStatusCreateForm) error { + hasStatus := form.Status != "" + hasMedia := len(form.MediaIDs) != 0 + hasPoll := form.Poll != nil + + if !hasStatus && !hasMedia && !hasPoll { + return errors.New("no status, media, or poll provided") + } + + if hasMedia && hasPoll { + return errors.New("can't post media + poll in same status") + } + + maxChars := config.GetStatusesMaxChars() + maxMediaFiles := config.GetStatusesMediaMaxFiles() + maxPollOptions := config.GetStatusesPollMaxOptions() + maxPollChars := config.GetStatusesPollOptionMaxChars() + maxCwChars := config.GetStatusesCWMaxChars() + + if form.Status != "" { + if length := len([]rune(form.Status)); length > maxChars { + return fmt.Errorf("status too long, %d characters provided but limit is %d", length, maxChars) + } + } + + if len(form.MediaIDs) > maxMediaFiles { + return fmt.Errorf("too many media files attached to status, %d attached but limit is %d", len(form.MediaIDs), maxMediaFiles) + } + + if form.Poll != nil { + if form.Poll.Options == nil { + return errors.New("poll with no options") + } + if len(form.Poll.Options) > maxPollOptions { + return fmt.Errorf("too many poll options provided, %d provided but limit is %d", len(form.Poll.Options), maxPollOptions) + } + for _, p := range form.Poll.Options { + if length := len([]rune(p)); length > maxPollChars { + return fmt.Errorf("poll option too long, %d characters provided but limit is %d", length, maxPollChars) + } + } + } + + if form.SpoilerText != "" { + if length := len([]rune(form.SpoilerText)); length > maxCwChars { + return fmt.Errorf("content-warning/spoilertext too long, %d characters provided but limit is %d", length, maxCwChars) + } + } + + if form.Language != "" { + if err := validate.Language(form.Language); err != nil { + return err + } + } + + return nil +} diff --git a/internal/api/client/statuses/statuscreate_test.go b/internal/api/client/statuses/statuscreate_test.go @@ -0,0 +1,398 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 statuses_test + +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/statuses" + 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/testrig" +) + +type StatusCreateTestSuite struct { + StatusStandardTestSuite +} + +const ( + statusWithLinksAndTags = "#test alright, should be able to post #links with fragments in them now, let's see........\n\nhttps://docs.gotosocial.org/en/latest/user_guide/posts/#links\n\n#gotosocial\n\n(tobi remember to pull the docker image challenge)" + statusMarkdown = "# Title\n\n## Smaller title\n\nThis is a post written in [markdown](https://www.markdownguide.org/)\n\n<img src=\"https://d33wubrfki0l68.cloudfront.net/f1f475a6fda1c2c4be4cac04033db5c3293032b4/513a4/assets/images/markdown-mark-white.svg\"/>" + statusMarkdownExpected = "<h1>Title</h1><h2>Smaller title</h2><p>This is a post written in <a href=\"https://www.markdownguide.org/\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">markdown</a></p><img src=\"https://d33wubrfki0l68.cloudfront.net/f1f475a6fda1c2c4be4cac04033db5c3293032b4/513a4/assets/images/markdown-mark-white.svg\" crossorigin=\"anonymous\">" +) + +// Post a new status with some custom visibility settings +func (suite *StatusCreateTestSuite) TestPostNewStatus() { + t := suite.testTokens["local_account_1"] + oauthToken := oauth.DBTokenToToken(t) + + // setup + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + 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", statuses.BasePath), nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/json") + ctx.Request.Form = url.Values{ + "status": {"this is a brand new status! #helloworld"}, + "spoiler_text": {"hello hello"}, + "sensitive": {"true"}, + "visibility": {string(apimodel.VisibilityMutualsOnly)}, + "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) + suite.NoError(err) + + statusReply := &apimodel.Status{} + err = json.Unmarshal(b, statusReply) + suite.NoError(err) + + suite.Equal("hello hello", statusReply.SpoilerText) + suite.Equal("<p>this is a brand new status! <a href=\"http://localhost:8080/tags/helloworld\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>helloworld</span></a></p>", statusReply.Content) + suite.True(statusReply.Sensitive) + suite.Equal(apimodel.VisibilityPrivate, statusReply.Visibility) // even though we set this status to mutuals only, it should serialize to private, because the mastodon api has no idea about mutuals_only + suite.Len(statusReply.Tags, 1) + suite.Equal(apimodel.Tag{ + Name: "helloworld", + URL: "http://localhost:8080/tags/helloworld", + }, statusReply.Tags[0]) + + gtsTag := &gtsmodel.Tag{} + err = suite.db.GetWhere(context.Background(), []db.Where{{Key: "name", Value: "helloworld"}}, gtsTag) + suite.NoError(err) + suite.Equal(statusReply.Account.ID, gtsTag.FirstSeenFromAccountID) +} + +func (suite *StatusCreateTestSuite) TestPostNewStatusMarkdown() { + // set default post language of account 1 to markdown + testAccount := suite.testAccounts["local_account_1"] + testAccount.StatusFormat = "markdown" + a := testAccount + + err := suite.db.UpdateAccount(context.Background(), a) + if err != nil { + suite.FailNow(err.Error()) + } + suite.Equal(a.StatusFormat, "markdown") + + t := suite.testTokens["local_account_1"] + oauthToken := oauth.DBTokenToToken(t) + + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + 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, a) + + ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", statuses.BasePath), nil) + ctx.Request.Header.Set("accept", "application/json") + ctx.Request.Form = url.Values{ + "status": {statusMarkdown}, + "visibility": {string(apimodel.VisibilityPublic)}, + } + suite.statusModule.StatusCreatePOSTHandler(ctx) + + suite.EqualValues(http.StatusOK, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + suite.NoError(err) + + statusReply := &apimodel.Status{} + err = json.Unmarshal(b, statusReply) + suite.NoError(err) + + suite.Equal(statusMarkdownExpected, statusReply.Content) +} + +// mention an account that is not yet known to the instance -- it should be looked up and put in the db +func (suite *StatusCreateTestSuite) TestMentionUnknownAccount() { + // first remove remote account 1 from the database so it gets looked up again + remoteAccount := suite.testAccounts["remote_account_1"] + err := suite.db.DeleteAccount(context.Background(), remoteAccount.ID) + suite.NoError(err) + + t := suite.testTokens["local_account_1"] + oauthToken := oauth.DBTokenToToken(t) + + // setup + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + 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", statuses.BasePath), nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/json") + ctx.Request.Form = url.Values{ + "status": {"hello @brand_new_person@unknown-instance.com"}, + "visibility": {string(apimodel.VisibilityPublic)}, + } + suite.statusModule.StatusCreatePOSTHandler(ctx) + + suite.EqualValues(http.StatusOK, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + suite.NoError(err) + + statusReply := &apimodel.Status{} + err = json.Unmarshal(b, statusReply) + suite.NoError(err) + + // if the status is properly formatted, that means the account has been put in the db + suite.Equal(`<p>hello <span class="h-card"><a href="https://unknown-instance.com/@brand_new_person" class="u-url mention" rel="nofollow noreferrer noopener" target="_blank">@<span>brand_new_person</span></a></span></p>`, statusReply.Content) + suite.Equal(apimodel.VisibilityPublic, statusReply.Visibility) +} + +func (suite *StatusCreateTestSuite) TestPostAnotherNewStatus() { + t := suite.testTokens["local_account_1"] + oauthToken := oauth.DBTokenToToken(t) + + // setup + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + 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", statuses.BasePath), nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/json") + ctx.Request.Form = url.Values{ + "status": {statusWithLinksAndTags}, + } + 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) + suite.NoError(err) + + statusReply := &apimodel.Status{} + err = json.Unmarshal(b, statusReply) + suite.NoError(err) + + suite.Equal("<p><a href=\"http://localhost:8080/tags/test\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>test</span></a> alright, should be able to post <a href=\"http://localhost:8080/tags/links\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>links</span></a> with fragments in them now, let&#39;s see........<br/><br/><a href=\"https://docs.gotosocial.org/en/latest/user_guide/posts/#links\" rel=\"noopener nofollow noreferrer\" target=\"_blank\">docs.gotosocial.org/en/latest/user_guide/posts/#links</a><br/><br/><a href=\"http://localhost:8080/tags/gotosocial\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>gotosocial</span></a><br/><br/>(tobi remember to pull the docker image challenge)</p>", statusReply.Content) +} + +func (suite *StatusCreateTestSuite) TestPostNewStatusWithEmoji() { + t := suite.testTokens["local_account_1"] + oauthToken := oauth.DBTokenToToken(t) + + // setup + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + 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", statuses.BasePath), nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/json") + 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) + suite.NoError(err) + + statusReply := &apimodel.Status{} + err = json.Unmarshal(b, statusReply) + suite.NoError(err) + + suite.Equal("", statusReply.SpoilerText) + suite.Equal("<p>here is a rainbow emoji a few times! :rainbow: :rainbow: :rainbow: <br/> here&#39;s an emoji that isn&#39;t in the db: :test_emoji:</p>", statusReply.Content) + + suite.Len(statusReply.Emojis, 1) + apiEmoji := statusReply.Emojis[0] + gtsEmoji := testrig.NewTestEmojis()["rainbow"] + + suite.Equal(gtsEmoji.Shortcode, apiEmoji.Shortcode) + suite.Equal(gtsEmoji.ImageURL, apiEmoji.URL) + suite.Equal(gtsEmoji.ImageStaticURL, apiEmoji.StaticURL) +} + +// Try to reply to a status that doesn't exist +func (suite *StatusCreateTestSuite) TestReplyToNonexistentStatus() { + t := suite.testTokens["local_account_1"] + oauthToken := oauth.DBTokenToToken(t) + + // setup + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + 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", statuses.BasePath), nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/json") + 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) + suite.NoError(err) + suite.Equal(`{"error":"Bad Request: 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.DBTokenToToken(t) + + // setup + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + 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", statuses.BasePath), nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/json") + 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) + suite.NoError(err) + + statusReply := &apimodel.Status{} + err = json.Unmarshal(b, statusReply) + suite.NoError(err) + + suite.Equal("", statusReply.SpoilerText) + suite.Equal(fmt.Sprintf("<p>hello <span class=\"h-card\"><a href=\"http://localhost:8080/@%s\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>%s</span></a></span> this reply should work!</p>", testrig.NewTestAccounts()["local_account_2"].Username, testrig.NewTestAccounts()["local_account_2"].Username), statusReply.Content) + suite.False(statusReply.Sensitive) + suite.Equal(apimodel.VisibilityPublic, statusReply.Visibility) + suite.Equal(testrig.NewTestStatuses()["local_account_2_status_1"].ID, *statusReply.InReplyToID) + suite.Equal(testrig.NewTestAccounts()["local_account_2"].ID, *statusReply.InReplyToAccountID) + suite.Len(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.DBTokenToToken(t) + + attachment := suite.testAttachments["local_account_1_unattached_1"] + + // setup + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + 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", statuses.BasePath), nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/json") + ctx.Request.Form = url.Values{ + "status": {"here's an image attachment"}, + "media_ids[]": {attachment.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) + suite.NoError(err) + + statusResponse := &apimodel.Status{} + err = json.Unmarshal(b, statusResponse) + suite.NoError(err) + + suite.Equal("", statusResponse.SpoilerText) + suite.Equal("<p>here&#39;s an image attachment</p>", statusResponse.Content) + suite.False(statusResponse.Sensitive) + suite.Equal(apimodel.VisibilityPublic, statusResponse.Visibility) + + // there should be one media attachment + suite.Len(statusResponse.MediaAttachments, 1) + + // get the updated media attachment from the database + gtsAttachment, err := suite.db.GetAttachmentByID(context.Background(), statusResponse.MediaAttachments[0].ID) + suite.NoError(err) + + // convert it to a api attachment + gtsAttachmentAsapi, err := suite.tc.AttachmentToAPIAttachment(context.Background(), gtsAttachment) + suite.NoError(err) + + // compare it with what we have now + suite.EqualValues(statusResponse.MediaAttachments[0], gtsAttachmentAsapi) + + // the status id of the attachment should now be set to the id of the status we just created + suite.Equal(statusResponse.ID, gtsAttachment.StatusID) +} + +func TestStatusCreateTestSuite(t *testing.T) { + suite.Run(t, new(StatusCreateTestSuite)) +} diff --git a/internal/api/client/statuses/statusdelete.go b/internal/api/client/statuses/statusdelete.go @@ -0,0 +1,100 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 statuses + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// StatusDELETEHandler swagger:operation DELETE /api/v1/statuses/{id} statusDelete +// +// Delete status with the given ID. The status must belong to you. +// +// The deleted status will be returned in the response. The `text` field will contain the original text of the status as it was submitted. +// This is useful when doing a 'delete and redraft' type operation. +// +// --- +// tags: +// - statuses +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: string +// description: Target status ID. +// in: path +// required: true +// +// security: +// - OAuth2 Bearer: +// - write:statuses +// +// responses: +// '200': +// description: "The status that was just deleted." +// schema: +// "$ref": "#/definitions/status" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) StatusDELETEHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + targetStatusID := c.Param(IDKey) + if targetStatusID == "" { + err := errors.New("no status id specified") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + apiStatus, errWithCode := m.processor.StatusDelete(c.Request.Context(), authed, targetStatusID) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + c.JSON(http.StatusOK, apiStatus) +} diff --git a/internal/api/client/statuses/statusdelete_test.go b/internal/api/client/statuses/statusdelete_test.go @@ -0,0 +1,91 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 statuses_test + +import ( + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/statuses" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type StatusDeleteTestSuite struct { + StatusStandardTestSuite +} + +func (suite *StatusDeleteTestSuite) TestPostDelete() { + t := suite.testTokens["local_account_1"] + oauthToken := oauth.DBTokenToToken(t) + targetStatus := suite.testStatuses["local_account_1_status_1"] + + // setup + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + 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.MethodDelete, fmt.Sprintf("http://localhost:8080%s", strings.Replace(statuses.BasePathWithID, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/json") + + // 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: statuses.IDKey, + Value: targetStatus.ID, + }, + } + + suite.statusModule.StatusDELETEHandler(ctx) + + // check response + suite.EqualValues(http.StatusOK, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + suite.NoError(err) + + statusReply := &apimodel.Status{} + err = json.Unmarshal(b, statusReply) + suite.NoError(err) + suite.NotNil(statusReply) + + if !testrig.WaitFor(func() bool { + _, err := suite.db.GetStatusByID(ctx, targetStatus.ID) + return errors.Is(err, db.ErrNoEntries) + }) { + suite.FailNow("time out waiting for status to be deleted") + } + +} + +func TestStatusDeleteTestSuite(t *testing.T) { + suite.Run(t, new(StatusDeleteTestSuite)) +} diff --git a/internal/api/client/statuses/statusfave.go b/internal/api/client/statuses/statusfave.go @@ -0,0 +1,97 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 statuses + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// StatusFavePOSTHandler swagger:operation POST /api/v1/statuses/{id}/favourite statusFave +// +// Star/like/favourite the given status, if permitted. +// +// --- +// tags: +// - statuses +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: string +// description: Target status ID. +// in: path +// required: true +// +// security: +// - OAuth2 Bearer: +// - write:statuses +// +// responses: +// '200': +// description: "The newly faved status." +// schema: +// "$ref": "#/definitions/status" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) StatusFavePOSTHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + targetStatusID := c.Param(IDKey) + if targetStatusID == "" { + err := errors.New("no status id specified") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + apiStatus, errWithCode := m.processor.StatusFave(c.Request.Context(), authed, targetStatusID) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + c.JSON(http.StatusOK, apiStatus) +} diff --git a/internal/api/client/statuses/statusfave_test.go b/internal/api/client/statuses/statusfave_test.go @@ -0,0 +1,132 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 statuses_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/statuses" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type StatusFaveTestSuite struct { + StatusStandardTestSuite +} + +// fave a status +func (suite *StatusFaveTestSuite) TestPostFave() { + t := suite.testTokens["local_account_1"] + oauthToken := oauth.DBTokenToToken(t) + + targetStatus := suite.testStatuses["admin_account_status_2"] + + // setup + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + 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(statuses.FavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/json") + + // 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: statuses.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 := &apimodel.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(), apimodel.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.DBTokenToToken(t) + + targetStatus := suite.testStatuses["local_account_2_status_3"] // this one is unlikeable and unreplyable + + // setup + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + 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(statuses.FavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/json") + + // 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: statuses.IDKey, + Value: targetStatus.ID, + }, + } + + suite.statusModule.StatusFavePOSTHandler(ctx) + + // check response + suite.EqualValues(http.StatusForbidden, 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":"Forbidden"}`, string(b)) +} + +func TestStatusFaveTestSuite(t *testing.T) { + suite.Run(t, new(StatusFaveTestSuite)) +} diff --git a/internal/api/client/statuses/statusfavedby.go b/internal/api/client/statuses/statusfavedby.go @@ -0,0 +1,98 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 statuses + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// StatusFavedByGETHandler swagger:operation GET /api/v1/statuses/{id}/favourited_by statusFavedBy +// +// View accounts that have faved/starred/liked the target status. +// +// --- +// tags: +// - statuses +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: string +// description: Target status ID. +// in: path +// required: true +// +// security: +// - OAuth2 Bearer: +// - read:accounts +// +// responses: +// '200': +// schema: +// type: array +// items: +// "$ref": "#/definitions/account" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) StatusFavedByGETHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + targetStatusID := c.Param(IDKey) + if targetStatusID == "" { + err := errors.New("no status id specified") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + apiAccounts, errWithCode := m.processor.StatusFavedBy(c.Request.Context(), authed, targetStatusID) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + c.JSON(http.StatusOK, apiAccounts) +} diff --git a/internal/api/client/statuses/statusfavedby_test.go b/internal/api/client/statuses/statusfavedby_test.go @@ -0,0 +1,88 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 statuses_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/statuses" + apimodel "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) TestGetFavedBy() { + t := suite.testTokens["local_account_2"] + oauthToken := oauth.DBTokenToToken(t) + + targetStatus := suite.testStatuses["admin_account_status_1"] // this status is faved by local_account_1 + + // setup + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + 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(statuses.FavouritedPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/json") + + // 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: statuses.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 := []apimodel.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/statuses/statusget.go b/internal/api/client/statuses/statusget.go @@ -0,0 +1,97 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 statuses + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// StatusGETHandler swagger:operation GET /api/v1/statuses/{id} statusGet +// +// View status with the given ID. +// +// --- +// tags: +// - statuses +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: string +// description: Target status ID. +// in: path +// required: true +// +// security: +// - OAuth2 Bearer: +// - read:statuses +// +// responses: +// '200': +// description: "The requested status." +// schema: +// "$ref": "#/definitions/status" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) StatusGETHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + targetStatusID := c.Param(IDKey) + if targetStatusID == "" { + err := errors.New("no status id specified") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + apiStatus, errWithCode := m.processor.StatusGet(c.Request.Context(), authed, targetStatusID) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + c.JSON(http.StatusOK, apiStatus) +} diff --git a/internal/api/client/statuses/statusget_test.go b/internal/api/client/statuses/statusget_test.go @@ -0,0 +1,33 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 statuses_test + +import ( + "testing" + + "github.com/stretchr/testify/suite" +) + +type StatusGetTestSuite struct { + StatusStandardTestSuite +} + +func TestStatusGetTestSuite(t *testing.T) { + suite.Run(t, new(StatusGetTestSuite)) +} diff --git a/internal/api/client/statuses/statusunbookmark.go b/internal/api/client/statuses/statusunbookmark.go @@ -0,0 +1,98 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 statuses + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// StatusUnbookmarkPOSTHandler swagger:operation POST /api/v1/statuses/{id}/unbookmark statusUnbookmark +// +// Unbookmark status with the given ID. +// +// --- +// tags: +// - statuses +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: string +// description: Target status ID. +// in: path +// required: true +// +// security: +// - OAuth2 Bearer: +// - write:statuses +// +// responses: +// '200': +// name: status +// description: The status. +// schema: +// "$ref": "#/definitions/status" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) StatusUnbookmarkPOSTHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + targetStatusID := c.Param(IDKey) + if targetStatusID == "" { + err := errors.New("no status id specified") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + apiStatus, errWithCode := m.processor.StatusUnbookmark(c.Request.Context(), authed, targetStatusID) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + c.JSON(http.StatusOK, apiStatus) +} diff --git a/internal/api/client/statuses/statusunbookmark_test.go b/internal/api/client/statuses/statusunbookmark_test.go @@ -0,0 +1,78 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 statuses_test + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/statuses" + "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type StatusUnbookmarkTestSuite struct { + StatusStandardTestSuite +} + +func (suite *StatusUnbookmarkTestSuite) TestPostUnbookmark() { + t := suite.testTokens["local_account_1"] + oauthToken := oauth.DBTokenToToken(t) + + targetStatus := suite.testStatuses["admin_account_status_1"] + + // setup + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + 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(statuses.UnbookmarkPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/json") + + ctx.Params = gin.Params{ + gin.Param{ + Key: statuses.IDKey, + Value: targetStatus.ID, + }, + } + + suite.statusModule.StatusUnbookmarkPOSTHandler(ctx) + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + suite.NoError(err) + + statusReply := &model.Status{} + err = json.Unmarshal(b, statusReply) + suite.NoError(err) + + suite.False(statusReply.Bookmarked) +} + +func TestStatusUnbookmarkTestSuite(t *testing.T) { + suite.Run(t, new(StatusUnbookmarkTestSuite)) +} diff --git a/internal/api/client/statuses/statusunboost.go b/internal/api/client/statuses/statusunboost.go @@ -0,0 +1,98 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 statuses + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// StatusUnboostPOSTHandler swagger:operation POST /api/v1/statuses/{id}/unreblog statusUnreblog +// +// Unreblog/unboost status with the given ID. +// +// --- +// tags: +// - statuses +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: string +// description: Target status ID. +// in: path +// required: true +// +// security: +// - OAuth2 Bearer: +// - write:statuses +// +// responses: +// '200': +// name: status +// description: The unboosted status. +// schema: +// "$ref": "#/definitions/status" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) StatusUnboostPOSTHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + targetStatusID := c.Param(IDKey) + if targetStatusID == "" { + err := errors.New("no status id specified") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + apiStatus, errWithCode := m.processor.StatusUnboost(c.Request.Context(), authed, targetStatusID) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + c.JSON(http.StatusOK, apiStatus) +} diff --git a/internal/api/client/statuses/statusunfave.go b/internal/api/client/statuses/statusunfave.go @@ -0,0 +1,97 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 statuses + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// StatusUnfavePOSTHandler swagger:operation POST /api/v1/statuses/{id}/unfavourite statusUnfave +// +// Unstar/unlike/unfavourite the given status. +// +// --- +// tags: +// - statuses +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: string +// description: Target status ID. +// in: path +// required: true +// +// security: +// - OAuth2 Bearer: +// - write:statuses +// +// responses: +// '200': +// description: "The unfaved status." +// schema: +// "$ref": "#/definitions/status" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) StatusUnfavePOSTHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + targetStatusID := c.Param(IDKey) + if targetStatusID == "" { + err := errors.New("no status id specified") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + apiStatus, errWithCode := m.processor.StatusUnfave(c.Request.Context(), authed, targetStatusID) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + c.JSON(http.StatusOK, apiStatus) +} diff --git a/internal/api/client/statuses/statusunfave_test.go b/internal/api/client/statuses/statusunfave_test.go @@ -0,0 +1,143 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 statuses_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/statuses" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type StatusUnfaveTestSuite struct { + StatusStandardTestSuite +} + +// unfave a status +func (suite *StatusUnfaveTestSuite) TestPostUnfave() { + t := suite.testTokens["local_account_1"] + oauthToken := oauth.DBTokenToToken(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, _ := testrig.CreateGinTestContext(recorder, nil) + 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(statuses.UnfavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/json") + + // 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: statuses.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 := &apimodel.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(), apimodel.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.DBTokenToToken(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, _ := testrig.CreateGinTestContext(recorder, nil) + 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(statuses.UnfavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/json") + + // 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: statuses.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 := &apimodel.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(), apimodel.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/client/streaming/stream.go b/internal/api/client/streaming/stream.go @@ -1,3 +1,21 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 streaming import ( @@ -6,7 +24,7 @@ import ( "time" "codeberg.org/gruf/go-kv" - "github.com/superseriousbusiness/gotosocial/internal/api" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/log" @@ -14,12 +32,15 @@ import ( "github.com/gorilla/websocket" ) -var wsUpgrader = websocket.Upgrader{ - ReadBufferSize: 1024, - WriteBufferSize: 1024, - // we expect cors requests (via eg., pinafore.social) so be lenient - CheckOrigin: func(r *http.Request) bool { return true }, -} +var ( + wsUpgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + // we expect cors requests (via eg., pinafore.social) so be lenient + CheckOrigin: func(r *http.Request) bool { return true }, + } + errNoToken = fmt.Errorf("no access token provided under query key %s or under header %s", AccessTokenQueryKey, AccessTokenHeader) +) // StreamGETHandler swagger:operation GET /api/v1/streaming streamGet // @@ -125,29 +146,33 @@ func (m *Module) StreamGETHandler(c *gin.Context) { streamType := c.Query(StreamQueryKey) if streamType == "" { err := fmt.Errorf("no stream type provided under query key %s", StreamQueryKey) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } - accessToken := c.Query(AccessTokenQueryKey) - if accessToken == "" { - accessToken = c.GetHeader(AccessTokenHeader) - } - if accessToken == "" { - err := fmt.Errorf("no access token provided under query key %s or under header %s", AccessTokenQueryKey, AccessTokenHeader) - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + var accessToken string + if t := c.Query(AccessTokenQueryKey); t != "" { + // try query param first + accessToken = t + } else if t := c.GetHeader(AccessTokenHeader); t != "" { + // fall back to Sec-Websocket-Protocol + accessToken = t + } else { + // no token + err := errNoToken + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } account, errWithCode := m.processor.AuthorizeStreamingRequest(c.Request.Context(), accessToken) if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } stream, errWithCode := m.processor.OpenStreamForAccount(c.Request.Context(), account, streamType) if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } @@ -175,6 +200,7 @@ func (m *Module) StreamGETHandler(c *gin.Context) { }() streamTicker := time.NewTicker(m.tickDuration) + defer streamTicker.Stop() // We want to stay in the loop as long as possible while the client is connected. // The only thing that should break the loop is if the client leaves or the connection becomes unhealthy. diff --git a/internal/api/client/streaming/streaming.go b/internal/api/client/streaming/streaming.go @@ -22,14 +22,13 @@ import ( "net/http" "time" - "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/processing" - "github.com/superseriousbusiness/gotosocial/internal/router" ) const ( - // BasePath is the path for the streaming api - BasePath = "/api/v1/streaming" + // BasePath is the path for the streaming api, minus the 'api' prefix + BasePath = "/v1/streaming" // StreamQueryKey is the query key for the type of stream being requested StreamQueryKey = "stream" @@ -41,29 +40,25 @@ const ( AccessTokenHeader = "Sec-Websocket-Protocol" ) -// Module implements the api.ClientModule interface for everything related to streaming type Module struct { processor processing.Processor tickDuration time.Duration } -// New returns a new streaming module -func New(processor processing.Processor) api.ClientModule { +func New(processor processing.Processor) *Module { return &Module{ processor: processor, tickDuration: 30 * time.Second, } } -func NewWithTickDuration(processor processing.Processor, tickDuration time.Duration) api.ClientModule { +func NewWithTickDuration(processor processing.Processor, tickDuration time.Duration) *Module { return &Module{ processor: processor, tickDuration: tickDuration, } } -// Route attaches all routes from this module to the given router -func (m *Module) Route(r router.Router) error { - r.AttachHandler(http.MethodGet, BasePath, m.StreamGETHandler) - return nil +func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) { + attachHandler(http.MethodGet, BasePath, m.StreamGETHandler) } diff --git a/internal/api/client/streaming/streaming_test.go b/internal/api/client/streaming/streaming_test.go @@ -99,7 +99,7 @@ func (suite *StreamingTestSuite) SetupTest() { suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker), suite.storage, suite.mediaManager, fedWorker) suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil) suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, suite.mediaManager, clientWorker, fedWorker) - suite.streamingModule = streaming.NewWithTickDuration(suite.processor, 1).(*streaming.Module) + suite.streamingModule = streaming.NewWithTickDuration(suite.processor, 1) suite.NoError(suite.processor.Start()) } diff --git a/internal/api/client/timeline/home.go b/internal/api/client/timeline/home.go @@ -1,176 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 timeline - -import ( - "fmt" - "net/http" - "strconv" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// HomeTimelineGETHandler swagger:operation GET /api/v1/timelines/home homeTimeline -// -// See statuses/posts by accounts you follow. -// -// The statuses will be returned in descending chronological order (newest first), with sequential IDs (bigger = newer). -// -// The returned Link header can be used to generate the previous and next queries when scrolling up or down a timeline. -// -// Example: -// -// ``` -// <https://example.org/api/v1/timelines/home?limit=20&max_id=01FC3GSQ8A3MMJ43BPZSGEG29M>; rel="next", <https://example.org/api/v1/timelines/home?limit=20&min_id=01FC3KJW2GYXSDDRA6RWNDM46M>; rel="prev" -// ```` -// -// --- -// tags: -// - timelines -// -// produces: -// - application/json -// -// parameters: -// - -// name: max_id -// type: string -// description: >- -// Return only statuses *OLDER* than the given max status ID. -// The status with the specified ID will not be included in the response. -// in: query -// required: false -// - -// name: since_id -// type: string -// description: >- -// Return only statuses *NEWER* than the given since status ID. -// The status with the specified ID will not be included in the response. -// in: query -// - -// name: min_id -// type: string -// description: >- -// Return only statuses *NEWER* than the given since status ID. -// The status with the specified ID will not be included in the response. -// in: query -// required: false -// - -// name: limit -// type: integer -// description: Number of statuses to return. -// default: 20 -// in: query -// required: false -// - -// name: local -// type: boolean -// description: Show only statuses posted by local accounts. -// default: false -// in: query -// required: false -// -// security: -// - OAuth2 Bearer: -// - read:statuses -// -// responses: -// '200': -// name: statuses -// description: Array of statuses. -// schema: -// type: array -// items: -// "$ref": "#/definitions/status" -// headers: -// Link: -// type: string -// description: Links to the next and previous queries. -// '401': -// description: unauthorized -// '400': -// description: bad request -func (m *Module) HomeTimelineGETHandler(c *gin.Context) { - authed, err := oauth.Authed(c, true, true, true, true) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) - return - } - - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - maxID := "" - maxIDString := c.Query(MaxIDKey) - if maxIDString != "" { - maxID = maxIDString - } - - sinceID := "" - sinceIDString := c.Query(SinceIDKey) - if sinceIDString != "" { - sinceID = sinceIDString - } - - minID := "" - minIDString := c.Query(MinIDKey) - if minIDString != "" { - minID = minIDString - } - - limit := 20 - limitString := c.Query(LimitKey) - if limitString != "" { - i, err := strconv.ParseInt(limitString, 10, 32) - if err != nil { - err := fmt.Errorf("error parsing %s: %s", LimitKey, err) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - limit = int(i) - } - - local := false - localString := c.Query(LocalKey) - if localString != "" { - i, err := strconv.ParseBool(localString) - if err != nil { - err := fmt.Errorf("error parsing %s: %s", LocalKey, err) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - local = i - } - - resp, errWithCode := m.processor.HomeTimelineGet(c.Request.Context(), authed, maxID, sinceID, minID, limit, local) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - if resp.LinkHeader != "" { - c.Header("Link", resp.LinkHeader) - } - c.JSON(http.StatusOK, resp.Items) -} diff --git a/internal/api/client/timeline/public.go b/internal/api/client/timeline/public.go @@ -1,187 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 timeline - -import ( - "fmt" - "net/http" - "strconv" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// PublicTimelineGETHandler swagger:operation GET /api/v1/timelines/public publicTimeline -// -// See public statuses/posts that your instance is aware of. -// -// The statuses will be returned in descending chronological order (newest first), with sequential IDs (bigger = newer). -// -// The returned Link header can be used to generate the previous and next queries when scrolling up or down a timeline. -// -// Example: -// -// ``` -// <https://example.org/api/v1/timelines/public?limit=20&max_id=01FC3GSQ8A3MMJ43BPZSGEG29M>; rel="next", <https://example.org/api/v1/timelines/public?limit=20&min_id=01FC3KJW2GYXSDDRA6RWNDM46M>; rel="prev" -// ```` -// -// --- -// tags: -// - timelines -// -// produces: -// - application/json -// -// parameters: -// - -// name: max_id -// type: string -// description: >- -// Return only statuses *OLDER* than the given max status ID. -// The status with the specified ID will not be included in the response. -// in: query -// required: false -// - -// name: since_id -// type: string -// description: >- -// Return only statuses *NEWER* than the given since status ID. -// The status with the specified ID will not be included in the response. -// in: query -// - -// name: min_id -// type: string -// description: >- -// Return only statuses *NEWER* than the given since status ID. -// The status with the specified ID will not be included in the response. -// in: query -// required: false -// - -// name: limit -// type: integer -// description: Number of statuses to return. -// default: 20 -// in: query -// required: false -// - -// name: local -// type: boolean -// description: Show only statuses posted by local accounts. -// default: false -// in: query -// required: false -// -// security: -// - OAuth2 Bearer: -// - read:statuses -// -// responses: -// '200': -// name: statuses -// description: Array of statuses. -// schema: -// type: array -// items: -// "$ref": "#/definitions/status" -// headers: -// Link: -// type: string -// description: Links to the next and previous queries. -// '401': -// description: unauthorized -// '400': -// description: bad request -func (m *Module) PublicTimelineGETHandler(c *gin.Context) { - var authed *oauth.Auth - var err error - - if config.GetInstanceExposePublicTimeline() { - // If the public timeline is allowed to be exposed, still check if we - // can extract various authentication properties, but don't require them. - authed, err = oauth.Authed(c, false, false, false, false) - } else { - authed, err = oauth.Authed(c, true, true, true, true) - } - - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) - return - } - - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - maxID := "" - maxIDString := c.Query(MaxIDKey) - if maxIDString != "" { - maxID = maxIDString - } - - sinceID := "" - sinceIDString := c.Query(SinceIDKey) - if sinceIDString != "" { - sinceID = sinceIDString - } - - minID := "" - minIDString := c.Query(MinIDKey) - if minIDString != "" { - minID = minIDString - } - - limit := 20 - limitString := c.Query(LimitKey) - if limitString != "" { - i, err := strconv.ParseInt(limitString, 10, 32) - if err != nil { - err := fmt.Errorf("error parsing %s: %s", LimitKey, err) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - limit = int(i) - } - - local := false - localString := c.Query(LocalKey) - if localString != "" { - i, err := strconv.ParseBool(localString) - if err != nil { - err := fmt.Errorf("error parsing %s: %s", LocalKey, err) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - local = i - } - - resp, errWithCode := m.processor.PublicTimelineGet(c.Request.Context(), authed, maxID, sinceID, minID, limit, local) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - if resp.LinkHeader != "" { - c.Header("Link", resp.LinkHeader) - } - c.JSON(http.StatusOK, resp.Items) -} diff --git a/internal/api/client/timeline/timeline.go b/internal/api/client/timeline/timeline.go @@ -1,65 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 timeline - -import ( - "net/http" - - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/processing" - "github.com/superseriousbusiness/gotosocial/internal/router" -) - -const ( - // BasePath is the base URI path for serving timelines - BasePath = "/api/v1/timelines" - // HomeTimeline is the path for the home timeline - HomeTimeline = BasePath + "/home" - // PublicTimeline is the path for the public (and public local) timeline - PublicTimeline = BasePath + "/public" - // MaxIDKey is the url query for setting a max status ID to return - MaxIDKey = "max_id" - // SinceIDKey is the url query for returning results newer than the given ID - SinceIDKey = "since_id" - // MinIDKey is the url query for returning results immediately newer than the given ID - MinIDKey = "min_id" - // LimitKey is for specifying maximum number of results to return. - LimitKey = "limit" - // LocalKey is for specifying whether only local statuses should be returned - LocalKey = "local" -) - -// Module implements the ClientAPIModule interface for everything relating to viewing timelines -type Module struct { - processor processing.Processor -} - -// New returns a new timeline module -func New(processor processing.Processor) api.ClientModule { - return &Module{ - processor: processor, - } -} - -// Route attaches all routes from this module to the given router -func (m *Module) Route(r router.Router) error { - r.AttachHandler(http.MethodGet, HomeTimeline, m.HomeTimelineGETHandler) - r.AttachHandler(http.MethodGet, PublicTimeline, m.PublicTimelineGETHandler) - return nil -} diff --git a/internal/api/client/timelines/home.go b/internal/api/client/timelines/home.go @@ -0,0 +1,176 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 timelines + +import ( + "fmt" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// HomeTimelineGETHandler swagger:operation GET /api/v1/timelines/home homeTimeline +// +// See statuses/posts by accounts you follow. +// +// The statuses will be returned in descending chronological order (newest first), with sequential IDs (bigger = newer). +// +// The returned Link header can be used to generate the previous and next queries when scrolling up or down a timeline. +// +// Example: +// +// ``` +// <https://example.org/api/v1/timelines/home?limit=20&max_id=01FC3GSQ8A3MMJ43BPZSGEG29M>; rel="next", <https://example.org/api/v1/timelines/home?limit=20&min_id=01FC3KJW2GYXSDDRA6RWNDM46M>; rel="prev" +// ```` +// +// --- +// tags: +// - timelines +// +// produces: +// - application/json +// +// parameters: +// - +// name: max_id +// type: string +// description: >- +// Return only statuses *OLDER* than the given max status ID. +// The status with the specified ID will not be included in the response. +// in: query +// required: false +// - +// name: since_id +// type: string +// description: >- +// Return only statuses *NEWER* than the given since status ID. +// The status with the specified ID will not be included in the response. +// in: query +// - +// name: min_id +// type: string +// description: >- +// Return only statuses *NEWER* than the given since status ID. +// The status with the specified ID will not be included in the response. +// in: query +// required: false +// - +// name: limit +// type: integer +// description: Number of statuses to return. +// default: 20 +// in: query +// required: false +// - +// name: local +// type: boolean +// description: Show only statuses posted by local accounts. +// default: false +// in: query +// required: false +// +// security: +// - OAuth2 Bearer: +// - read:statuses +// +// responses: +// '200': +// name: statuses +// description: Array of statuses. +// schema: +// type: array +// items: +// "$ref": "#/definitions/status" +// headers: +// Link: +// type: string +// description: Links to the next and previous queries. +// '401': +// description: unauthorized +// '400': +// description: bad request +func (m *Module) HomeTimelineGETHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + maxID := "" + maxIDString := c.Query(MaxIDKey) + if maxIDString != "" { + maxID = maxIDString + } + + sinceID := "" + sinceIDString := c.Query(SinceIDKey) + if sinceIDString != "" { + sinceID = sinceIDString + } + + minID := "" + minIDString := c.Query(MinIDKey) + if minIDString != "" { + minID = minIDString + } + + limit := 20 + limitString := c.Query(LimitKey) + if limitString != "" { + i, err := strconv.ParseInt(limitString, 10, 32) + if err != nil { + err := fmt.Errorf("error parsing %s: %s", LimitKey, err) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + limit = int(i) + } + + local := false + localString := c.Query(LocalKey) + if localString != "" { + i, err := strconv.ParseBool(localString) + if err != nil { + err := fmt.Errorf("error parsing %s: %s", LocalKey, err) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + local = i + } + + resp, errWithCode := m.processor.HomeTimelineGet(c.Request.Context(), authed, maxID, sinceID, minID, limit, local) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + if resp.LinkHeader != "" { + c.Header("Link", resp.LinkHeader) + } + c.JSON(http.StatusOK, resp.Items) +} diff --git a/internal/api/client/timelines/public.go b/internal/api/client/timelines/public.go @@ -0,0 +1,187 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 timelines + +import ( + "fmt" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// PublicTimelineGETHandler swagger:operation GET /api/v1/timelines/public publicTimeline +// +// See public statuses/posts that your instance is aware of. +// +// The statuses will be returned in descending chronological order (newest first), with sequential IDs (bigger = newer). +// +// The returned Link header can be used to generate the previous and next queries when scrolling up or down a timeline. +// +// Example: +// +// ``` +// <https://example.org/api/v1/timelines/public?limit=20&max_id=01FC3GSQ8A3MMJ43BPZSGEG29M>; rel="next", <https://example.org/api/v1/timelines/public?limit=20&min_id=01FC3KJW2GYXSDDRA6RWNDM46M>; rel="prev" +// ```` +// +// --- +// tags: +// - timelines +// +// produces: +// - application/json +// +// parameters: +// - +// name: max_id +// type: string +// description: >- +// Return only statuses *OLDER* than the given max status ID. +// The status with the specified ID will not be included in the response. +// in: query +// required: false +// - +// name: since_id +// type: string +// description: >- +// Return only statuses *NEWER* than the given since status ID. +// The status with the specified ID will not be included in the response. +// in: query +// - +// name: min_id +// type: string +// description: >- +// Return only statuses *NEWER* than the given since status ID. +// The status with the specified ID will not be included in the response. +// in: query +// required: false +// - +// name: limit +// type: integer +// description: Number of statuses to return. +// default: 20 +// in: query +// required: false +// - +// name: local +// type: boolean +// description: Show only statuses posted by local accounts. +// default: false +// in: query +// required: false +// +// security: +// - OAuth2 Bearer: +// - read:statuses +// +// responses: +// '200': +// name: statuses +// description: Array of statuses. +// schema: +// type: array +// items: +// "$ref": "#/definitions/status" +// headers: +// Link: +// type: string +// description: Links to the next and previous queries. +// '401': +// description: unauthorized +// '400': +// description: bad request +func (m *Module) PublicTimelineGETHandler(c *gin.Context) { + var authed *oauth.Auth + var err error + + if config.GetInstanceExposePublicTimeline() { + // If the public timeline is allowed to be exposed, still check if we + // can extract various authentication properties, but don't require them. + authed, err = oauth.Authed(c, false, false, false, false) + } else { + authed, err = oauth.Authed(c, true, true, true, true) + } + + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + maxID := "" + maxIDString := c.Query(MaxIDKey) + if maxIDString != "" { + maxID = maxIDString + } + + sinceID := "" + sinceIDString := c.Query(SinceIDKey) + if sinceIDString != "" { + sinceID = sinceIDString + } + + minID := "" + minIDString := c.Query(MinIDKey) + if minIDString != "" { + minID = minIDString + } + + limit := 20 + limitString := c.Query(LimitKey) + if limitString != "" { + i, err := strconv.ParseInt(limitString, 10, 32) + if err != nil { + err := fmt.Errorf("error parsing %s: %s", LimitKey, err) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + limit = int(i) + } + + local := false + localString := c.Query(LocalKey) + if localString != "" { + i, err := strconv.ParseBool(localString) + if err != nil { + err := fmt.Errorf("error parsing %s: %s", LocalKey, err) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + local = i + } + + resp, errWithCode := m.processor.PublicTimelineGet(c.Request.Context(), authed, maxID, sinceID, minID, limit, local) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + if resp.LinkHeader != "" { + c.Header("Link", resp.LinkHeader) + } + c.JSON(http.StatusOK, resp.Items) +} diff --git a/internal/api/client/timelines/timeline.go b/internal/api/client/timelines/timeline.go @@ -0,0 +1,60 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 timelines + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/processing" +) + +const ( + // BasePath is the base URI path for serving timelines, minus the 'api' prefix. + BasePath = "/v1/timelines" + // HomeTimeline is the path for the home timeline + HomeTimeline = BasePath + "/home" + // PublicTimeline is the path for the public (and public local) timeline + PublicTimeline = BasePath + "/public" + // MaxIDKey is the url query for setting a max status ID to return + MaxIDKey = "max_id" + // SinceIDKey is the url query for returning results newer than the given ID + SinceIDKey = "since_id" + // MinIDKey is the url query for returning results immediately newer than the given ID + MinIDKey = "min_id" + // LimitKey is for specifying maximum number of results to return. + LimitKey = "limit" + // LocalKey is for specifying whether only local statuses should be returned + LocalKey = "local" +) + +type Module struct { + processor processing.Processor +} + +func New(processor processing.Processor) *Module { + return &Module{ + processor: processor, + } +} + +func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) { + attachHandler(http.MethodGet, HomeTimeline, m.HomeTimelineGETHandler) + attachHandler(http.MethodGet, PublicTimeline, m.PublicTimelineGETHandler) +} diff --git a/internal/api/client/user/passwordchange.go b/internal/api/client/user/passwordchange.go @@ -23,8 +23,8 @@ import ( "net/http" "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/api/model" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -68,35 +68,35 @@ import ( func (m *Module) PasswordChangePOSTHandler(c *gin.Context) { authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } - form := &model.PasswordChangeRequest{} + form := &apimodel.PasswordChangeRequest{} if err := c.ShouldBind(form); err != nil { - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } if form.OldPassword == "" { err := errors.New("password change request missing field old_password") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } if form.NewPassword == "" { err := errors.New("password change request missing field new_password") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } if errWithCode := m.processor.UserChangePassword(c.Request.Context(), authed, form); errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/user/user.go b/internal/api/client/user/user.go @@ -21,32 +21,27 @@ package user import ( "net/http" - "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/processing" - "github.com/superseriousbusiness/gotosocial/internal/router" ) const ( - // BasePath is the base URI path for this module - BasePath = "/api/v1/user" + // BasePath is the base URI path for this module, minus the 'api' prefix + BasePath = "/v1/user" // PasswordChangePath is the path for POSTing a password change request. PasswordChangePath = BasePath + "/password_change" ) -// Module implements the ClientAPIModule interface type Module struct { processor processing.Processor } -// New returns a new user module -func New(processor processing.Processor) api.ClientModule { +func New(processor processing.Processor) *Module { return &Module{ processor: processor, } } -// Route attaches all routes from this module to the given router -func (m *Module) Route(r router.Router) error { - r.AttachHandler(http.MethodPost, PasswordChangePath, m.PasswordChangePOSTHandler) - return nil +func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) { + attachHandler(http.MethodPost, PasswordChangePath, m.PasswordChangePOSTHandler) } diff --git a/internal/api/client/user/user_test.go b/internal/api/client/user/user_test.go @@ -73,7 +73,7 @@ func (suite *UserStandardTestSuite) SetupTest() { suite.sentEmails = make(map[string]string) suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails) suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, suite.mediaManager, clientWorker, fedWorker) - suite.userModule = user.New(suite.processor).(*user.Module) + suite.userModule = user.New(suite.processor) testrig.StandardDBSetup(suite.db, suite.testAccounts) testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") diff --git a/internal/api/errorhandling.go b/internal/api/errorhandling.go @@ -1,132 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 ( - "context" - "net/http" - - "codeberg.org/gruf/go-kv" - "github.com/gin-gonic/gin" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/log" -) - -// TODO: add more templated html pages here for different error types - -// NotFoundHandler serves a 404 html page through the provided gin context, -// if accept is 'text/html', or just returns a json error if 'accept' is empty -// or application/json. -// -// When serving html, NotFoundHandler calls the provided InstanceGet function -// to fetch the apimodel representation of the instance, for serving in the -// 404 header and footer. -// -// If an error is returned by InstanceGet, the function will panic. -func NotFoundHandler(c *gin.Context, instanceGet func(ctx context.Context, domain string) (*apimodel.Instance, gtserror.WithCode), accept string) { - switch accept { - case string(TextHTML): - host := config.GetHost() - instance, err := instanceGet(c.Request.Context(), host) - if err != nil { - panic(err) - } - - c.HTML(http.StatusNotFound, "404.tmpl", gin.H{ - "instance": instance, - }) - default: - c.JSON(http.StatusNotFound, gin.H{"error": http.StatusText(http.StatusNotFound)}) - } -} - -// genericErrorHandler is a more general version of the NotFoundHandler, which can -// be used for serving either generic error pages with some rendered help text, -// or just some error json if the caller prefers (or has no preference). -func genericErrorHandler(c *gin.Context, instanceGet func(ctx context.Context, domain string) (*apimodel.Instance, gtserror.WithCode), accept string, errWithCode gtserror.WithCode) { - switch accept { - case string(TextHTML): - host := config.GetHost() - instance, err := instanceGet(c.Request.Context(), host) - if err != nil { - panic(err) - } - - c.HTML(errWithCode.Code(), "error.tmpl", gin.H{ - "instance": instance, - "code": errWithCode.Code(), - "error": errWithCode.Safe(), - }) - default: - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) - } -} - -// ErrorHandler takes the provided gin context and errWithCode and tries to serve -// a helpful error to the caller. It will do content negotiation to figure out if -// the caller prefers to see an html page with the error rendered there. If not, or -// if something goes wrong during the function, it will recover and just try to serve -// an appropriate application/json content-type error. -func ErrorHandler(c *gin.Context, errWithCode gtserror.WithCode, instanceGet func(ctx context.Context, domain string) (*apimodel.Instance, gtserror.WithCode)) { - // set the error on the gin context so that it can be logged - // in the gin logger middleware (internal/router/logger.go) - c.Error(errWithCode) //nolint:errcheck - - // discover if we're allowed to serve a nice html error page, - // or if we should just use a json. Normally we would want to - // check for a returned error, but if an error occurs here we - // can just fall back to default behavior (serve json error). - accept, _ := NegotiateAccept(c, HTMLOrJSONAcceptHeaders...) - - if errWithCode.Code() == http.StatusNotFound { - // use our special not found handler with useful status text - NotFoundHandler(c, instanceGet, accept) - } else { - genericErrorHandler(c, instanceGet, accept, errWithCode) - } -} - -// OAuthErrorHandler is a lot like ErrorHandler, but it specifically returns errors -// that are compatible with https://datatracker.ietf.org/doc/html/rfc6749#section-5.2, -// but serializing errWithCode.Error() in the 'error' field, and putting any help text -// from the error in the 'error_description' field. This means you should be careful not -// to pass any detailed errors (that might contain sensitive information) into the -// errWithCode.Error() field, since the client will see this. Use your noggin! -func OAuthErrorHandler(c *gin.Context, errWithCode gtserror.WithCode) { - l := log.WithFields(kv.Fields{ - {"path", c.Request.URL.Path}, - {"error", errWithCode.Error()}, - {"help", errWithCode.Safe()}, - }...) - - statusCode := errWithCode.Code() - - if statusCode == http.StatusInternalServerError { - l.Error("Internal Server Error") - } else { - l.Debug("handling OAuth error") - } - - c.JSON(statusCode, gin.H{ - "error": errWithCode.Error(), - "error_description": errWithCode.Safe(), - }) -} diff --git a/internal/api/fileserver.go b/internal/api/fileserver.go @@ -0,0 +1,55 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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/api/fileserver" + "github.com/superseriousbusiness/gotosocial/internal/middleware" + "github.com/superseriousbusiness/gotosocial/internal/processing" + "github.com/superseriousbusiness/gotosocial/internal/router" +) + +type Fileserver struct { + fileserver *fileserver.Module +} + +func (f *Fileserver) Route(r router.Router) { + fileserverGroup := r.AttachGroup("fileserver") + + // attach middlewares appropriate for this group + fileserverGroup.Use( + middleware.RateLimit(), + // Since we'll never host different files at the same + // URL (bc the ULIDs are generated per piece of media), + // it's sensible and safe to use a long cache here, so + // that clients don't keep fetching files over + over again. + // + // Nevertheless, we should use 'private' to indicate + // that there might be media in there which are gated by ACLs. + middleware.CacheControl("private", "max-age=604800"), + ) + + f.fileserver.Route(fileserverGroup.Handle) +} + +func NewFileserver(p processing.Processor) *Fileserver { + return &Fileserver{ + fileserver: fileserver.New(p), + } +} diff --git a/internal/api/fileserver/fileserver.go b/internal/api/fileserver/fileserver.go @@ -0,0 +1,54 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/processing" +) + +const ( + // AccountIDKey is the url key for account id (an account ulid) + 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" + // FileServePath is the fileserve path minus the 'fileserver' prefix. + FileServePath = "/:" + AccountIDKey + "/:" + MediaTypeKey + "/:" + MediaSizeKey + "/:" + FileNameKey +) + +type Module struct { + processor processing.Processor +} + +func New(processor processing.Processor) *Module { + return &Module{ + processor: processor, + } +} + +func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) { + attachHandler(http.MethodGet, FileServePath, m.ServeFile) + attachHandler(http.MethodHead, FileServePath, m.ServeFile) +} diff --git a/internal/api/fileserver/fileserver_test.go b/internal/api/fileserver/fileserver_test.go @@ -0,0 +1,109 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/fileserver" + "github.com/superseriousbusiness/gotosocial/internal/concurrency" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/email" + "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/log" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/messages" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/processing" + "github.com/superseriousbusiness/gotosocial/internal/storage" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type FileserverTestSuite struct { + // standard suite interfaces + suite.Suite + db db.DB + storage *storage.Driver + federator federation.Federator + tc typeutils.TypeConverter + processor processing.Processor + mediaManager media.Manager + oauthServer oauth.Server + emailSender email.Sender + + // standard suite models + testTokens map[string]*gtsmodel.Token + testClients map[string]*gtsmodel.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.Module +} + +/* + TEST INFRASTRUCTURE +*/ + +func (suite *FileserverTestSuite) SetupSuite() { + testrig.InitTestConfig() + testrig.InitTestLog() + + fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) + clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) + + suite.db = testrig.NewTestDB() + suite.storage = testrig.NewInMemoryStorage() + suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../testrig/media"), suite.db, fedWorker), suite.storage, suite.mediaManager, fedWorker) + suite.emailSender = testrig.NewEmailSender("../../../web/template/", nil) + + suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, testrig.NewTestMediaManager(suite.db, suite.storage), clientWorker, fedWorker) + suite.tc = testrig.NewTestTypeConverter(suite.db) + suite.mediaManager = testrig.NewTestMediaManager(suite.db, suite.storage) + suite.oauthServer = testrig.NewTestOauthServer(suite.db) + + suite.fileServer = fileserver.New(suite.processor) +} + +func (suite *FileserverTestSuite) SetupTest() { + testrig.StandardDBSetup(suite.db, nil) + 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 *FileserverTestSuite) TearDownSuite() { + if err := suite.db.Stop(context.Background()); err != nil { + log.Panicf("error closing db connection: %s", err) + } +} + +func (suite *FileserverTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) +} diff --git a/internal/api/fileserver/servefile.go b/internal/api/fileserver/servefile.go @@ -0,0 +1,130 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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" + "fmt" + "io" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/log" + "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 *Module) ServeFile(c *gin.Context) { + authed, err := oauth.Authed(c, false, false, false, false) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotFound(err), m.processor.InstanceGet) + 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 == "" { + err := fmt.Errorf("missing %s from request", AccountIDKey) + apiutil.ErrorHandler(c, gtserror.NewErrorNotFound(err), m.processor.InstanceGet) + return + } + + mediaType := c.Param(MediaTypeKey) + if mediaType == "" { + err := fmt.Errorf("missing %s from request", MediaTypeKey) + apiutil.ErrorHandler(c, gtserror.NewErrorNotFound(err), m.processor.InstanceGet) + return + } + + mediaSize := c.Param(MediaSizeKey) + if mediaSize == "" { + err := fmt.Errorf("missing %s from request", MediaSizeKey) + apiutil.ErrorHandler(c, gtserror.NewErrorNotFound(err), m.processor.InstanceGet) + return + } + + fileName := c.Param(FileNameKey) + if fileName == "" { + err := fmt.Errorf("missing %s from request", FileNameKey) + apiutil.ErrorHandler(c, gtserror.NewErrorNotFound(err), m.processor.InstanceGet) + return + } + + content, errWithCode := m.processor.FileGet(c.Request.Context(), authed, &apimodel.GetContentRequestForm{ + AccountID: accountID, + MediaType: mediaType, + MediaSize: mediaSize, + FileName: fileName, + }) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + defer func() { + // close content when we're done + if content.Content != nil { + if err := content.Content.Close(); err != nil { + log.Errorf("ServeFile: error closing readcloser: %s", err) + } + } + }() + + if content.URL != nil { + c.Redirect(http.StatusFound, content.URL.String()) + return + } + + // TODO: if the requester only accepts text/html we should try to serve them *something*. + // This is mostly needed because when sharing a link to a gts-hosted file on something like mastodon, the masto servers will + // attempt to look up the content to provide a preview of the link, and they ask for text/html. + format, err := apiutil.NegotiateAccept(c, apiutil.MIME(content.ContentType)) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + // if this is a head request, just return info + throw the reader away + if c.Request.Method == http.MethodHead { + c.Header("Content-Type", format) + c.Header("Content-Length", strconv.FormatInt(content.ContentLength, 10)) + c.Status(http.StatusOK) + return + } + + // try to slurp the first few bytes to make sure we have something + b := bytes.NewBuffer(make([]byte, 0, 64)) + if _, err := io.CopyN(b, content.Content, 64); err != nil { + err = fmt.Errorf("ServeFile: error reading from content: %w", err) + apiutil.ErrorHandler(c, gtserror.NewErrorNotFound(err, err.Error()), m.processor.InstanceGet) + return + } + + // we're good, return the slurped bytes + the rest of the content + c.DataFromReader(http.StatusOK, content.ContentLength, format, io.MultiReader(b, content.Content), nil) +} diff --git a/internal/api/fileserver/servefile_test.go b/internal/api/fileserver/servefile_test.go @@ -0,0 +1,272 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/fileserver" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type ServeFileTestSuite struct { + FileserverTestSuite +} + +// GetFile is just a convenience function to save repetition in this test suite. +// It takes the required params to serve a file, calls the handler, and returns +// the http status code, the response headers, and the parsed body bytes. +func (suite *ServeFileTestSuite) GetFile( + accountID string, + mediaType media.Type, + mediaSize media.Size, + filename string, +) (code int, headers http.Header, body []byte) { + recorder := httptest.NewRecorder() + + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Request = httptest.NewRequest(http.MethodGet, "http://localhost:8080/whatever", nil) + ctx.Request.Header.Set("accept", "*/*") + ctx.AddParam(fileserver.AccountIDKey, accountID) + ctx.AddParam(fileserver.MediaTypeKey, string(mediaType)) + ctx.AddParam(fileserver.MediaSizeKey, string(mediaSize)) + ctx.AddParam(fileserver.FileNameKey, filename) + + suite.fileServer.ServeFile(ctx) + code = recorder.Code + headers = recorder.Result().Header + + var err error + body, err = ioutil.ReadAll(recorder.Body) + if err != nil { + suite.FailNow(err.Error()) + } + + return +} + +// UncacheAttachment is a convenience function that uncaches the targetAttachment by +// removing its associated files from storage, and updating the database. +func (suite *ServeFileTestSuite) UncacheAttachment(targetAttachment *gtsmodel.MediaAttachment) { + ctx := context.Background() + + cached := false + targetAttachment.Cached = &cached + + if err := suite.db.UpdateByID(ctx, targetAttachment, targetAttachment.ID, "cached"); err != nil { + suite.FailNow(err.Error()) + } + if err := suite.storage.Delete(ctx, targetAttachment.File.Path); err != nil { + suite.FailNow(err.Error()) + } + if err := suite.storage.Delete(ctx, targetAttachment.Thumbnail.Path); err != nil { + suite.FailNow(err.Error()) + } +} + +func (suite *ServeFileTestSuite) TestServeOriginalLocalFileOK() { + targetAttachment := &gtsmodel.MediaAttachment{} + *targetAttachment = *suite.testAttachments["admin_account_status_1_attachment_1"] + fileInStorage, err := suite.storage.Get(context.Background(), targetAttachment.File.Path) + if err != nil { + suite.FailNow(err.Error()) + } + + code, headers, body := suite.GetFile( + targetAttachment.AccountID, + media.TypeAttachment, + media.SizeOriginal, + targetAttachment.ID+".jpeg", + ) + + suite.Equal(http.StatusOK, code) + suite.Equal("image/jpeg", headers.Get("content-type")) + suite.Equal(fileInStorage, body) +} + +func (suite *ServeFileTestSuite) TestServeSmallLocalFileOK() { + targetAttachment := &gtsmodel.MediaAttachment{} + *targetAttachment = *suite.testAttachments["admin_account_status_1_attachment_1"] + fileInStorage, err := suite.storage.Get(context.Background(), targetAttachment.Thumbnail.Path) + if err != nil { + suite.FailNow(err.Error()) + } + + code, headers, body := suite.GetFile( + targetAttachment.AccountID, + media.TypeAttachment, + media.SizeSmall, + targetAttachment.ID+".jpeg", + ) + + suite.Equal(http.StatusOK, code) + suite.Equal("image/jpeg", headers.Get("content-type")) + suite.Equal(fileInStorage, body) +} + +func (suite *ServeFileTestSuite) TestServeOriginalRemoteFileOK() { + targetAttachment := &gtsmodel.MediaAttachment{} + *targetAttachment = *suite.testAttachments["remote_account_1_status_1_attachment_1"] + fileInStorage, err := suite.storage.Get(context.Background(), targetAttachment.File.Path) + if err != nil { + suite.FailNow(err.Error()) + } + + code, headers, body := suite.GetFile( + targetAttachment.AccountID, + media.TypeAttachment, + media.SizeOriginal, + targetAttachment.ID+".jpeg", + ) + + suite.Equal(http.StatusOK, code) + suite.Equal("image/jpeg", headers.Get("content-type")) + suite.Equal(fileInStorage, body) +} + +func (suite *ServeFileTestSuite) TestServeSmallRemoteFileOK() { + targetAttachment := &gtsmodel.MediaAttachment{} + *targetAttachment = *suite.testAttachments["remote_account_1_status_1_attachment_1"] + fileInStorage, err := suite.storage.Get(context.Background(), targetAttachment.Thumbnail.Path) + if err != nil { + suite.FailNow(err.Error()) + } + + code, headers, body := suite.GetFile( + targetAttachment.AccountID, + media.TypeAttachment, + media.SizeSmall, + targetAttachment.ID+".jpeg", + ) + + suite.Equal(http.StatusOK, code) + suite.Equal("image/jpeg", headers.Get("content-type")) + suite.Equal(fileInStorage, body) +} + +func (suite *ServeFileTestSuite) TestServeOriginalRemoteFileRecache() { + targetAttachment := &gtsmodel.MediaAttachment{} + *targetAttachment = *suite.testAttachments["remote_account_1_status_1_attachment_1"] + fileInStorage, err := suite.storage.Get(context.Background(), targetAttachment.File.Path) + if err != nil { + suite.FailNow(err.Error()) + } + + // uncache the attachment so we'll have to refetch it from the 'remote' instance + suite.UncacheAttachment(targetAttachment) + + code, headers, body := suite.GetFile( + targetAttachment.AccountID, + media.TypeAttachment, + media.SizeOriginal, + targetAttachment.ID+".jpeg", + ) + + suite.Equal(http.StatusOK, code) + suite.Equal("image/jpeg", headers.Get("content-type")) + suite.Equal(fileInStorage, body) +} + +func (suite *ServeFileTestSuite) TestServeSmallRemoteFileRecache() { + targetAttachment := &gtsmodel.MediaAttachment{} + *targetAttachment = *suite.testAttachments["remote_account_1_status_1_attachment_1"] + fileInStorage, err := suite.storage.Get(context.Background(), targetAttachment.Thumbnail.Path) + if err != nil { + suite.FailNow(err.Error()) + } + + // uncache the attachment so we'll have to refetch it from the 'remote' instance + suite.UncacheAttachment(targetAttachment) + + code, headers, body := suite.GetFile( + targetAttachment.AccountID, + media.TypeAttachment, + media.SizeSmall, + targetAttachment.ID+".jpeg", + ) + + suite.Equal(http.StatusOK, code) + suite.Equal("image/jpeg", headers.Get("content-type")) + suite.Equal(fileInStorage, body) +} + +func (suite *ServeFileTestSuite) TestServeOriginalRemoteFileRecacheNotFound() { + targetAttachment := &gtsmodel.MediaAttachment{} + *targetAttachment = *suite.testAttachments["remote_account_1_status_1_attachment_1"] + + // uncache the attachment *and* set the remote URL to something that will return a 404 + suite.UncacheAttachment(targetAttachment) + targetAttachment.RemoteURL = "http://nothing.at.this.url/weeeeeeeee" + if err := suite.db.UpdateByID(context.Background(), targetAttachment, targetAttachment.ID, "remote_url"); err != nil { + suite.FailNow(err.Error()) + } + + code, _, _ := suite.GetFile( + targetAttachment.AccountID, + media.TypeAttachment, + media.SizeOriginal, + targetAttachment.ID+".jpeg", + ) + + suite.Equal(http.StatusNotFound, code) +} + +func (suite *ServeFileTestSuite) TestServeSmallRemoteFileRecacheNotFound() { + targetAttachment := &gtsmodel.MediaAttachment{} + *targetAttachment = *suite.testAttachments["remote_account_1_status_1_attachment_1"] + + // uncache the attachment *and* set the remote URL to something that will return a 404 + suite.UncacheAttachment(targetAttachment) + targetAttachment.RemoteURL = "http://nothing.at.this.url/weeeeeeeee" + if err := suite.db.UpdateByID(context.Background(), targetAttachment, targetAttachment.ID, "remote_url"); err != nil { + suite.FailNow(err.Error()) + } + + code, _, _ := suite.GetFile( + targetAttachment.AccountID, + media.TypeAttachment, + media.SizeSmall, + targetAttachment.ID+".jpeg", + ) + + suite.Equal(http.StatusNotFound, code) +} + +// Callers trying to get some random-ass file that doesn't exist should just get a 404 +func (suite *ServeFileTestSuite) TestServeFileNotFound() { + code, _, _ := suite.GetFile( + "01GMMY4G9B0QEG0PQK5Q5JGJWZ", + media.TypeAttachment, + media.SizeOriginal, + "01GMMY68Y7E5DJ3CA3Y9SS8524.jpeg", + ) + + suite.Equal(http.StatusNotFound, code) +} + +func TestServeFileTestSuite(t *testing.T) { + suite.Run(t, new(ServeFileTestSuite)) +} diff --git a/internal/api/mime.go b/internal/api/mime.go @@ -1,36 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 - -// MIME represents a mime-type. -type MIME string - -// MIME type -const ( - AppJSON MIME = `application/json` - AppXML MIME = `application/xml` - AppRSSXML MIME = `application/rss+xml` - AppActivityJSON MIME = `application/activity+json` - AppActivityLDJSON MIME = `application/ld+json; profile="https://www.w3.org/ns/activitystreams"` - AppForm MIME = `application/x-www-form-urlencoded` - MultipartForm MIME = `multipart/form-data` - TextXML MIME = `text/xml` - TextHTML MIME = `text/html` - TextCSS MIME = `text/css` -) diff --git a/internal/api/negotiate.go b/internal/api/negotiate.go @@ -1,104 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 ( - "errors" - "fmt" - - "github.com/gin-gonic/gin" -) - -// ActivityPubAcceptHeaders represents the Accept headers mentioned here: -var ActivityPubAcceptHeaders = []MIME{ - AppActivityJSON, - AppActivityLDJSON, -} - -// JSONAcceptHeaders is a slice of offers that just contains application/json types. -var JSONAcceptHeaders = []MIME{ - AppJSON, -} - -// HTMLOrJSONAcceptHeaders is a slice of offers that prefers TextHTML and will -// fall back to JSON if necessary. This is useful for error handling, since it can -// be used to serve a nice HTML page if the caller accepts that, or just JSON if not. -var HTMLOrJSONAcceptHeaders = []MIME{ - TextHTML, - AppJSON, -} - -// HTMLAcceptHeaders is a slice of offers that just contains text/html types. -var HTMLAcceptHeaders = []MIME{ - TextHTML, -} - -// HTMLOrActivityPubHeaders matches text/html first, then activitypub types. -// This is useful for user URLs that a user might go to in their browser. -// https://www.w3.org/TR/activitypub/#retrieving-objects -var HTMLOrActivityPubHeaders = []MIME{ - TextHTML, - AppActivityJSON, - AppActivityLDJSON, -} - -// NegotiateAccept takes the *gin.Context from an incoming request, and a -// slice of Offers, and performs content negotiation for the given request -// with the given content-type offers. It will return a string representation -// of the first suitable content-type, or an error if something goes wrong or -// a suitable content-type cannot be matched. -// -// For example, if the request in the *gin.Context has Accept headers of value -// [application/json, text/html], and the provided offers are of value -// [application/json, application/xml], then the returned string will be -// 'application/json', which indicates the content-type that should be returned. -// -// If the length of offers is 0, then an error will be returned, so this function -// should only be called in places where format negotiation is actually needed. -// -// If there are no Accept headers in the request, then the first offer will be returned, -// under the assumption that it's better to serve *something* than error out completely. -// -// Callers can use the offer slices exported in this package as shortcuts for -// often-used Accept types. -// -// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Content_negotiation#server-driven_content_negotiation -func NegotiateAccept(c *gin.Context, offers ...MIME) (string, error) { - if len(offers) == 0 { - return "", errors.New("no format offered") - } - - strings := []string{} - for _, o := range offers { - strings = append(strings, string(o)) - } - - accepts := c.Request.Header.Values("Accept") - if len(accepts) == 0 { - // there's no accept header set, just return the first offer - return strings[0], nil - } - - format := c.NegotiateFormat(strings...) - if format == "" { - return "", fmt.Errorf("no format can be offered for requested Accept header(s) %s; this endpoint offers %s", accepts, offers) - } - - return format, nil -} diff --git a/internal/api/nodeinfo.go b/internal/api/nodeinfo.go @@ -0,0 +1,50 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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/api/nodeinfo" + "github.com/superseriousbusiness/gotosocial/internal/middleware" + "github.com/superseriousbusiness/gotosocial/internal/processing" + "github.com/superseriousbusiness/gotosocial/internal/router" +) + +type NodeInfo struct { + nodeInfo *nodeinfo.Module +} + +func (w *NodeInfo) Route(r router.Router) { + // group nodeinfo endpoints together + nodeInfoGroup := r.AttachGroup("nodeinfo") + + // attach middlewares appropriate for this group + nodeInfoGroup.Use( + middleware.Gzip(), + middleware.RateLimit(), + middleware.CacheControl("public", "max-age=120"), // allow cache for 2 minutes + ) + + w.nodeInfo.Route(nodeInfoGroup.Handle) +} + +func NewNodeInfo(p processing.Processor) *NodeInfo { + return &NodeInfo{ + nodeInfo: nodeinfo.New(p), + } +} diff --git a/internal/api/nodeinfo/nodeinfo.go b/internal/api/nodeinfo/nodeinfo.go @@ -0,0 +1,46 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 nodeinfo + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/processing" +) + +const ( + NodeInfo2Version = "2.0" + NodeInfo2Path = "/" + NodeInfo2Version + NodeInfo2ContentType = "application/json; profile=\"http://nodeinfo.diaspora.software/ns/schema/" + NodeInfo2Version + "#\"" +) + +type Module struct { + processor processing.Processor +} + +func New(processor processing.Processor) *Module { + return &Module{ + processor: processor, + } +} + +func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) { + attachHandler(http.MethodGet, NodeInfo2Path, m.NodeInfo2GETHandler) +} diff --git a/internal/api/nodeinfo/nodeinfoget.go b/internal/api/nodeinfo/nodeinfoget.go @@ -0,0 +1,66 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 nodeinfo + +import ( + "encoding/json" + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" +) + +// NodeInfo2GETHandler swagger:operation GET /nodeinfo/2.0 nodeInfoGet +// +// Returns a compliant nodeinfo response to node info queries. +// +// See: https://nodeinfo.diaspora.software/schema.html +// +// --- +// tags: +// - nodeinfo +// +// produces: +// - application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.0#" +// +// responses: +// '200': +// schema: +// "$ref": "#/definitions/nodeinfo" +func (m *Module) NodeInfo2GETHandler(c *gin.Context) { + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + nodeInfo, errWithCode := m.processor.GetNodeInfo(c.Request.Context()) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + b, err := json.Marshal(nodeInfo) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) + return + } + + c.Data(http.StatusOK, NodeInfo2ContentType, b) +} diff --git a/internal/api/s2s/emoji/emoji.go b/internal/api/s2s/emoji/emoji.go @@ -1,53 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 emoji - -import ( - "net/http" - - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/processing" - "github.com/superseriousbusiness/gotosocial/internal/router" - "github.com/superseriousbusiness/gotosocial/internal/uris" -) - -const ( - // EmojiIDKey is for emoji IDs - EmojiIDKey = "id" - // EmojiBasePath is the base path for serving information about Emojis eg https://example.org/emoji - EmojiWithIDPath = "/" + uris.EmojiPath + "/:" + EmojiIDKey -) - -// Module implements the FederationModule interface -type Module struct { - processor processing.Processor -} - -// New returns a emoji module -func New(processor processing.Processor) api.FederationModule { - return &Module{ - processor: processor, - } -} - -// Route satisfies the RESTAPIModule interface -func (m *Module) Route(s router.Router) error { - s.AttachHandler(http.MethodGet, EmojiWithIDPath, m.EmojiGetHandler) - return nil -} diff --git a/internal/api/s2s/emoji/emojiget.go b/internal/api/s2s/emoji/emojiget.go @@ -1,74 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 emoji - -import ( - "context" - "encoding/json" - "errors" - "net/http" - "strings" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/ap" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" -) - -// EmojiGetHandler -func (m *Module) EmojiGetHandler(c *gin.Context) { - // usernames on our instance are always lowercase - requestedEmojiID := strings.ToUpper(c.Param(EmojiIDKey)) - if requestedEmojiID == "" { - err := errors.New("no emoji id specified in request") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - format, err := api.NegotiateAccept(c, api.ActivityPubAcceptHeaders...) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - ctx := c.Request.Context() - verifier, signed := c.Get(string(ap.ContextRequestingPublicKeyVerifier)) - if signed { - ctx = context.WithValue(ctx, ap.ContextRequestingPublicKeyVerifier, verifier) - } - - signature, signed := c.Get(string(ap.ContextRequestingPublicKeySignature)) - if signed { - ctx = context.WithValue(ctx, ap.ContextRequestingPublicKeySignature, signature) - } - - resp, errWithCode := m.processor.GetFediEmoji(ctx, requestedEmojiID, c.Request.URL) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - b, err := json.Marshal(resp) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) - return - } - - c.Data(http.StatusOK, format, b) -} diff --git a/internal/api/s2s/emoji/emojiget_test.go b/internal/api/s2s/emoji/emojiget_test.go @@ -1,138 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 emoji_test - -import ( - "io/ioutil" - "net/http" - "net/http/httptest" - "testing" - - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/api/s2s/emoji" - "github.com/superseriousbusiness/gotosocial/internal/api/security" - "github.com/superseriousbusiness/gotosocial/internal/concurrency" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/email" - "github.com/superseriousbusiness/gotosocial/internal/federation" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/messages" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/processing" - "github.com/superseriousbusiness/gotosocial/internal/storage" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type EmojiGetTestSuite struct { - suite.Suite - db db.DB - tc typeutils.TypeConverter - mediaManager media.Manager - federator federation.Federator - emailSender email.Sender - processor processing.Processor - storage *storage.Driver - oauthServer oauth.Server - securityModule *security.Module - - testEmojis map[string]*gtsmodel.Emoji - testAccounts map[string]*gtsmodel.Account - - emojiModule *emoji.Module -} - -func (suite *EmojiGetTestSuite) SetupSuite() { - suite.testAccounts = testrig.NewTestAccounts() - suite.testEmojis = testrig.NewTestEmojis() -} - -func (suite *EmojiGetTestSuite) SetupTest() { - testrig.InitTestConfig() - testrig.InitTestLog() - - clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) - fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) - - suite.db = testrig.NewTestDB() - suite.tc = testrig.NewTestTypeConverter(suite.db) - suite.storage = testrig.NewInMemoryStorage() - suite.mediaManager = testrig.NewTestMediaManager(suite.db, suite.storage) - suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker), suite.storage, suite.mediaManager, fedWorker) - suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil) - suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, suite.mediaManager, clientWorker, fedWorker) - suite.emojiModule = emoji.New(suite.processor).(*emoji.Module) - suite.oauthServer = testrig.NewTestOauthServer(suite.db) - suite.securityModule = security.New(suite.db, suite.oauthServer).(*security.Module) - testrig.StandardDBSetup(suite.db, suite.testAccounts) - testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") - - suite.NoError(suite.processor.Start()) -} - -func (suite *EmojiGetTestSuite) TearDownTest() { - testrig.StandardDBTeardown(suite.db) - testrig.StandardStorageTeardown(suite.storage) -} - -func (suite *EmojiGetTestSuite) TestGetEmoji() { - // the dereference we're gonna use - derefRequests := testrig.NewTestDereferenceRequests(suite.testAccounts) - signedRequest := derefRequests["foss_satan_dereference_emoji"] - targetEmoji := suite.testEmojis["rainbow"] - - // setup request - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - ctx.Request = httptest.NewRequest(http.MethodGet, targetEmoji.URI, nil) // the endpoint we're hitting - ctx.Request.Header.Set("accept", "application/activity+json") - ctx.Request.Header.Set("Signature", signedRequest.SignatureHeader) - ctx.Request.Header.Set("Date", signedRequest.DateHeader) - - // we need to pass the context through signature check first to set appropriate values on it - suite.securityModule.SignatureCheck(ctx) - - // 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: emoji.EmojiIDKey, - Value: targetEmoji.ID, - }, - } - - // trigger the function being tested - suite.emojiModule.EmojiGetHandler(ctx) - - // check response - suite.EqualValues(http.StatusOK, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - suite.NoError(err) - - suite.Contains(string(b), `"icon":{"mediaType":"image/png","type":"Image","url":"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png"},"id":"http://localhost:8080/emoji/01F8MH9H8E4VG3KDYJR9EGPXCQ","name":":rainbow:","type":"Emoji"`) -} - -func TestEmojiGetTestSuite(t *testing.T) { - suite.Run(t, new(EmojiGetTestSuite)) -} diff --git a/internal/api/s2s/nodeinfo/nodeinfo.go b/internal/api/s2s/nodeinfo/nodeinfo.go @@ -1,53 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 nodeinfo - -import ( - "net/http" - - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/processing" - "github.com/superseriousbusiness/gotosocial/internal/router" -) - -const ( - // NodeInfoWellKnownPath is the base path for serving responses to nodeinfo lookup requests. - NodeInfoWellKnownPath = ".well-known/nodeinfo" - // NodeInfoBasePath is the path for serving nodeinfo responses. - NodeInfoBasePath = "/nodeinfo/2.0" -) - -// Module implements the FederationModule interface -type Module struct { - processor processing.Processor -} - -// New returns a new nodeinfo module -func New(processor processing.Processor) api.FederationModule { - return &Module{ - processor: processor, - } -} - -// Route satisfies the FederationModule interface -func (m *Module) Route(s router.Router) error { - s.AttachHandler(http.MethodGet, NodeInfoWellKnownPath, m.NodeInfoWellKnownGETHandler) - s.AttachHandler(http.MethodGet, NodeInfoBasePath, m.NodeInfoGETHandler) - return nil -} diff --git a/internal/api/s2s/nodeinfo/nodeinfoget.go b/internal/api/s2s/nodeinfo/nodeinfoget.go @@ -1,66 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 nodeinfo - -import ( - "encoding/json" - "net/http" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" -) - -// NodeInfoGETHandler swagger:operation GET /nodeinfo/2.0 nodeInfoGet -// -// Returns a compliant nodeinfo response to node info queries. -// -// See: https://nodeinfo.diaspora.software/schema.html -// -// --- -// tags: -// - nodeinfo -// -// produces: -// - application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.0#" -// -// responses: -// '200': -// schema: -// "$ref": "#/definitions/nodeinfo" -func (m *Module) NodeInfoGETHandler(c *gin.Context) { - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - ni, errWithCode := m.processor.GetNodeInfo(c.Request.Context(), c.Request) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - b, err := json.Marshal(ni) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) - return - } - - c.Data(http.StatusOK, `application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.0#"`, b) -} diff --git a/internal/api/s2s/nodeinfo/wellknownget.go b/internal/api/s2s/nodeinfo/wellknownget.go @@ -1,60 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 nodeinfo - -import ( - "net/http" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" -) - -// NodeInfoWellKnownGETHandler swagger:operation GET /.well-known/nodeinfo nodeInfoWellKnownGet -// -// Directs callers to /nodeinfo/2.0. -// -// eg. `{"links":[{"rel":"http://nodeinfo.diaspora.software/ns/schema/2.0","href":"http://example.org/nodeinfo/2.0"}]}` -// See: https://nodeinfo.diaspora.software/protocol.html -// -// --- -// tags: -// - nodeinfo -// -// produces: -// - application/json -// -// responses: -// '200': -// schema: -// "$ref": "#/definitions/wellKnownResponse" -func (m *Module) NodeInfoWellKnownGETHandler(c *gin.Context) { - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - niRel, errWithCode := m.processor.GetNodeInfoRel(c.Request.Context(), c.Request) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - c.JSON(http.StatusOK, niRel) -} diff --git a/internal/api/s2s/user/common.go b/internal/api/s2s/user/common.go @@ -1,81 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 ( - "context" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/ap" -) - -// transferContext transfers the signature verifier and signature from the gin context to the request context -func transferContext(c *gin.Context) context.Context { - ctx := c.Request.Context() - - verifier, signed := c.Get(string(ap.ContextRequestingPublicKeyVerifier)) - if signed { - ctx = context.WithValue(ctx, ap.ContextRequestingPublicKeyVerifier, verifier) - } - - signature, signed := c.Get(string(ap.ContextRequestingPublicKeySignature)) - if signed { - ctx = context.WithValue(ctx, ap.ContextRequestingPublicKeySignature, signature) - } - - return ctx -} - -// SwaggerCollection represents an activitypub collection. -// swagger:model swaggerCollection -type SwaggerCollection struct { - // ActivityStreams context. - // example: https://www.w3.org/ns/activitystreams - Context string `json:"@context"` - // ActivityStreams ID. - // example: https://example.org/users/some_user/statuses/106717595988259568/replies - ID string `json:"id"` - // ActivityStreams type. - // example: Collection - Type string `json:"type"` - // ActivityStreams first property. - First SwaggerCollectionPage `json:"first"` - // ActivityStreams last property. - Last SwaggerCollectionPage `json:"last,omitempty"` -} - -// SwaggerCollectionPage represents one page of a collection. -// swagger:model swaggerCollectionPage -type SwaggerCollectionPage struct { - // ActivityStreams ID. - // example: https://example.org/users/some_user/statuses/106717595988259568/replies?page=true - ID string `json:"id"` - // ActivityStreams type. - // example: CollectionPage - Type string `json:"type"` - // Link to the next page. - // example: https://example.org/users/some_user/statuses/106717595988259568/replies?only_other_accounts=true&page=true - Next string `json:"next"` - // Collection this page belongs to. - // example: https://example.org/users/some_user/statuses/106717595988259568/replies - PartOf string `json:"partOf"` - // Items on this page. - // example: ["https://example.org/users/some_other_user/statuses/086417595981111564", "https://another.example.com/users/another_user/statuses/01FCN8XDV3YG7B4R42QA6YQZ9R"] - Items []string `json:"items"` -} diff --git a/internal/api/s2s/user/followers.go b/internal/api/s2s/user/followers.go @@ -1,67 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 ( - "encoding/json" - "errors" - "net/http" - "strings" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" -) - -// FollowersGETHandler returns a collection of URIs for followers of the target user, formatted so that other AP servers can understand it. -func (m *Module) FollowersGETHandler(c *gin.Context) { - // usernames on our instance are always lowercase - requestedUsername := strings.ToLower(c.Param(UsernameKey)) - if requestedUsername == "" { - err := errors.New("no username specified in request") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - format, err := api.NegotiateAccept(c, api.HTMLOrActivityPubHeaders...) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - if format == string(api.TextHTML) { - // redirect to the user's profile - c.Redirect(http.StatusSeeOther, "/@"+requestedUsername) - return - } - - resp, errWithCode := m.processor.GetFediFollowers(transferContext(c), requestedUsername, c.Request.URL) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - b, err := json.Marshal(resp) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) - return - } - - c.Data(http.StatusOK, format, b) -} diff --git a/internal/api/s2s/user/following.go b/internal/api/s2s/user/following.go @@ -1,67 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 ( - "encoding/json" - "errors" - "net/http" - "strings" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" -) - -// FollowingGETHandler returns a collection of URIs for accounts that the target user follows, formatted so that other AP servers can understand it. -func (m *Module) FollowingGETHandler(c *gin.Context) { - // usernames on our instance are always lowercase - requestedUsername := strings.ToLower(c.Param(UsernameKey)) - if requestedUsername == "" { - err := errors.New("no username specified in request") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - format, err := api.NegotiateAccept(c, api.HTMLOrActivityPubHeaders...) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - if format == string(api.TextHTML) { - // redirect to the user's profile - c.Redirect(http.StatusSeeOther, "/@"+requestedUsername) - return - } - - resp, errWithCode := m.processor.GetFediFollowing(transferContext(c), requestedUsername, c.Request.URL) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - b, err := json.Marshal(resp) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) - return - } - - c.Data(http.StatusOK, format, b) -} diff --git a/internal/api/s2s/user/inboxpost.go b/internal/api/s2s/user/inboxpost.go @@ -1,51 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 ( - "errors" - "strings" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" //nolint:typecheck -) - -// InboxPOSTHandler deals with incoming POST requests to an actor's inbox. -// Eg., POST to https://example.org/users/whatever/inbox. -func (m *Module) InboxPOSTHandler(c *gin.Context) { - // usernames on our instance are always lowercase - requestedUsername := strings.ToLower(c.Param(UsernameKey)) - if requestedUsername == "" { - err := errors.New("no username specified in request") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - if posted, err := m.processor.InboxPost(transferContext(c), c.Writer, c.Request); err != nil { - if withCode, ok := err.(gtserror.WithCode); ok { - api.ErrorHandler(c, withCode, m.processor.InstanceGet) - } else { - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - } - } else if !posted { - err := errors.New("unable to process request") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - } -} diff --git a/internal/api/s2s/user/inboxpost_test.go b/internal/api/s2s/user/inboxpost_test.go @@ -1,500 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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_test - -import ( - "bytes" - "context" - "encoding/json" - "io/ioutil" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/activity/pub" - "github.com/superseriousbusiness/activity/streams" - "github.com/superseriousbusiness/gotosocial/internal/api/s2s/user" - "github.com/superseriousbusiness/gotosocial/internal/concurrency" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/id" - "github.com/superseriousbusiness/gotosocial/internal/messages" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type InboxPostTestSuite struct { - UserStandardTestSuite -} - -func (suite *InboxPostTestSuite) TestPostBlock() { - blockingAccount := suite.testAccounts["remote_account_1"] - blockedAccount := suite.testAccounts["local_account_1"] - blockURI := testrig.URLMustParse("http://fossbros-anonymous.io/users/foss_satan/blocks/01FG9C441MCTW3R2W117V2PQK3") - - block := streams.NewActivityStreamsBlock() - - // set the actor property to the block-ing account's URI - actorProp := streams.NewActivityStreamsActorProperty() - actorIRI := testrig.URLMustParse(blockingAccount.URI) - actorProp.AppendIRI(actorIRI) - block.SetActivityStreamsActor(actorProp) - - // set the ID property to the blocks's URI - idProp := streams.NewJSONLDIdProperty() - idProp.Set(blockURI) - block.SetJSONLDId(idProp) - - // set the object property to the target account's URI - objectProp := streams.NewActivityStreamsObjectProperty() - targetIRI := testrig.URLMustParse(blockedAccount.URI) - objectProp.AppendIRI(targetIRI) - block.SetActivityStreamsObject(objectProp) - - // set the TO property to the target account's IRI - toProp := streams.NewActivityStreamsToProperty() - toIRI := testrig.URLMustParse(blockedAccount.URI) - toProp.AppendIRI(toIRI) - block.SetActivityStreamsTo(toProp) - - targetURI := testrig.URLMustParse(blockedAccount.InboxURI) - - signature, digestHeader, dateHeader := testrig.GetSignatureForActivity(block, blockingAccount.PublicKeyURI, blockingAccount.PrivateKey, targetURI) - bodyI, err := streams.Serialize(block) - suite.NoError(err) - - bodyJson, err := json.Marshal(bodyI) - suite.NoError(err) - body := bytes.NewReader(bodyJson) - - clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) - fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) - - tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker) - federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager, fedWorker) - emailSender := testrig.NewEmailSender("../../../../web/template/", nil) - processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager, clientWorker, fedWorker) - userModule := user.New(processor).(*user.Module) - suite.NoError(processor.Start()) - - // setup request - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - ctx.Request = httptest.NewRequest(http.MethodPost, targetURI.String(), body) // the endpoint we're hitting - ctx.Request.Header.Set("Signature", signature) - ctx.Request.Header.Set("Date", dateHeader) - ctx.Request.Header.Set("Digest", digestHeader) - ctx.Request.Header.Set("Content-Type", "application/activity+json") - - // we need to pass the context through signature check first to set appropriate values on it - suite.securityModule.SignatureCheck(ctx) - - // 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: blockedAccount.Username, - }, - } - - // trigger the function being tested - userModule.InboxPOSTHandler(ctx) - - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - suite.NoError(err) - suite.Empty(b) - - // there should be a block in the database now between the accounts - dbBlock, err := suite.db.GetBlock(context.Background(), blockingAccount.ID, blockedAccount.ID) - suite.NoError(err) - suite.NotNil(dbBlock) - suite.WithinDuration(time.Now(), dbBlock.CreatedAt, 30*time.Second) - suite.WithinDuration(time.Now(), dbBlock.UpdatedAt, 30*time.Second) - suite.Equal("http://fossbros-anonymous.io/users/foss_satan/blocks/01FG9C441MCTW3R2W117V2PQK3", dbBlock.URI) -} - -// TestPostUnblock verifies that a remote account with a block targeting one of our instance users should be able to undo that block. -func (suite *InboxPostTestSuite) TestPostUnblock() { - blockingAccount := suite.testAccounts["remote_account_1"] - blockedAccount := suite.testAccounts["local_account_1"] - - // first put a block in the database so we have something to undo - blockURI := "http://fossbros-anonymous.io/users/foss_satan/blocks/01FG9C441MCTW3R2W117V2PQK3" - dbBlockID, err := id.NewRandomULID() - suite.NoError(err) - - dbBlock := &gtsmodel.Block{ - ID: dbBlockID, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - URI: blockURI, - AccountID: blockingAccount.ID, - TargetAccountID: blockedAccount.ID, - } - - err = suite.db.PutBlock(context.Background(), dbBlock) - suite.NoError(err) - - asBlock, err := suite.tc.BlockToAS(context.Background(), dbBlock) - suite.NoError(err) - - targetAccountURI := testrig.URLMustParse(blockedAccount.URI) - - // create an Undo and set the appropriate actor on it - undo := streams.NewActivityStreamsUndo() - undo.SetActivityStreamsActor(asBlock.GetActivityStreamsActor()) - - // Set the block as the 'object' property. - undoObject := streams.NewActivityStreamsObjectProperty() - undoObject.AppendActivityStreamsBlock(asBlock) - undo.SetActivityStreamsObject(undoObject) - - // Set the To of the undo as the target of the block - undoTo := streams.NewActivityStreamsToProperty() - undoTo.AppendIRI(targetAccountURI) - undo.SetActivityStreamsTo(undoTo) - - undoID := streams.NewJSONLDIdProperty() - undoID.SetIRI(testrig.URLMustParse("http://fossbros-anonymous.io/72cc96a3-f742-4daf-b9f5-3407667260c5")) - undo.SetJSONLDId(undoID) - - targetURI := testrig.URLMustParse(blockedAccount.InboxURI) - - signature, digestHeader, dateHeader := testrig.GetSignatureForActivity(undo, blockingAccount.PublicKeyURI, blockingAccount.PrivateKey, targetURI) - bodyI, err := streams.Serialize(undo) - suite.NoError(err) - - bodyJson, err := json.Marshal(bodyI) - suite.NoError(err) - body := bytes.NewReader(bodyJson) - - clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) - fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) - - tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker) - federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager, fedWorker) - emailSender := testrig.NewEmailSender("../../../../web/template/", nil) - processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager, clientWorker, fedWorker) - userModule := user.New(processor).(*user.Module) - suite.NoError(processor.Start()) - - // setup request - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - ctx.Request = httptest.NewRequest(http.MethodPost, targetURI.String(), body) // the endpoint we're hitting - ctx.Request.Header.Set("Signature", signature) - ctx.Request.Header.Set("Date", dateHeader) - ctx.Request.Header.Set("Digest", digestHeader) - ctx.Request.Header.Set("Content-Type", "application/activity+json") - - // we need to pass the context through signature check first to set appropriate values on it - suite.securityModule.SignatureCheck(ctx) - - // 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: blockedAccount.Username, - }, - } - - // trigger the function being tested - userModule.InboxPOSTHandler(ctx) - - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - suite.NoError(err) - suite.Empty(b) - suite.Equal(http.StatusOK, result.StatusCode) - - // the block should be undone - block, err := suite.db.GetBlock(context.Background(), blockingAccount.ID, blockedAccount.ID) - suite.ErrorIs(err, db.ErrNoEntries) - suite.Nil(block) -} - -func (suite *InboxPostTestSuite) TestPostUpdate() { - updatedAccount := *suite.testAccounts["remote_account_1"] - updatedAccount.DisplayName = "updated display name!" - - // ad an emoji to the account; because we're serializing this remote - // account from our own instance, we need to cheat a bit to get the emoji - // to work properly, just for this test - testEmoji := &gtsmodel.Emoji{} - *testEmoji = *testrig.NewTestEmojis()["yell"] - testEmoji.ImageURL = testEmoji.ImageRemoteURL // <- here's the cheat - updatedAccount.Emojis = []*gtsmodel.Emoji{testEmoji} - - asAccount, err := suite.tc.AccountToAS(context.Background(), &updatedAccount) - suite.NoError(err) - - receivingAccount := suite.testAccounts["local_account_1"] - - // create an update - update := streams.NewActivityStreamsUpdate() - - // set the appropriate actor on it - updateActor := streams.NewActivityStreamsActorProperty() - updateActor.AppendIRI(testrig.URLMustParse(updatedAccount.URI)) - update.SetActivityStreamsActor(updateActor) - - // Set the account as the 'object' property. - updateObject := streams.NewActivityStreamsObjectProperty() - updateObject.AppendActivityStreamsPerson(asAccount) - update.SetActivityStreamsObject(updateObject) - - // Set the To of the update as public - updateTo := streams.NewActivityStreamsToProperty() - updateTo.AppendIRI(testrig.URLMustParse(pub.PublicActivityPubIRI)) - update.SetActivityStreamsTo(updateTo) - - // set the cc of the update to the receivingAccount - updateCC := streams.NewActivityStreamsCcProperty() - updateCC.AppendIRI(testrig.URLMustParse(receivingAccount.URI)) - update.SetActivityStreamsCc(updateCC) - - // set some random-ass ID for the activity - undoID := streams.NewJSONLDIdProperty() - undoID.SetIRI(testrig.URLMustParse("http://fossbros-anonymous.io/d360613a-dc8d-4563-8f0b-b6161caf0f2b")) - update.SetJSONLDId(undoID) - - targetURI := testrig.URLMustParse(receivingAccount.InboxURI) - - signature, digestHeader, dateHeader := testrig.GetSignatureForActivity(update, updatedAccount.PublicKeyURI, updatedAccount.PrivateKey, targetURI) - bodyI, err := streams.Serialize(update) - suite.NoError(err) - - bodyJson, err := json.Marshal(bodyI) - suite.NoError(err) - body := bytes.NewReader(bodyJson) - - clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) - fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) - - tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker) - federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager, fedWorker) - emailSender := testrig.NewEmailSender("../../../../web/template/", nil) - processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager, clientWorker, fedWorker) - userModule := user.New(processor).(*user.Module) - suite.NoError(processor.Start()) - - // setup request - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - ctx.Request = httptest.NewRequest(http.MethodPost, targetURI.String(), body) // the endpoint we're hitting - ctx.Request.Header.Set("Signature", signature) - ctx.Request.Header.Set("Date", dateHeader) - ctx.Request.Header.Set("Digest", digestHeader) - ctx.Request.Header.Set("Content-Type", "application/activity+json") - - // we need to pass the context through signature check first to set appropriate values on it - suite.securityModule.SignatureCheck(ctx) - - // 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: receivingAccount.Username, - }, - } - - // trigger the function being tested - userModule.InboxPOSTHandler(ctx) - - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - suite.NoError(err) - suite.Empty(b) - suite.Equal(http.StatusOK, result.StatusCode) - - // account should be changed in the database now - var dbUpdatedAccount *gtsmodel.Account - - if !testrig.WaitFor(func() bool { - // displayName should be updated - dbUpdatedAccount, _ = suite.db.GetAccountByID(context.Background(), updatedAccount.ID) - return dbUpdatedAccount.DisplayName == "updated display name!" - }) { - suite.FailNow("timed out waiting for account update") - } - - // emojis should be updated - suite.Contains(dbUpdatedAccount.EmojiIDs, testEmoji.ID) - - // account should be freshly webfingered - suite.WithinDuration(time.Now(), dbUpdatedAccount.LastWebfingeredAt, 10*time.Second) - - // everything else should be the same as it was before - suite.EqualValues(updatedAccount.Username, dbUpdatedAccount.Username) - suite.EqualValues(updatedAccount.Domain, dbUpdatedAccount.Domain) - suite.EqualValues(updatedAccount.AvatarMediaAttachmentID, dbUpdatedAccount.AvatarMediaAttachmentID) - suite.EqualValues(updatedAccount.AvatarMediaAttachment, dbUpdatedAccount.AvatarMediaAttachment) - suite.EqualValues(updatedAccount.AvatarRemoteURL, dbUpdatedAccount.AvatarRemoteURL) - suite.EqualValues(updatedAccount.HeaderMediaAttachmentID, dbUpdatedAccount.HeaderMediaAttachmentID) - suite.EqualValues(updatedAccount.HeaderMediaAttachment, dbUpdatedAccount.HeaderMediaAttachment) - suite.EqualValues(updatedAccount.HeaderRemoteURL, dbUpdatedAccount.HeaderRemoteURL) - suite.EqualValues(updatedAccount.Note, dbUpdatedAccount.Note) - suite.EqualValues(updatedAccount.Memorial, dbUpdatedAccount.Memorial) - suite.EqualValues(updatedAccount.AlsoKnownAs, dbUpdatedAccount.AlsoKnownAs) - suite.EqualValues(updatedAccount.MovedToAccountID, dbUpdatedAccount.MovedToAccountID) - suite.EqualValues(updatedAccount.Bot, dbUpdatedAccount.Bot) - suite.EqualValues(updatedAccount.Reason, dbUpdatedAccount.Reason) - suite.EqualValues(updatedAccount.Locked, dbUpdatedAccount.Locked) - suite.EqualValues(updatedAccount.Discoverable, dbUpdatedAccount.Discoverable) - suite.EqualValues(updatedAccount.Privacy, dbUpdatedAccount.Privacy) - suite.EqualValues(updatedAccount.Sensitive, dbUpdatedAccount.Sensitive) - suite.EqualValues(updatedAccount.Language, dbUpdatedAccount.Language) - suite.EqualValues(updatedAccount.URI, dbUpdatedAccount.URI) - suite.EqualValues(updatedAccount.URL, dbUpdatedAccount.URL) - suite.EqualValues(updatedAccount.InboxURI, dbUpdatedAccount.InboxURI) - suite.EqualValues(updatedAccount.OutboxURI, dbUpdatedAccount.OutboxURI) - suite.EqualValues(updatedAccount.FollowingURI, dbUpdatedAccount.FollowingURI) - suite.EqualValues(updatedAccount.FollowersURI, dbUpdatedAccount.FollowersURI) - suite.EqualValues(updatedAccount.FeaturedCollectionURI, dbUpdatedAccount.FeaturedCollectionURI) - suite.EqualValues(updatedAccount.ActorType, dbUpdatedAccount.ActorType) - suite.EqualValues(updatedAccount.PublicKey, dbUpdatedAccount.PublicKey) - suite.EqualValues(updatedAccount.PublicKeyURI, dbUpdatedAccount.PublicKeyURI) - suite.EqualValues(updatedAccount.SensitizedAt, dbUpdatedAccount.SensitizedAt) - suite.EqualValues(updatedAccount.SilencedAt, dbUpdatedAccount.SilencedAt) - suite.EqualValues(updatedAccount.SuspendedAt, dbUpdatedAccount.SuspendedAt) - suite.EqualValues(updatedAccount.HideCollections, dbUpdatedAccount.HideCollections) - suite.EqualValues(updatedAccount.SuspensionOrigin, dbUpdatedAccount.SuspensionOrigin) -} - -func (suite *InboxPostTestSuite) TestPostDelete() { - deletedAccount := *suite.testAccounts["remote_account_1"] - receivingAccount := suite.testAccounts["local_account_1"] - - // create a delete - delete := streams.NewActivityStreamsDelete() - - // set the appropriate actor on it - deleteActor := streams.NewActivityStreamsActorProperty() - deleteActor.AppendIRI(testrig.URLMustParse(deletedAccount.URI)) - delete.SetActivityStreamsActor(deleteActor) - - // Set the account iri as the 'object' property. - deleteObject := streams.NewActivityStreamsObjectProperty() - deleteObject.AppendIRI(testrig.URLMustParse(deletedAccount.URI)) - delete.SetActivityStreamsObject(deleteObject) - - // Set the To of the delete as public - deleteTo := streams.NewActivityStreamsToProperty() - deleteTo.AppendIRI(testrig.URLMustParse(pub.PublicActivityPubIRI)) - delete.SetActivityStreamsTo(deleteTo) - - // set some random-ass ID for the activity - deleteID := streams.NewJSONLDIdProperty() - deleteID.SetIRI(testrig.URLMustParse("http://fossbros-anonymous.io/d360613a-dc8d-4563-8f0b-b6161caf0f2b")) - delete.SetJSONLDId(deleteID) - - targetURI := testrig.URLMustParse(receivingAccount.InboxURI) - - signature, digestHeader, dateHeader := testrig.GetSignatureForActivity(delete, deletedAccount.PublicKeyURI, deletedAccount.PrivateKey, targetURI) - bodyI, err := streams.Serialize(delete) - suite.NoError(err) - - bodyJson, err := json.Marshal(bodyI) - suite.NoError(err) - body := bytes.NewReader(bodyJson) - - clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) - fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) - - tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker) - federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager, fedWorker) - emailSender := testrig.NewEmailSender("../../../../web/template/", nil) - processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager, clientWorker, fedWorker) - suite.NoError(processor.Start()) - userModule := user.New(processor).(*user.Module) - - // setup request - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - ctx.Request = httptest.NewRequest(http.MethodPost, targetURI.String(), body) // the endpoint we're hitting - ctx.Request.Header.Set("Signature", signature) - ctx.Request.Header.Set("Date", dateHeader) - ctx.Request.Header.Set("Digest", digestHeader) - ctx.Request.Header.Set("Content-Type", "application/activity+json") - - // we need to pass the context through signature check first to set appropriate values on it - suite.securityModule.SignatureCheck(ctx) - - // 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: receivingAccount.Username, - }, - } - - // trigger the function being tested - userModule.InboxPOSTHandler(ctx) - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - suite.NoError(err) - suite.Empty(b) - suite.Equal(http.StatusOK, result.StatusCode) - - if !testrig.WaitFor(func() bool { - // local account 2 blocked foss_satan, that block should be gone now - testBlock := suite.testBlocks["local_account_2_block_remote_account_1"] - dbBlock := &gtsmodel.Block{} - err = suite.db.GetByID(ctx, testBlock.ID, dbBlock) - return suite.ErrorIs(err, db.ErrNoEntries) - }) { - suite.FailNow("timed out waiting for block to be removed") - } - - // no statuses from foss satan should be left in the database - dbStatuses, err := suite.db.GetAccountStatuses(ctx, deletedAccount.ID, 0, false, false, "", "", false, false, false) - suite.ErrorIs(err, db.ErrNoEntries) - suite.Empty(dbStatuses) - - dbAccount, err := suite.db.GetAccountByID(ctx, deletedAccount.ID) - suite.NoError(err) - - suite.Empty(dbAccount.Note) - suite.Empty(dbAccount.DisplayName) - suite.Empty(dbAccount.AvatarMediaAttachmentID) - suite.Empty(dbAccount.AvatarRemoteURL) - suite.Empty(dbAccount.HeaderMediaAttachmentID) - suite.Empty(dbAccount.HeaderRemoteURL) - suite.Empty(dbAccount.Reason) - suite.Empty(dbAccount.Fields) - suite.True(*dbAccount.HideCollections) - suite.False(*dbAccount.Discoverable) - suite.WithinDuration(time.Now(), dbAccount.SuspendedAt, 30*time.Second) - suite.Equal(dbAccount.ID, dbAccount.SuspensionOrigin) -} - -func TestInboxPostTestSuite(t *testing.T) { - suite.Run(t, &InboxPostTestSuite{}) -} diff --git a/internal/api/s2s/user/outboxget.go b/internal/api/s2s/user/outboxget.go @@ -1,145 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 ( - "encoding/json" - "errors" - "fmt" - "net/http" - "strconv" - "strings" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" -) - -// OutboxGETHandler swagger:operation GET /users/{username}/outbox s2sOutboxGet -// -// Get the public outbox collection for an actor. -// -// Note that the response will be a Collection with a page as `first`, as shown below, if `page` is `false`. -// -// If `page` is `true`, then the response will be a single `CollectionPage` without the wrapping `Collection`. -// -// HTTP signature is required on the request. -// -// --- -// tags: -// - s2s/federation -// -// produces: -// - application/activity+json -// -// parameters: -// - -// name: username -// type: string -// description: Username of the account. -// in: path -// required: true -// - -// name: page -// type: boolean -// description: Return response as a CollectionPage. -// in: query -// default: false -// - -// name: min_id -// type: string -// description: Minimum ID of the next status, used for paging. -// in: query -// - -// name: max_id -// type: string -// description: Maximum ID of the next status, used for paging. -// in: query -// -// responses: -// '200': -// in: body -// schema: -// "$ref": "#/definitions/swaggerCollection" -// '400': -// description: bad request -// '401': -// description: unauthorized -// '403': -// description: forbidden -// '404': -// description: not found -func (m *Module) OutboxGETHandler(c *gin.Context) { - // usernames on our instance are always lowercase - requestedUsername := strings.ToLower(c.Param(UsernameKey)) - if requestedUsername == "" { - err := errors.New("no username specified in request") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - format, err := api.NegotiateAccept(c, api.HTMLOrActivityPubHeaders...) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - if format == string(api.TextHTML) { - // redirect to the user's profile - c.Redirect(http.StatusSeeOther, "/@"+requestedUsername) - return - } - - var page bool - if pageString := c.Query(PageKey); pageString != "" { - i, err := strconv.ParseBool(pageString) - if err != nil { - err := fmt.Errorf("error parsing %s: %s", PageKey, err) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - page = i - } - - minID := "" - minIDString := c.Query(MinIDKey) - if minIDString != "" { - minID = minIDString - } - - maxID := "" - maxIDString := c.Query(MaxIDKey) - if maxIDString != "" { - maxID = maxIDString - } - - resp, errWithCode := m.processor.GetFediOutbox(transferContext(c), requestedUsername, page, maxID, minID, c.Request.URL) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - b, err := json.Marshal(resp) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) - return - } - - c.Data(http.StatusOK, format, b) -} diff --git a/internal/api/s2s/user/outboxget_test.go b/internal/api/s2s/user/outboxget_test.go @@ -1,216 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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_test - -import ( - "context" - "encoding/json" - "io/ioutil" - "net/http" - "net/http/httptest" - "testing" - - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/activity/streams" - "github.com/superseriousbusiness/activity/streams/vocab" - "github.com/superseriousbusiness/gotosocial/internal/api/s2s/user" - "github.com/superseriousbusiness/gotosocial/internal/concurrency" - "github.com/superseriousbusiness/gotosocial/internal/messages" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type OutboxGetTestSuite struct { - UserStandardTestSuite -} - -func (suite *OutboxGetTestSuite) TestGetOutbox() { - // the dereference we're gonna use - derefRequests := testrig.NewTestDereferenceRequests(suite.testAccounts) - signedRequest := derefRequests["foss_satan_dereference_zork_outbox"] - targetAccount := suite.testAccounts["local_account_1"] - - // setup request - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - ctx.Request = httptest.NewRequest(http.MethodGet, targetAccount.OutboxURI, nil) // the endpoint we're hitting - ctx.Request.Header.Set("accept", "application/activity+json") - ctx.Request.Header.Set("Signature", signedRequest.SignatureHeader) - ctx.Request.Header.Set("Date", signedRequest.DateHeader) - - // we need to pass the context through signature check first to set appropriate values on it - suite.securityModule.SignatureCheck(ctx) - - // 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, - }, - } - - // trigger the function being tested - suite.userModule.OutboxGETHandler(ctx) - - // check response - suite.EqualValues(http.StatusOK, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - suite.NoError(err) - suite.Equal(`{"@context":"https://www.w3.org/ns/activitystreams","first":"http://localhost:8080/users/the_mighty_zork/outbox?page=true","id":"http://localhost:8080/users/the_mighty_zork/outbox","type":"OrderedCollection"}`, string(b)) - - m := make(map[string]interface{}) - err = json.Unmarshal(b, &m) - suite.NoError(err) - - t, err := streams.ToType(context.Background(), m) - suite.NoError(err) - - _, ok := t.(vocab.ActivityStreamsOrderedCollection) - suite.True(ok) -} - -func (suite *OutboxGetTestSuite) TestGetOutboxFirstPage() { - // the dereference we're gonna use - derefRequests := testrig.NewTestDereferenceRequests(suite.testAccounts) - signedRequest := derefRequests["foss_satan_dereference_zork_outbox_first"] - targetAccount := suite.testAccounts["local_account_1"] - - clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) - fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) - - tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker) - federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager, fedWorker) - emailSender := testrig.NewEmailSender("../../../../web/template/", nil) - processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager, clientWorker, fedWorker) - userModule := user.New(processor).(*user.Module) - suite.NoError(processor.Start()) - - // setup request - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - ctx.Request = httptest.NewRequest(http.MethodGet, targetAccount.OutboxURI+"?page=true", nil) // the endpoint we're hitting - ctx.Request.Header.Set("accept", "application/activity+json") - ctx.Request.Header.Set("Signature", signedRequest.SignatureHeader) - ctx.Request.Header.Set("Date", signedRequest.DateHeader) - - // we need to pass the context through signature check first to set appropriate values on it - suite.securityModule.SignatureCheck(ctx) - - // 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, - }, - } - - // trigger the function being tested - userModule.OutboxGETHandler(ctx) - - // check response - suite.EqualValues(http.StatusOK, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - suite.NoError(err) - suite.Equal(`{"@context":"https://www.w3.org/ns/activitystreams","id":"http://localhost:8080/users/the_mighty_zork/outbox?page=true","next":"http://localhost:8080/users/the_mighty_zork/outbox?page=true\u0026max_id=01F8MHAMCHF6Y650WCRSCP4WMY","orderedItems":{"actor":"http://localhost:8080/users/the_mighty_zork","cc":"http://localhost:8080/users/the_mighty_zork/followers","id":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/activity","object":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY","published":"2021-10-20T10:40:37Z","to":"https://www.w3.org/ns/activitystreams#Public","type":"Create"},"partOf":"http://localhost:8080/users/the_mighty_zork/outbox","prev":"http://localhost:8080/users/the_mighty_zork/outbox?page=true\u0026min_id=01F8MHAMCHF6Y650WCRSCP4WMY","type":"OrderedCollectionPage"}`, string(b)) - - m := make(map[string]interface{}) - err = json.Unmarshal(b, &m) - suite.NoError(err) - - t, err := streams.ToType(context.Background(), m) - suite.NoError(err) - - _, ok := t.(vocab.ActivityStreamsOrderedCollectionPage) - suite.True(ok) -} - -func (suite *OutboxGetTestSuite) TestGetOutboxNextPage() { - // the dereference we're gonna use - derefRequests := testrig.NewTestDereferenceRequests(suite.testAccounts) - signedRequest := derefRequests["foss_satan_dereference_zork_outbox_next"] - targetAccount := suite.testAccounts["local_account_1"] - - clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) - fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) - - tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker) - federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager, fedWorker) - emailSender := testrig.NewEmailSender("../../../../web/template/", nil) - processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager, clientWorker, fedWorker) - userModule := user.New(processor).(*user.Module) - suite.NoError(processor.Start()) - - // setup request - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - ctx.Request = httptest.NewRequest(http.MethodGet, targetAccount.OutboxURI+"?page=true&max_id=01F8MHAMCHF6Y650WCRSCP4WMY", nil) // the endpoint we're hitting - ctx.Request.Header.Set("accept", "application/activity+json") - ctx.Request.Header.Set("Signature", signedRequest.SignatureHeader) - ctx.Request.Header.Set("Date", signedRequest.DateHeader) - - // we need to pass the context through signature check first to set appropriate values on it - suite.securityModule.SignatureCheck(ctx) - - // 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, - }, - gin.Param{ - Key: user.MaxIDKey, - Value: "01F8MHAMCHF6Y650WCRSCP4WMY", - }, - } - - // trigger the function being tested - userModule.OutboxGETHandler(ctx) - - // check response - suite.EqualValues(http.StatusOK, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - suite.NoError(err) - suite.Equal(`{"@context":"https://www.w3.org/ns/activitystreams","id":"http://localhost:8080/users/the_mighty_zork/outbox?page=true\u0026maxID=01F8MHAMCHF6Y650WCRSCP4WMY","orderedItems":[],"partOf":"http://localhost:8080/users/the_mighty_zork/outbox","type":"OrderedCollectionPage"}`, string(b)) - - m := make(map[string]interface{}) - err = json.Unmarshal(b, &m) - suite.NoError(err) - - t, err := streams.ToType(context.Background(), m) - suite.NoError(err) - - _, ok := t.(vocab.ActivityStreamsOrderedCollectionPage) - suite.True(ok) -} - -func TestOutboxGetTestSuite(t *testing.T) { - suite.Run(t, new(OutboxGetTestSuite)) -} diff --git a/internal/api/s2s/user/publickeyget.go b/internal/api/s2s/user/publickeyget.go @@ -1,71 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 ( - "encoding/json" - "errors" - "net/http" - "strings" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" -) - -// PublicKeyGETHandler should be served at eg https://example.org/users/:username/main-key. -// -// The goal here is to return a MINIMAL activitypub representation of an account -// in the form of a vocab.ActivityStreamsPerson. The account will only contain the id, -// public key, username, and type of the account. -func (m *Module) PublicKeyGETHandler(c *gin.Context) { - // usernames on our instance are always lowercase - requestedUsername := strings.ToLower(c.Param(UsernameKey)) - if requestedUsername == "" { - err := errors.New("no username specified in request") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - format, err := api.NegotiateAccept(c, api.HTMLOrActivityPubHeaders...) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - if format == string(api.TextHTML) { - // redirect to the user's profile - c.Redirect(http.StatusSeeOther, "/@"+requestedUsername) - return - } - - resp, errWithCode := m.processor.GetFediUser(transferContext(c), requestedUsername, c.Request.URL) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - b, err := json.Marshal(resp) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) - return - } - - c.Data(http.StatusOK, format, b) -} diff --git a/internal/api/s2s/user/repliesget.go b/internal/api/s2s/user/repliesget.go @@ -1,166 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 ( - "encoding/json" - "errors" - "fmt" - "net/http" - "strconv" - "strings" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" -) - -// StatusRepliesGETHandler swagger:operation GET /users/{username}/statuses/{status}/replies s2sRepliesGet -// -// Get the replies collection for a status. -// -// Note that the response will be a Collection with a page as `first`, as shown below, if `page` is `false`. -// -// If `page` is `true`, then the response will be a single `CollectionPage` without the wrapping `Collection`. -// -// HTTP signature is required on the request. -// -// --- -// tags: -// - s2s/federation -// -// produces: -// - application/activity+json -// -// parameters: -// - -// name: username -// type: string -// description: Username of the account. -// in: path -// required: true -// - -// name: status -// type: string -// description: ID of the status. -// in: path -// required: true -// - -// name: page -// type: boolean -// description: Return response as a CollectionPage. -// in: query -// default: false -// - -// name: only_other_accounts -// type: boolean -// description: Return replies only from accounts other than the status owner. -// in: query -// default: false -// - -// name: min_id -// type: string -// description: Minimum ID of the next status, used for paging. -// in: query -// -// responses: -// '200': -// in: body -// schema: -// "$ref": "#/definitions/swaggerCollection" -// '400': -// description: bad request -// '401': -// description: unauthorized -// '403': -// description: forbidden -// '404': -// description: not found -func (m *Module) StatusRepliesGETHandler(c *gin.Context) { - // usernames on our instance are always lowercase - requestedUsername := strings.ToLower(c.Param(UsernameKey)) - if requestedUsername == "" { - err := errors.New("no username specified in request") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - // status IDs on our instance are always uppercase - requestedStatusID := strings.ToUpper(c.Param(StatusIDKey)) - if requestedStatusID == "" { - err := errors.New("no status id specified in request") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - format, err := api.NegotiateAccept(c, api.HTMLOrActivityPubHeaders...) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - if format == string(api.TextHTML) { - // redirect to the status - c.Redirect(http.StatusSeeOther, "/@"+requestedUsername+"/statuses/"+requestedStatusID) - return - } - - var page bool - if pageString := c.Query(PageKey); pageString != "" { - i, err := strconv.ParseBool(pageString) - if err != nil { - err := fmt.Errorf("error parsing %s: %s", PageKey, err) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - page = i - } - - onlyOtherAccounts := false - onlyOtherAccountsString := c.Query(OnlyOtherAccountsKey) - if onlyOtherAccountsString != "" { - i, err := strconv.ParseBool(onlyOtherAccountsString) - if err != nil { - err := fmt.Errorf("error parsing %s: %s", OnlyOtherAccountsKey, err) - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - onlyOtherAccounts = i - } - - minID := "" - minIDString := c.Query(MinIDKey) - if minIDString != "" { - minID = minIDString - } - - resp, errWithCode := m.processor.GetFediStatusReplies(transferContext(c), requestedUsername, requestedStatusID, page, onlyOtherAccounts, minID, c.Request.URL) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - b, err := json.Marshal(resp) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) - return - } - - c.Data(http.StatusOK, format, b) -} diff --git a/internal/api/s2s/user/repliesget_test.go b/internal/api/s2s/user/repliesget_test.go @@ -1,239 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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_test - -import ( - "context" - "encoding/json" - "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/activity/streams" - "github.com/superseriousbusiness/activity/streams/vocab" - "github.com/superseriousbusiness/gotosocial/internal/api/s2s/user" - "github.com/superseriousbusiness/gotosocial/internal/concurrency" - "github.com/superseriousbusiness/gotosocial/internal/messages" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type RepliesGetTestSuite struct { - UserStandardTestSuite -} - -func (suite *RepliesGetTestSuite) TestGetReplies() { - // the dereference we're gonna use - derefRequests := testrig.NewTestDereferenceRequests(suite.testAccounts) - signedRequest := derefRequests["foss_satan_dereference_local_account_1_status_1_replies"] - targetAccount := suite.testAccounts["local_account_1"] - targetStatus := suite.testStatuses["local_account_1_status_1"] - - // setup request - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - ctx.Request = httptest.NewRequest(http.MethodGet, targetStatus.URI+"/replies", nil) // the endpoint we're hitting - ctx.Request.Header.Set("accept", "application/activity+json") - ctx.Request.Header.Set("Signature", signedRequest.SignatureHeader) - ctx.Request.Header.Set("Date", signedRequest.DateHeader) - - // we need to pass the context through signature check first to set appropriate values on it - suite.securityModule.SignatureCheck(ctx) - - // 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, - }, - gin.Param{ - Key: user.StatusIDKey, - Value: targetStatus.ID, - }, - } - - // trigger the function being tested - suite.userModule.StatusRepliesGETHandler(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) - assert.Equal(suite.T(), `{"@context":"https://www.w3.org/ns/activitystreams","first":{"id":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies?page=true","next":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies?only_other_accounts=false\u0026page=true","partOf":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies","type":"CollectionPage"},"id":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies","type":"Collection"}`, string(b)) - - // should be a Collection - 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) - - _, ok := t.(vocab.ActivityStreamsCollection) - assert.True(suite.T(), ok) -} - -func (suite *RepliesGetTestSuite) TestGetRepliesNext() { - // the dereference we're gonna use - derefRequests := testrig.NewTestDereferenceRequests(suite.testAccounts) - signedRequest := derefRequests["foss_satan_dereference_local_account_1_status_1_replies_next"] - targetAccount := suite.testAccounts["local_account_1"] - targetStatus := suite.testStatuses["local_account_1_status_1"] - - clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) - fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) - - tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker) - federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager, fedWorker) - emailSender := testrig.NewEmailSender("../../../../web/template/", nil) - processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager, clientWorker, fedWorker) - userModule := user.New(processor).(*user.Module) - suite.NoError(processor.Start()) - - // setup request - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - ctx.Request = httptest.NewRequest(http.MethodGet, targetStatus.URI+"/replies?only_other_accounts=false&page=true", nil) // the endpoint we're hitting - ctx.Request.Header.Set("accept", "application/activity+json") - ctx.Request.Header.Set("Signature", signedRequest.SignatureHeader) - ctx.Request.Header.Set("Date", signedRequest.DateHeader) - - // we need to pass the context through signature check first to set appropriate values on it - suite.securityModule.SignatureCheck(ctx) - - // 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, - }, - gin.Param{ - Key: user.StatusIDKey, - Value: targetStatus.ID, - }, - } - - // trigger the function being tested - userModule.StatusRepliesGETHandler(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) - - assert.Equal(suite.T(), `{"@context":"https://www.w3.org/ns/activitystreams","id":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies?page=true\u0026only_other_accounts=false","items":"http://localhost:8080/users/admin/statuses/01FF25D5Q0DH7CHD57CTRS6WK0","next":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies?only_other_accounts=false\u0026page=true\u0026min_id=01FF25D5Q0DH7CHD57CTRS6WK0","partOf":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies","type":"CollectionPage"}`, string(b)) - - // should be a Collection - 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) - - page, ok := t.(vocab.ActivityStreamsCollectionPage) - assert.True(suite.T(), ok) - - assert.Equal(suite.T(), page.GetActivityStreamsItems().Len(), 1) -} - -func (suite *RepliesGetTestSuite) TestGetRepliesLast() { - // the dereference we're gonna use - derefRequests := testrig.NewTestDereferenceRequests(suite.testAccounts) - signedRequest := derefRequests["foss_satan_dereference_local_account_1_status_1_replies_last"] - targetAccount := suite.testAccounts["local_account_1"] - targetStatus := suite.testStatuses["local_account_1_status_1"] - - clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) - fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) - - tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker) - federator := testrig.NewTestFederator(suite.db, tc, suite.storage, suite.mediaManager, fedWorker) - emailSender := testrig.NewEmailSender("../../../../web/template/", nil) - processor := testrig.NewTestProcessor(suite.db, suite.storage, federator, emailSender, suite.mediaManager, clientWorker, fedWorker) - userModule := user.New(processor).(*user.Module) - suite.NoError(processor.Start()) - - // setup request - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - ctx.Request = httptest.NewRequest(http.MethodGet, targetStatus.URI+"/replies?only_other_accounts=false&page=true&min_id=01FF25D5Q0DH7CHD57CTRS6WK0", nil) // the endpoint we're hitting - ctx.Request.Header.Set("accept", "application/activity+json") - ctx.Request.Header.Set("Signature", signedRequest.SignatureHeader) - ctx.Request.Header.Set("Date", signedRequest.DateHeader) - - // we need to pass the context through signature check first to set appropriate values on it - suite.securityModule.SignatureCheck(ctx) - - // 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, - }, - gin.Param{ - Key: user.StatusIDKey, - Value: targetStatus.ID, - }, - } - - // trigger the function being tested - userModule.StatusRepliesGETHandler(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)) - assert.Equal(suite.T(), `{"@context":"https://www.w3.org/ns/activitystreams","id":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies?page=true\u0026only_other_accounts=false\u0026min_id=01FF25D5Q0DH7CHD57CTRS6WK0","items":[],"next":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies?only_other_accounts=false\u0026page=true","partOf":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies","type":"CollectionPage"}`, string(b)) - - // should be a Collection - 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) - - page, ok := t.(vocab.ActivityStreamsCollectionPage) - assert.True(suite.T(), ok) - - assert.Equal(suite.T(), page.GetActivityStreamsItems().Len(), 0) -} - -func TestRepliesGetTestSuite(t *testing.T) { - suite.Run(t, new(RepliesGetTestSuite)) -} diff --git a/internal/api/s2s/user/statusget.go b/internal/api/s2s/user/statusget.go @@ -1,75 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 ( - "encoding/json" - "errors" - "net/http" - "strings" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" -) - -// StatusGETHandler serves the target status as an activitystreams NOTE so that other AP servers can parse it. -func (m *Module) StatusGETHandler(c *gin.Context) { - // usernames on our instance are always lowercase - requestedUsername := strings.ToLower(c.Param(UsernameKey)) - if requestedUsername == "" { - err := errors.New("no username specified in request") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - // status IDs on our instance are always uppercase - requestedStatusID := strings.ToUpper(c.Param(StatusIDKey)) - if requestedStatusID == "" { - err := errors.New("no status id specified in request") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - format, err := api.NegotiateAccept(c, api.HTMLOrActivityPubHeaders...) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - if format == string(api.TextHTML) { - // redirect to the status - c.Redirect(http.StatusSeeOther, "/@"+requestedUsername+"/statuses/"+requestedStatusID) - return - } - - resp, errWithCode := m.processor.GetFediStatus(transferContext(c), requestedUsername, requestedStatusID, c.Request.URL) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - b, err := json.Marshal(resp) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) - return - } - - c.Data(http.StatusOK, format, b) -} diff --git a/internal/api/s2s/user/statusget_test.go b/internal/api/s2s/user/statusget_test.go @@ -1,162 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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_test - -import ( - "context" - "encoding/json" - "io/ioutil" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/activity/streams" - "github.com/superseriousbusiness/activity/streams/vocab" - "github.com/superseriousbusiness/gotosocial/internal/api/s2s/user" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type StatusGetTestSuite struct { - UserStandardTestSuite -} - -func (suite *StatusGetTestSuite) TestGetStatus() { - // the dereference we're gonna use - derefRequests := testrig.NewTestDereferenceRequests(suite.testAccounts) - signedRequest := derefRequests["foss_satan_dereference_local_account_1_status_1"] - targetAccount := suite.testAccounts["local_account_1"] - targetStatus := suite.testStatuses["local_account_1_status_1"] - - // setup request - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - ctx.Request = httptest.NewRequest(http.MethodGet, targetStatus.URI, nil) // the endpoint we're hitting - ctx.Request.Header.Set("accept", "application/activity+json") - ctx.Request.Header.Set("Signature", signedRequest.SignatureHeader) - ctx.Request.Header.Set("Date", signedRequest.DateHeader) - - // we need to pass the context through signature check first to set appropriate values on it - suite.securityModule.SignatureCheck(ctx) - - // 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, - }, - gin.Param{ - Key: user.StatusIDKey, - Value: targetStatus.ID, - }, - } - - // trigger the function being tested - suite.userModule.StatusGETHandler(ctx) - - // check response - suite.EqualValues(http.StatusOK, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - suite.NoError(err) - - // should be a Note - m := make(map[string]interface{}) - err = json.Unmarshal(b, &m) - suite.NoError(err) - - t, err := streams.ToType(context.Background(), m) - suite.NoError(err) - - note, ok := t.(vocab.ActivityStreamsNote) - suite.True(ok) - - // convert note to status - a, err := suite.tc.ASStatusToStatus(context.Background(), note) - suite.NoError(err) - suite.EqualValues(targetStatus.Content, a.Content) -} - -func (suite *StatusGetTestSuite) TestGetStatusLowercase() { - // the dereference we're gonna use - derefRequests := testrig.NewTestDereferenceRequests(suite.testAccounts) - signedRequest := derefRequests["foss_satan_dereference_local_account_1_status_1_lowercase"] - targetAccount := suite.testAccounts["local_account_1"] - targetStatus := suite.testStatuses["local_account_1_status_1"] - - // setup request - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - ctx.Request = httptest.NewRequest(http.MethodGet, strings.ToLower(targetStatus.URI), nil) // the endpoint we're hitting - ctx.Request.Header.Set("accept", "application/activity+json") - ctx.Request.Header.Set("Signature", signedRequest.SignatureHeader) - ctx.Request.Header.Set("Date", signedRequest.DateHeader) - - // we need to pass the context through signature check first to set appropriate values on it - suite.securityModule.SignatureCheck(ctx) - - // 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: strings.ToLower(targetAccount.Username), - }, - gin.Param{ - Key: user.StatusIDKey, - Value: strings.ToLower(targetStatus.ID), - }, - } - - // trigger the function being tested - suite.userModule.StatusGETHandler(ctx) - - // check response - suite.EqualValues(http.StatusOK, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - suite.NoError(err) - - // should be a Note - m := make(map[string]interface{}) - err = json.Unmarshal(b, &m) - suite.NoError(err) - - t, err := streams.ToType(context.Background(), m) - suite.NoError(err) - - note, ok := t.(vocab.ActivityStreamsNote) - suite.True(ok) - - // convert note to status - a, err := suite.tc.ASStatusToStatus(context.Background(), note) - suite.NoError(err) - suite.EqualValues(targetStatus.Content, a.Content) -} - -func TestStatusGetTestSuite(t *testing.T) { - suite.Run(t, new(StatusGetTestSuite)) -} diff --git a/internal/api/s2s/user/user.go b/internal/api/s2s/user/user.go @@ -1,89 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/processing" - "github.com/superseriousbusiness/gotosocial/internal/router" - "github.com/superseriousbusiness/gotosocial/internal/uris" -) - -const ( - // UsernameKey is for account usernames. - UsernameKey = "username" - // StatusIDKey is for status IDs - StatusIDKey = "status" - // OnlyOtherAccountsKey is for filtering status responses. - OnlyOtherAccountsKey = "only_other_accounts" - // MinIDKey is for filtering status responses. - MinIDKey = "min_id" - // MaxIDKey is for filtering status responses. - MaxIDKey = "max_id" - // PageKey is for filtering status responses. - PageKey = "page" - - // UsersBasePath is the base path for serving information about Users eg https://example.org/users - UsersBasePath = "/" + uris.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 - // UsersPublicKeyPath is a path to a user's public key, for serving bare minimum AP representations. - UsersPublicKeyPath = UsersBasePathWithUsername + "/" + uris.PublicKeyPath - // UsersInboxPath is for serving POST requests to a user's inbox with the given username key. - UsersInboxPath = UsersBasePathWithUsername + "/" + uris.InboxPath - // UsersOutboxPath is for serving GET requests to a user's outbox with the given username key. - UsersOutboxPath = UsersBasePathWithUsername + "/" + uris.OutboxPath - // UsersFollowersPath is for serving GET request's to a user's followers list, with the given username key. - UsersFollowersPath = UsersBasePathWithUsername + "/" + uris.FollowersPath - // UsersFollowingPath is for serving GET request's to a user's following list, with the given username key. - UsersFollowingPath = UsersBasePathWithUsername + "/" + uris.FollowingPath - // UsersStatusPath is for serving GET requests to a particular status by a user, with the given username key and status ID - UsersStatusPath = UsersBasePathWithUsername + "/" + uris.StatusesPath + "/:" + StatusIDKey - // UsersStatusRepliesPath is for serving the replies collection of a status. - UsersStatusRepliesPath = UsersStatusPath + "/replies" -) - -// Module implements the FederationAPIModule interface -type Module struct { - processor processing.Processor -} - -// New returns a new auth module -func New(processor processing.Processor) api.FederationModule { - return &Module{ - processor: processor, - } -} - -// Route satisfies the RESTAPIModule interface -func (m *Module) Route(s router.Router) error { - s.AttachHandler(http.MethodGet, UsersBasePathWithUsername, m.UsersGETHandler) - s.AttachHandler(http.MethodPost, UsersInboxPath, m.InboxPOSTHandler) - s.AttachHandler(http.MethodGet, UsersFollowersPath, m.FollowersGETHandler) - s.AttachHandler(http.MethodGet, UsersFollowingPath, m.FollowingGETHandler) - s.AttachHandler(http.MethodGet, UsersStatusPath, m.StatusGETHandler) - s.AttachHandler(http.MethodGet, UsersPublicKeyPath, m.PublicKeyGETHandler) - s.AttachHandler(http.MethodGet, UsersStatusRepliesPath, m.StatusRepliesGETHandler) - s.AttachHandler(http.MethodGet, UsersOutboxPath, m.OutboxGETHandler) - return nil -} diff --git a/internal/api/s2s/user/user_test.go b/internal/api/s2s/user/user_test.go @@ -1,103 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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_test - -import ( - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/api/s2s/user" - "github.com/superseriousbusiness/gotosocial/internal/api/security" - "github.com/superseriousbusiness/gotosocial/internal/concurrency" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/email" - "github.com/superseriousbusiness/gotosocial/internal/federation" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/messages" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/processing" - "github.com/superseriousbusiness/gotosocial/internal/storage" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type UserStandardTestSuite struct { - // standard suite interfaces - suite.Suite - db db.DB - tc typeutils.TypeConverter - mediaManager media.Manager - federator federation.Federator - emailSender email.Sender - processor processing.Processor - storage *storage.Driver - oauthServer oauth.Server - securityModule *security.Module - - // standard suite models - testTokens map[string]*gtsmodel.Token - testClients map[string]*gtsmodel.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 - testBlocks map[string]*gtsmodel.Block - - // module being tested - userModule *user.Module -} - -func (suite *UserStandardTestSuite) 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() - suite.testBlocks = testrig.NewTestBlocks() -} - -func (suite *UserStandardTestSuite) SetupTest() { - testrig.InitTestConfig() - testrig.InitTestLog() - - clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) - fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) - - suite.db = testrig.NewTestDB() - suite.tc = testrig.NewTestTypeConverter(suite.db) - suite.storage = testrig.NewInMemoryStorage() - suite.mediaManager = testrig.NewTestMediaManager(suite.db, suite.storage) - suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker), suite.storage, suite.mediaManager, fedWorker) - suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil) - suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, suite.mediaManager, clientWorker, fedWorker) - suite.userModule = user.New(suite.processor).(*user.Module) - suite.oauthServer = testrig.NewTestOauthServer(suite.db) - suite.securityModule = security.New(suite.db, suite.oauthServer).(*security.Module) - testrig.StandardDBSetup(suite.db, suite.testAccounts) - testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") - - suite.NoError(suite.processor.Start()) -} - -func (suite *UserStandardTestSuite) TearDownTest() { - testrig.StandardDBTeardown(suite.db) - testrig.StandardStorageTeardown(suite.storage) -} diff --git a/internal/api/s2s/user/userget.go b/internal/api/s2s/user/userget.go @@ -1,75 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 ( - "encoding/json" - "errors" - "net/http" - "strings" - - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" -) - -// 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) { - // usernames on our instance are always lowercase - requestedUsername := strings.ToLower(c.Param(UsernameKey)) - if requestedUsername == "" { - err := errors.New("no username specified in request") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) - return - } - - format, err := api.NegotiateAccept(c, api.HTMLOrActivityPubHeaders...) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) - return - } - - if format == string(api.TextHTML) { - // redirect to the user's profile - c.Redirect(http.StatusSeeOther, "/@"+requestedUsername) - return - } - - resp, errWithCode := m.processor.GetFediUser(transferContext(c), requestedUsername, c.Request.URL) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - b, err := json.Marshal(resp) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) - return - } - - c.Data(http.StatusOK, format, b) -} diff --git a/internal/api/s2s/user/userget_test.go b/internal/api/s2s/user/userget_test.go @@ -1,177 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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_test - -import ( - "context" - "encoding/json" - "io/ioutil" - "net/http" - "net/http/httptest" - "testing" - - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/activity/streams" - "github.com/superseriousbusiness/activity/streams/vocab" - apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/api/s2s/user" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type UserGetTestSuite struct { - UserStandardTestSuite -} - -func (suite *UserGetTestSuite) TestGetUser() { - // the dereference we're gonna use - derefRequests := testrig.NewTestDereferenceRequests(suite.testAccounts) - signedRequest := derefRequests["foss_satan_dereference_zork"] - targetAccount := suite.testAccounts["local_account_1"] - - // setup request - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - ctx.Request = httptest.NewRequest(http.MethodGet, targetAccount.URI, nil) // the endpoint we're hitting - ctx.Request.Header.Set("accept", "application/activity+json") - ctx.Request.Header.Set("Signature", signedRequest.SignatureHeader) - ctx.Request.Header.Set("Date", signedRequest.DateHeader) - - // we need to pass the context through signature check first to set appropriate values on it - suite.securityModule.SignatureCheck(ctx) - - // 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, - }, - } - - // trigger the function being tested - suite.userModule.UsersGETHandler(ctx) - - // check response - suite.EqualValues(http.StatusOK, recorder.Code) - - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - suite.NoError(err) - - // should be a Person - m := make(map[string]interface{}) - err = json.Unmarshal(b, &m) - suite.NoError(err) - - t, err := streams.ToType(context.Background(), m) - suite.NoError(err) - - person, ok := t.(vocab.ActivityStreamsPerson) - suite.True(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(context.Background(), person, "", false) - suite.NoError(err) - suite.EqualValues(targetAccount.Username, a.Username) -} - -// TestGetUserPublicKeyDeleted checks whether the public key of a deleted account can still be dereferenced. -// This is needed by remote instances for authenticating delete requests and stuff like that. -func (suite *UserGetTestSuite) TestGetUserPublicKeyDeleted() { - userModule := user.New(suite.processor).(*user.Module) - targetAccount := suite.testAccounts["local_account_1"] - - // first delete the account, as though zork had deleted himself - authed := &oauth.Auth{ - Application: suite.testApplications["local_account_1"], - User: suite.testUsers["local_account_1"], - Account: suite.testAccounts["local_account_1"], - } - suite.processor.AccountDeleteLocal(context.Background(), authed, &apimodel.AccountDeleteRequest{ - Password: "password", - DeleteOriginID: targetAccount.ID, - }) - - // wait for the account delete to be processed - if !testrig.WaitFor(func() bool { - a, _ := suite.db.GetAccountByID(context.Background(), targetAccount.ID) - return !a.SuspendedAt.IsZero() - }) { - suite.FailNow("delete of account timed out") - } - - // the dereference we're gonna use - derefRequests := testrig.NewTestDereferenceRequests(suite.testAccounts) - signedRequest := derefRequests["foss_satan_dereference_zork_public_key"] - - // setup request - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - ctx.Request = httptest.NewRequest(http.MethodGet, targetAccount.PublicKeyURI, nil) // the endpoint we're hitting - ctx.Request.Header.Set("accept", "application/activity+json") - ctx.Request.Header.Set("Signature", signedRequest.SignatureHeader) - ctx.Request.Header.Set("Date", signedRequest.DateHeader) - - // we need to pass the context through signature check first to set appropriate values on it - suite.securityModule.SignatureCheck(ctx) - - // 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, - }, - } - - // 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) - suite.NoError(err) - - // should be a Person - m := make(map[string]interface{}) - err = json.Unmarshal(b, &m) - suite.NoError(err) - - t, err := streams.ToType(context.Background(), m) - suite.NoError(err) - - person, ok := t.(vocab.ActivityStreamsPerson) - suite.True(ok) - - // convert person to account - a, err := suite.tc.ASRepresentationToAccount(context.Background(), person, "", false) - suite.NoError(err) - suite.EqualValues(targetAccount.Username, a.Username) -} - -func TestUserGetTestSuite(t *testing.T) { - suite.Run(t, new(UserGetTestSuite)) -} diff --git a/internal/api/s2s/webfinger/webfinger.go b/internal/api/s2s/webfinger/webfinger.go @@ -1,50 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 webfinger - -import ( - "net/http" - - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/processing" - "github.com/superseriousbusiness/gotosocial/internal/router" -) - -const ( - // WebfingerBasePath is the base path for serving webfinger lookup requests - WebfingerBasePath = ".well-known/webfinger" -) - -// Module implements the FederationModule interface -type Module struct { - processor processing.Processor -} - -// New returns a new webfinger module -func New(processor processing.Processor) api.FederationModule { - return &Module{ - processor: processor, - } -} - -// Route satisfies the FederationModule interface -func (m *Module) Route(s router.Router) error { - s.AttachHandler(http.MethodGet, WebfingerBasePath, m.WebfingerGETRequest) - return nil -} diff --git a/internal/api/s2s/webfinger/webfinger_test.go b/internal/api/s2s/webfinger/webfinger_test.go @@ -1,137 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 webfinger_test - -import ( - "crypto/rand" - "crypto/rsa" - "time" - - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/ap" - "github.com/superseriousbusiness/gotosocial/internal/api/s2s/webfinger" - "github.com/superseriousbusiness/gotosocial/internal/api/security" - "github.com/superseriousbusiness/gotosocial/internal/concurrency" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/email" - "github.com/superseriousbusiness/gotosocial/internal/federation" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/media" - "github.com/superseriousbusiness/gotosocial/internal/messages" - "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/processing" - "github.com/superseriousbusiness/gotosocial/internal/storage" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type WebfingerStandardTestSuite struct { - // standard suite interfaces - suite.Suite - db db.DB - tc typeutils.TypeConverter - mediaManager media.Manager - federator federation.Federator - emailSender email.Sender - processor processing.Processor - storage *storage.Driver - oauthServer oauth.Server - securityModule *security.Module - - // standard suite models - testTokens map[string]*gtsmodel.Token - testClients map[string]*gtsmodel.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 - webfingerModule *webfinger.Module -} - -func (suite *WebfingerStandardTestSuite) 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 *WebfingerStandardTestSuite) SetupTest() { - testrig.InitTestLog() - testrig.InitTestConfig() - - clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) - fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) - - suite.db = testrig.NewTestDB() - suite.tc = testrig.NewTestTypeConverter(suite.db) - suite.storage = testrig.NewInMemoryStorage() - suite.mediaManager = testrig.NewTestMediaManager(suite.db, suite.storage) - suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker), suite.storage, suite.mediaManager, fedWorker) - suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil) - suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, suite.mediaManager, clientWorker, fedWorker) - suite.webfingerModule = webfinger.New(suite.processor).(*webfinger.Module) - suite.oauthServer = testrig.NewTestOauthServer(suite.db) - suite.securityModule = security.New(suite.db, suite.oauthServer).(*security.Module) - testrig.StandardDBSetup(suite.db, suite.testAccounts) - testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") - - suite.NoError(suite.processor.Start()) -} - -func (suite *WebfingerStandardTestSuite) TearDownTest() { - testrig.StandardDBTeardown(suite.db) - testrig.StandardStorageTeardown(suite.storage) -} - -func accountDomainAccount() *gtsmodel.Account { - privateKey, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - panic(err) - } - publicKey := &privateKey.PublicKey - - acct := &gtsmodel.Account{ - ID: "01FG1K8EA7SYHEC7V6XKVNC4ZA", - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - Username: "aaaaa", - Domain: "", - Privacy: gtsmodel.VisibilityDefault, - Language: "en", - URI: "http://gts.example.org/users/aaaaa", - URL: "http://gts.example.org/@aaaaa", - InboxURI: "http://gts.example.org/users/aaaaa/inbox", - OutboxURI: "http://gts.example.org/users/aaaaa/outbox", - FollowingURI: "http://gts.example.org/users/aaaaa/following", - FollowersURI: "http://gts.example.org/users/aaaaa/followers", - FeaturedCollectionURI: "http://gts.example.org/users/aaaaa/collections/featured", - ActorType: ap.ActorPerson, - PrivateKey: privateKey, - PublicKey: publicKey, - PublicKeyURI: "http://gts.example.org/users/aaaaa/main-key", - } - - return acct -} diff --git a/internal/api/s2s/webfinger/webfingerget.go b/internal/api/s2s/webfinger/webfingerget.go @@ -1,102 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 webfinger - -import ( - "context" - "fmt" - "net/http" - - "codeberg.org/gruf/go-kv" - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/ap" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/log" - "github.com/superseriousbusiness/gotosocial/internal/util" -) - -// WebfingerGETRequest swagger:operation GET /.well-known/webfinger webfingerGet -// -// Handles webfinger account lookup requests. -// -// For example, a GET to `https://goblin.technology/.well-known/webfinger?resource=acct:tobi@goblin.technology` would return: -// -// ``` -// -// {"subject":"acct:tobi@goblin.technology","aliases":["https://goblin.technology/users/tobi","https://goblin.technology/@tobi"],"links":[{"rel":"http://webfinger.net/rel/profile-page","type":"text/html","href":"https://goblin.technology/@tobi"},{"rel":"self","type":"application/activity+json","href":"https://goblin.technology/users/tobi"}]} -// -// ``` -// -// See: https://webfinger.net/ -// -// --- -// tags: -// - webfinger -// -// produces: -// - application/json -// -// responses: -// '200': -// schema: -// "$ref": "#/definitions/wellKnownResponse" -func (m *Module) WebfingerGETRequest(c *gin.Context) { - l := log.WithFields(kv.Fields{ - {K: "user-agent", V: c.Request.UserAgent()}, - }...) - - resourceQuery, set := c.GetQuery("resource") - if !set || resourceQuery == "" { - l.Debug("aborting request because no resource was set in query") - c.JSON(http.StatusBadRequest, gin.H{"error": "no 'resource' in request query"}) - return - } - - requestedUsername, requestedHost, err := util.ExtractWebfingerParts(resourceQuery) - if err != nil { - l.Debugf("bad webfinger request with resource query %s: %s", resourceQuery, err) - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("bad webfinger request with resource query %s", resourceQuery)}) - return - } - - accountDomain := config.GetAccountDomain() - host := config.GetHost() - - if requestedHost != host && requestedHost != accountDomain { - l.Debugf("aborting request because requestedHost %s does not belong to this instance", requestedHost) - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("requested host %s does not belong to this instance", requestedHost)}) - return - } - - // transfer the signature verifier from the gin context to the request context - ctx := c.Request.Context() - verifier, signed := c.Get(string(ap.ContextRequestingPublicKeyVerifier)) - if signed { - ctx = context.WithValue(ctx, ap.ContextRequestingPublicKeyVerifier, verifier) - } - - resp, errWithCode := m.processor.GetWebfingerAccount(ctx, requestedUsername) - if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) - return - } - - c.JSON(http.StatusOK, resp) -} diff --git a/internal/api/s2s/webfinger/webfingerget_test.go b/internal/api/s2s/webfinger/webfingerget_test.go @@ -1,171 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 webfinger_test - -import ( - "context" - "fmt" - "io/ioutil" - "net/http" - "net/http/httptest" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/api/s2s/webfinger" - "github.com/superseriousbusiness/gotosocial/internal/concurrency" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/messages" - "github.com/superseriousbusiness/gotosocial/internal/processing" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type WebfingerGetTestSuite struct { - WebfingerStandardTestSuite -} - -func (suite *WebfingerGetTestSuite) TestFingerUser() { - targetAccount := suite.testAccounts["local_account_1"] - - // setup request - host := config.GetHost() - requestPath := fmt.Sprintf("/%s?resource=acct:%s@%s", webfinger.WebfingerBasePath, targetAccount.Username, host) - - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - ctx.Request = httptest.NewRequest(http.MethodGet, requestPath, nil) // the endpoint we're hitting - ctx.Request.Header.Set("accept", "application/json") - - // trigger the function being tested - suite.webfingerModule.WebfingerGETRequest(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) - - suite.Equal(`{"subject":"acct:the_mighty_zork@localhost:8080","aliases":["http://localhost:8080/users/the_mighty_zork","http://localhost:8080/@the_mighty_zork"],"links":[{"rel":"http://webfinger.net/rel/profile-page","type":"text/html","href":"http://localhost:8080/@the_mighty_zork"},{"rel":"self","type":"application/activity+json","href":"http://localhost:8080/users/the_mighty_zork"}]}`, string(b)) -} - -func (suite *WebfingerGetTestSuite) TestFingerUserWithDifferentAccountDomainByHost() { - config.SetHost("gts.example.org") - config.SetAccountDomain("example.org") - - clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) - fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) - suite.processor = processing.NewProcessor(suite.tc, suite.federator, testrig.NewTestOauthServer(suite.db), testrig.NewTestMediaManager(suite.db, suite.storage), suite.storage, suite.db, suite.emailSender, clientWorker, fedWorker) - suite.webfingerModule = webfinger.New(suite.processor).(*webfinger.Module) - - targetAccount := accountDomainAccount() - if err := suite.db.Put(context.Background(), targetAccount); err != nil { - panic(err) - } - - // setup request - host := config.GetHost() - requestPath := fmt.Sprintf("/%s?resource=acct:%s@%s", webfinger.WebfingerBasePath, targetAccount.Username, host) - - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - ctx.Request = httptest.NewRequest(http.MethodGet, requestPath, nil) // the endpoint we're hitting - ctx.Request.Header.Set("accept", "application/json") - - // trigger the function being tested - suite.webfingerModule.WebfingerGETRequest(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) - - suite.Equal(`{"subject":"acct:aaaaa@example.org","aliases":["http://gts.example.org/users/aaaaa","http://gts.example.org/@aaaaa"],"links":[{"rel":"http://webfinger.net/rel/profile-page","type":"text/html","href":"http://gts.example.org/@aaaaa"},{"rel":"self","type":"application/activity+json","href":"http://gts.example.org/users/aaaaa"}]}`, string(b)) -} - -func (suite *WebfingerGetTestSuite) TestFingerUserWithDifferentAccountDomainByAccountDomain() { - config.SetHost("gts.example.org") - config.SetAccountDomain("example.org") - - clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) - fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) - suite.processor = processing.NewProcessor(suite.tc, suite.federator, testrig.NewTestOauthServer(suite.db), testrig.NewTestMediaManager(suite.db, suite.storage), suite.storage, suite.db, suite.emailSender, clientWorker, fedWorker) - suite.webfingerModule = webfinger.New(suite.processor).(*webfinger.Module) - - targetAccount := accountDomainAccount() - if err := suite.db.Put(context.Background(), targetAccount); err != nil { - panic(err) - } - - // setup request - accountDomain := config.GetAccountDomain() - requestPath := fmt.Sprintf("/%s?resource=acct:%s@%s", webfinger.WebfingerBasePath, targetAccount.Username, accountDomain) - - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - ctx.Request = httptest.NewRequest(http.MethodGet, requestPath, nil) // the endpoint we're hitting - ctx.Request.Header.Set("accept", "application/json") - - // trigger the function being tested - suite.webfingerModule.WebfingerGETRequest(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) - - suite.Equal(`{"subject":"acct:aaaaa@example.org","aliases":["http://gts.example.org/users/aaaaa","http://gts.example.org/@aaaaa"],"links":[{"rel":"http://webfinger.net/rel/profile-page","type":"text/html","href":"http://gts.example.org/@aaaaa"},{"rel":"self","type":"application/activity+json","href":"http://gts.example.org/users/aaaaa"}]}`, string(b)) -} - -func (suite *WebfingerGetTestSuite) TestFingerUserWithoutAcct() { - targetAccount := suite.testAccounts["local_account_1"] - - // setup request -- leave out the 'acct:' prefix, which is prettymuch what pixelfed currently does - host := config.GetHost() - requestPath := fmt.Sprintf("/%s?resource=%s@%s", webfinger.WebfingerBasePath, targetAccount.Username, host) - - recorder := httptest.NewRecorder() - ctx, _ := testrig.CreateGinTestContext(recorder, nil) - ctx.Request = httptest.NewRequest(http.MethodGet, requestPath, nil) // the endpoint we're hitting - ctx.Request.Header.Set("accept", "application/json") - - // trigger the function being tested - suite.webfingerModule.WebfingerGETRequest(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) - - suite.Equal(`{"subject":"acct:the_mighty_zork@localhost:8080","aliases":["http://localhost:8080/users/the_mighty_zork","http://localhost:8080/@the_mighty_zork"],"links":[{"rel":"http://webfinger.net/rel/profile-page","type":"text/html","href":"http://localhost:8080/@the_mighty_zork"},{"rel":"self","type":"application/activity+json","href":"http://localhost:8080/users/the_mighty_zork"}]}`, string(b)) -} - -func TestWebfingerGetTestSuite(t *testing.T) { - suite.Run(t, new(WebfingerGetTestSuite)) -} diff --git a/internal/api/security/extraheaders.go b/internal/api/security/extraheaders.go @@ -1,26 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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/gin-gonic/gin" - -// ExtraHeaders adds any additional required headers to the response -func (m *Module) ExtraHeaders(c *gin.Context) { - c.Header("Server", "gotosocial") -} diff --git a/internal/api/security/flocblock.go b/internal/api/security/flocblock.go @@ -1,31 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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/gin-gonic/gin" - -// FlocBlock is a middleware that prevents google chrome cohort tracking by -// writing the Permissions-Policy header after all other parts of the request -// have been completed. Floc was replaced by Topics in 2022 and the spec says -// that interest-cohort will also block Topics (as of 2022-Nov). -// See: https://smartframe.io/blog/google-topics-api-everything-you-need-to-know -// See: https://github.com/patcg-individual-drafts/topics -func (m *Module) FlocBlock(c *gin.Context) { - c.Header("Permissions-Policy", "browsing-topics=()") -} diff --git a/internal/api/security/ratelimit.go b/internal/api/security/ratelimit.go @@ -1,70 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 ( - "net" - "net/http" - "time" - - "github.com/gin-gonic/gin" - limiter "github.com/ulule/limiter/v3" - mgin "github.com/ulule/limiter/v3/drivers/middleware/gin" - memory "github.com/ulule/limiter/v3/drivers/store/memory" -) - -type RateLimitOptions struct { - Period time.Duration - Limit int64 -} - -func (m *Module) LimitReachedHandler(c *gin.Context) { - code := http.StatusTooManyRequests - c.AbortWithStatusJSON(code, gin.H{"error": "rate limit reached"}) -} - -// returns a gin middleware that will automatically rate limit caller (by IP address) -// and enrich the response header with the following headers: -// - `x-ratelimit-limit` maximum number of requests allowed per time period (fixed) -// - `x-ratelimit-remaining` number of remaining requests that can still be performed -// - `x-ratelimit-reset` unix timestamp when the rate limit will reset -// if `x-ratelimit-limit` is exceeded an HTTP 429 error is returned -func (m *Module) RateLimit(rateOptions RateLimitOptions) func(c *gin.Context) { - rate := limiter.Rate{ - Period: rateOptions.Period, - Limit: rateOptions.Limit, - } - - store := memory.NewStore() - - limiterInstance := limiter.New( - store, - rate, - // apply /64 mask to IPv6 addresses - limiter.WithIPv6Mask(net.CIDRMask(64, 128)), - ) - - middleware := mgin.NewMiddleware( - limiterInstance, - // use custom rate limit reached error - mgin.WithLimitReachedHandler(m.LimitReachedHandler), - ) - - return middleware -} diff --git a/internal/api/security/robots.go b/internal/api/security/robots.go @@ -1,57 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 ( - "net/http" - - "github.com/gin-gonic/gin" -) - -const robotsString = `User-agent: * -Crawl-delay: 500 -# api stuff -Disallow: /api/ -# auth/login stuff -Disallow: /auth/ -Disallow: /oauth/ -Disallow: /check_your_email -Disallow: /wait_for_approval -Disallow: /account_disabled -# well known stuff -Disallow: /.well-known/ -# files -Disallow: /fileserver/ -# s2s AP stuff -Disallow: /users/ -Disallow: /emoji/ -# panels -Disallow: /admin -Disallow: /user -Disallow: /settings/ -` - -// RobotsGETHandler returns a decent robots.txt that prevents crawling -// the api, auth pages, settings pages, etc. -// -// More granular robots meta tags are then applied for web pages -// depending on user preferences (see internal/web). -func (m *Module) RobotsGETHandler(c *gin.Context) { - c.String(http.StatusOK, robotsString) -} diff --git a/internal/api/security/security.go b/internal/api/security/security.go @@ -1,65 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 ( - "net/http" - "time" - - "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 robotsPath = "/robots.txt" - -// Module implements the ClientAPIModule interface for security middleware -type Module struct { - db db.DB - server oauth.Server -} - -// New returns a new security module -func New(db db.DB, server oauth.Server) api.ClientModule { - return &Module{ - db: db, - server: server, - } -} - -// Route attaches security middleware to the given router -func (m *Module) Route(s router.Router) error { - // only enable rate limit middleware if configured - // advanced-rate-limit-requests is greater than 0 - if rateLimitRequests := config.GetAdvancedRateLimitRequests(); rateLimitRequests > 0 { - s.AttachMiddleware(m.RateLimit(RateLimitOptions{ - Period: 5 * time.Minute, - Limit: int64(rateLimitRequests), - })) - } - s.AttachMiddleware(m.SignatureCheck) - s.AttachMiddleware(m.FlocBlock) - s.AttachMiddleware(m.ExtraHeaders) - s.AttachMiddleware(m.UserAgentBlock) - s.AttachMiddleware(m.TokenCheck) - s.AttachHandler(http.MethodGet, robotsPath, m.RobotsGETHandler) - return nil -} diff --git a/internal/api/security/signaturecheck.go b/internal/api/security/signaturecheck.go @@ -1,51 +0,0 @@ -package security - -import ( - "net/http" - "net/url" - - "github.com/superseriousbusiness/gotosocial/internal/ap" - "github.com/superseriousbusiness/gotosocial/internal/log" - - "github.com/gin-gonic/gin" - "github.com/go-fed/httpsig" -) - -// SignatureCheck checks whether an incoming http request has been signed. If so, it will check if the domain -// that signed the request is permitted to access the server. If it is permitted, the handler will set the key -// verifier and the signature in the gin context for use down the line. -func (m *Module) SignatureCheck(c *gin.Context) { - // create the verifier from the request - // if the request is signed, it will have a signature header - verifier, err := httpsig.NewVerifier(c.Request) - if err == nil { - // the request was signed! - - // The key ID should be given in the signature so that we know where to fetch it from the remote server. - // This will be something like https://example.org/users/whatever_requesting_user#main-key - requestingPublicKeyID, err := url.Parse(verifier.KeyId()) - if err == nil && requestingPublicKeyID != nil { - // we managed to parse the url! - - // if the domain is blocked we want to bail as early as possible - blocked, err := m.db.IsURIBlocked(c.Request.Context(), requestingPublicKeyID) - if err != nil { - log.Errorf("could not tell if domain %s was blocked or not: %s", requestingPublicKeyID.Host, err) - c.AbortWithStatus(http.StatusInternalServerError) - return - } - if blocked { - log.Infof("domain %s is blocked", requestingPublicKeyID.Host) - c.AbortWithStatus(http.StatusForbidden) - return - } - - // set the verifier and signature on the context here to save some work further down the line - c.Set(string(ap.ContextRequestingPublicKeyVerifier), verifier) - signature := c.GetHeader("Signature") - if signature != "" { - c.Set(string(ap.ContextRequestingPublicKeySignature), signature) - } - } - } -} diff --git a/internal/api/security/tokencheck.go b/internal/api/security/tokencheck.go @@ -1,120 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/log" - "github.com/superseriousbusiness/gotosocial/internal/oauth" -) - -// TokenCheck 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) TokenCheck(c *gin.Context) { - ctx := c.Request.Context() - defer c.Next() - - if c.Request.Header.Get("Authorization") == "" { - // no token set in the header, we can just bail - return - } - - ti, err := m.server.ValidationBearerToken(c.Copy().Request) - if err != nil { - log.Infof("token was passed in Authorization header but we could not validate it: %s", err) - return - } - c.Set(oauth.SessionAuthorizedToken, ti) - - // check for user-level token - if userID := ti.GetUserID(); userID != "" { - log.Tracef("authenticated user %s with bearer token, scope is %s", userID, ti.GetScope()) - - // fetch user for this token - user, err := m.db.GetUserByID(ctx, userID) - if err != nil { - if err != db.ErrNoEntries { - log.Errorf("database error looking for user with id %s: %s", userID, err) - return - } - log.Warnf("no user found for userID %s", userID) - return - } - - if user.ConfirmedAt.IsZero() { - log.Warnf("authenticated user %s has never confirmed thier email address", userID) - return - } - - if !*user.Approved { - log.Warnf("authenticated user %s's account was never approved by an admin", userID) - return - } - - if *user.Disabled { - log.Warnf("authenticated user %s's account was disabled'", userID) - return - } - - c.Set(oauth.SessionAuthorizedUser, user) - - // fetch account for this token - if user.Account == nil { - acct, err := m.db.GetAccountByID(ctx, user.AccountID) - if err != nil { - if err != db.ErrNoEntries { - log.Errorf("database error looking for account with id %s: %s", user.AccountID, err) - return - } - log.Warnf("no account found for userID %s", userID) - return - } - user.Account = acct - } - - if !user.Account.SuspendedAt.IsZero() { - log.Warnf("authenticated user %s's account (accountId=%s) has been suspended", userID, user.AccountID) - return - } - - c.Set(oauth.SessionAuthorizedAccount, user.Account) - } - - // check for application token - if clientID := ti.GetClientID(); clientID != "" { - log.Tracef("authenticated client %s with bearer token, scope is %s", clientID, ti.GetScope()) - - // fetch app for this token - app := &gtsmodel.Application{} - if err := m.db.GetWhere(ctx, []db.Where{{Key: "client_id", Value: clientID}}, app); err != nil { - if err != db.ErrNoEntries { - log.Errorf("database error looking for application with clientID %s: %s", clientID, err) - return - } - log.Warnf("no app found for client %s", clientID) - return - } - c.Set(oauth.SessionAuthorizedApplication, app) - } -} diff --git a/internal/api/security/useragentblock.go b/internal/api/security/useragentblock.go @@ -1,35 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 ( - "errors" - "net/http" - - "github.com/gin-gonic/gin" -) - -// UserAgentBlock aborts requests with empty user agent strings. -func (m *Module) UserAgentBlock(c *gin.Context) { - if ua := c.Request.UserAgent(); ua == "" { - code := http.StatusTeapot - err := errors.New(http.StatusText(code) + ": no user-agent sent with request") - c.AbortWithStatusJSON(code, gin.H{"error": err.Error()}) - } -} diff --git a/internal/api/util/errorhandling.go b/internal/api/util/errorhandling.go @@ -0,0 +1,132 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 ( + "context" + "net/http" + + "codeberg.org/gruf/go-kv" + "github.com/gin-gonic/gin" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/log" +) + +// TODO: add more templated html pages here for different error types + +// NotFoundHandler serves a 404 html page through the provided gin context, +// if accept is 'text/html', or just returns a json error if 'accept' is empty +// or application/json. +// +// When serving html, NotFoundHandler calls the provided InstanceGet function +// to fetch the apimodel representation of the instance, for serving in the +// 404 header and footer. +// +// If an error is returned by InstanceGet, the function will panic. +func NotFoundHandler(c *gin.Context, instanceGet func(ctx context.Context, domain string) (*apimodel.Instance, gtserror.WithCode), accept string) { + switch accept { + case string(TextHTML): + host := config.GetHost() + instance, err := instanceGet(c.Request.Context(), host) + if err != nil { + panic(err) + } + + c.HTML(http.StatusNotFound, "404.tmpl", gin.H{ + "instance": instance, + }) + default: + c.JSON(http.StatusNotFound, gin.H{"error": http.StatusText(http.StatusNotFound)}) + } +} + +// genericErrorHandler is a more general version of the NotFoundHandler, which can +// be used for serving either generic error pages with some rendered help text, +// or just some error json if the caller prefers (or has no preference). +func genericErrorHandler(c *gin.Context, instanceGet func(ctx context.Context, domain string) (*apimodel.Instance, gtserror.WithCode), accept string, errWithCode gtserror.WithCode) { + switch accept { + case string(TextHTML): + host := config.GetHost() + instance, err := instanceGet(c.Request.Context(), host) + if err != nil { + panic(err) + } + + c.HTML(errWithCode.Code(), "error.tmpl", gin.H{ + "instance": instance, + "code": errWithCode.Code(), + "error": errWithCode.Safe(), + }) + default: + c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + } +} + +// ErrorHandler takes the provided gin context and errWithCode and tries to serve +// a helpful error to the caller. It will do content negotiation to figure out if +// the caller prefers to see an html page with the error rendered there. If not, or +// if something goes wrong during the function, it will recover and just try to serve +// an appropriate application/json content-type error. +func ErrorHandler(c *gin.Context, errWithCode gtserror.WithCode, instanceGet func(ctx context.Context, domain string) (*apimodel.Instance, gtserror.WithCode)) { + // set the error on the gin context so that it can be logged + // in the gin logger middleware (internal/router/logger.go) + c.Error(errWithCode) //nolint:errcheck + + // discover if we're allowed to serve a nice html error page, + // or if we should just use a json. Normally we would want to + // check for a returned error, but if an error occurs here we + // can just fall back to default behavior (serve json error). + accept, _ := NegotiateAccept(c, HTMLOrJSONAcceptHeaders...) + + if errWithCode.Code() == http.StatusNotFound { + // use our special not found handler with useful status text + NotFoundHandler(c, instanceGet, accept) + } else { + genericErrorHandler(c, instanceGet, accept, errWithCode) + } +} + +// OAuthErrorHandler is a lot like ErrorHandler, but it specifically returns errors +// that are compatible with https://datatracker.ietf.org/doc/html/rfc6749#section-5.2, +// but serializing errWithCode.Error() in the 'error' field, and putting any help text +// from the error in the 'error_description' field. This means you should be careful not +// to pass any detailed errors (that might contain sensitive information) into the +// errWithCode.Error() field, since the client will see this. Use your noggin! +func OAuthErrorHandler(c *gin.Context, errWithCode gtserror.WithCode) { + l := log.WithFields(kv.Fields{ + {"path", c.Request.URL.Path}, + {"error", errWithCode.Error()}, + {"help", errWithCode.Safe()}, + }...) + + statusCode := errWithCode.Code() + + if statusCode == http.StatusInternalServerError { + l.Error("Internal Server Error") + } else { + l.Debug("handling OAuth error") + } + + c.JSON(statusCode, gin.H{ + "error": errWithCode.Error(), + "error_description": errWithCode.Safe(), + }) +} diff --git a/internal/api/util/mime.go b/internal/api/util/mime.go @@ -0,0 +1,36 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 + +// MIME represents a mime-type. +type MIME string + +// MIME type +const ( + AppJSON MIME = `application/json` + AppXML MIME = `application/xml` + AppRSSXML MIME = `application/rss+xml` + AppActivityJSON MIME = `application/activity+json` + AppActivityLDJSON MIME = `application/ld+json; profile="https://www.w3.org/ns/activitystreams"` + AppForm MIME = `application/x-www-form-urlencoded` + MultipartForm MIME = `multipart/form-data` + TextXML MIME = `text/xml` + TextHTML MIME = `text/html` + TextCSS MIME = `text/css` +) diff --git a/internal/api/util/negotiate.go b/internal/api/util/negotiate.go @@ -0,0 +1,104 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 ( + "errors" + "fmt" + + "github.com/gin-gonic/gin" +) + +// ActivityPubAcceptHeaders represents the Accept headers mentioned here: +var ActivityPubAcceptHeaders = []MIME{ + AppActivityJSON, + AppActivityLDJSON, +} + +// JSONAcceptHeaders is a slice of offers that just contains application/json types. +var JSONAcceptHeaders = []MIME{ + AppJSON, +} + +// HTMLOrJSONAcceptHeaders is a slice of offers that prefers TextHTML and will +// fall back to JSON if necessary. This is useful for error handling, since it can +// be used to serve a nice HTML page if the caller accepts that, or just JSON if not. +var HTMLOrJSONAcceptHeaders = []MIME{ + TextHTML, + AppJSON, +} + +// HTMLAcceptHeaders is a slice of offers that just contains text/html types. +var HTMLAcceptHeaders = []MIME{ + TextHTML, +} + +// HTMLOrActivityPubHeaders matches text/html first, then activitypub types. +// This is useful for user URLs that a user might go to in their browser. +// https://www.w3.org/TR/activitypub/#retrieving-objects +var HTMLOrActivityPubHeaders = []MIME{ + TextHTML, + AppActivityJSON, + AppActivityLDJSON, +} + +// NegotiateAccept takes the *gin.Context from an incoming request, and a +// slice of Offers, and performs content negotiation for the given request +// with the given content-type offers. It will return a string representation +// of the first suitable content-type, or an error if something goes wrong or +// a suitable content-type cannot be matched. +// +// For example, if the request in the *gin.Context has Accept headers of value +// [application/json, text/html], and the provided offers are of value +// [application/json, application/xml], then the returned string will be +// 'application/json', which indicates the content-type that should be returned. +// +// If the length of offers is 0, then an error will be returned, so this function +// should only be called in places where format negotiation is actually needed. +// +// If there are no Accept headers in the request, then the first offer will be returned, +// under the assumption that it's better to serve *something* than error out completely. +// +// Callers can use the offer slices exported in this package as shortcuts for +// often-used Accept types. +// +// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Content_negotiation#server-driven_content_negotiation +func NegotiateAccept(c *gin.Context, offers ...MIME) (string, error) { + if len(offers) == 0 { + return "", errors.New("no format offered") + } + + strings := []string{} + for _, o := range offers { + strings = append(strings, string(o)) + } + + accepts := c.Request.Header.Values("Accept") + if len(accepts) == 0 { + // there's no accept header set, just return the first offer + return strings[0], nil + } + + format := c.NegotiateFormat(strings...) + if format == "" { + return "", fmt.Errorf("no format can be offered for requested Accept header(s) %s; this endpoint offers %s", accepts, offers) + } + + return format, nil +} diff --git a/internal/api/util/signaturectx.go b/internal/api/util/signaturectx.go @@ -0,0 +1,41 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 ( + "context" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/ap" +) + +// TransferSignatureContext transfers a signature verifier and signature from a gin context to a go context. +func TransferSignatureContext(c *gin.Context) context.Context { + ctx := c.Request.Context() + + if verifier, signed := c.Get(string(ap.ContextRequestingPublicKeyVerifier)); signed { + ctx = context.WithValue(ctx, ap.ContextRequestingPublicKeyVerifier, verifier) + } + + if signature, signed := c.Get(string(ap.ContextRequestingPublicKeySignature)); signed { + ctx = context.WithValue(ctx, ap.ContextRequestingPublicKeySignature, signature) + } + + return ctx +} diff --git a/internal/api/wellknown.go b/internal/api/wellknown.go @@ -0,0 +1,55 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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/api/wellknown/nodeinfo" + "github.com/superseriousbusiness/gotosocial/internal/api/wellknown/webfinger" + "github.com/superseriousbusiness/gotosocial/internal/middleware" + "github.com/superseriousbusiness/gotosocial/internal/processing" + "github.com/superseriousbusiness/gotosocial/internal/router" +) + +type WellKnown struct { + nodeInfo *nodeinfo.Module + webfinger *webfinger.Module +} + +func (w *WellKnown) Route(r router.Router) { + // group .well-known endpoints together + wellKnownGroup := r.AttachGroup(".well-known") + + // attach middlewares appropriate for this group + wellKnownGroup.Use( + middleware.Gzip(), + middleware.RateLimit(), + // allow .well-known responses to be cached for 2 minutes + middleware.CacheControl("public", "max-age=120"), + ) + + w.nodeInfo.Route(wellKnownGroup.Handle) + w.webfinger.Route(wellKnownGroup.Handle) +} + +func NewWellKnown(p processing.Processor) *WellKnown { + return &WellKnown{ + nodeInfo: nodeinfo.New(p), + webfinger: webfinger.New(p), + } +} diff --git a/internal/api/wellknown/nodeinfo/nodeinfo.go b/internal/api/wellknown/nodeinfo/nodeinfo.go @@ -0,0 +1,47 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 nodeinfo + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/processing" +) + +const ( + // NodeInfoWellKnownPath is the base path for serving responses + // to nodeinfo lookup requests, minus the '.well-known' prefix. + NodeInfoWellKnownPath = "/nodeinfo" +) + +type Module struct { + processor processing.Processor +} + +// New returns a new nodeinfo module +func New(processor processing.Processor) *Module { + return &Module{ + processor: processor, + } +} + +func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) { + attachHandler(http.MethodGet, NodeInfoWellKnownPath, m.NodeInfoWellKnownGETHandler) +} diff --git a/internal/api/wellknown/nodeinfo/nodeinfoget.go b/internal/api/wellknown/nodeinfo/nodeinfoget.go @@ -0,0 +1,60 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 nodeinfo + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" +) + +// NodeInfoWellKnownGETHandler swagger:operation GET /.well-known/nodeinfo nodeInfoWellKnownGet +// +// Returns a well-known response which redirects callers to `/nodeinfo/2.0`. +// +// eg. `{"links":[{"rel":"http://nodeinfo.diaspora.software/ns/schema/2.0","href":"http://example.org/nodeinfo/2.0"}]}` +// See: https://nodeinfo.diaspora.software/protocol.html +// +// --- +// tags: +// - .well-known +// +// produces: +// - application/json +// +// responses: +// '200': +// schema: +// "$ref": "#/definitions/wellKnownResponse" +func (m *Module) NodeInfoWellKnownGETHandler(c *gin.Context) { + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + resp, errWithCode := m.processor.GetNodeInfoRel(c.Request.Context()) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + c.JSON(http.StatusOK, resp) +} diff --git a/internal/api/wellknown/webfinger/webfinger.go b/internal/api/wellknown/webfinger/webfinger.go @@ -0,0 +1,46 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 webfinger + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/processing" +) + +const ( + // WebfingerBasePath is the base path for serving webfinger + // lookup requests, minus the .well-known prefix + WebfingerBasePath = "/webfinger" +) + +type Module struct { + processor processing.Processor +} + +func New(processor processing.Processor) *Module { + return &Module{ + processor: processor, + } +} + +func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) { + attachHandler(http.MethodGet, WebfingerBasePath, m.WebfingerGETRequest) +} diff --git a/internal/api/wellknown/webfinger/webfinger_test.go b/internal/api/wellknown/webfinger/webfinger_test.go @@ -0,0 +1,134 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 webfinger_test + +import ( + "crypto/rand" + "crypto/rsa" + "time" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/ap" + "github.com/superseriousbusiness/gotosocial/internal/api/wellknown/webfinger" + "github.com/superseriousbusiness/gotosocial/internal/concurrency" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/email" + "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/messages" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/processing" + "github.com/superseriousbusiness/gotosocial/internal/storage" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type WebfingerStandardTestSuite struct { + // standard suite interfaces + suite.Suite + db db.DB + tc typeutils.TypeConverter + mediaManager media.Manager + federator federation.Federator + emailSender email.Sender + processor processing.Processor + storage *storage.Driver + oauthServer oauth.Server + + // standard suite models + testTokens map[string]*gtsmodel.Token + testClients map[string]*gtsmodel.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 + webfingerModule *webfinger.Module +} + +func (suite *WebfingerStandardTestSuite) 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 *WebfingerStandardTestSuite) SetupTest() { + testrig.InitTestLog() + testrig.InitTestConfig() + + clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) + fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) + + suite.db = testrig.NewTestDB() + suite.tc = testrig.NewTestTypeConverter(suite.db) + suite.storage = testrig.NewInMemoryStorage() + suite.mediaManager = testrig.NewTestMediaManager(suite.db, suite.storage) + suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), suite.db, fedWorker), suite.storage, suite.mediaManager, fedWorker) + suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil) + suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, suite.mediaManager, clientWorker, fedWorker) + suite.webfingerModule = webfinger.New(suite.processor) + suite.oauthServer = testrig.NewTestOauthServer(suite.db) + testrig.StandardDBSetup(suite.db, suite.testAccounts) + testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") + + suite.NoError(suite.processor.Start()) +} + +func (suite *WebfingerStandardTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) +} + +func accountDomainAccount() *gtsmodel.Account { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + panic(err) + } + publicKey := &privateKey.PublicKey + + acct := &gtsmodel.Account{ + ID: "01FG1K8EA7SYHEC7V6XKVNC4ZA", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Username: "aaaaa", + Domain: "", + Privacy: gtsmodel.VisibilityDefault, + Language: "en", + URI: "http://gts.example.org/users/aaaaa", + URL: "http://gts.example.org/@aaaaa", + InboxURI: "http://gts.example.org/users/aaaaa/inbox", + OutboxURI: "http://gts.example.org/users/aaaaa/outbox", + FollowingURI: "http://gts.example.org/users/aaaaa/following", + FollowersURI: "http://gts.example.org/users/aaaaa/followers", + FeaturedCollectionURI: "http://gts.example.org/users/aaaaa/collections/featured", + ActorType: ap.ActorPerson, + PrivateKey: privateKey, + PublicKey: publicKey, + PublicKeyURI: "http://gts.example.org/users/aaaaa/main-key", + } + + return acct +} diff --git a/internal/api/wellknown/webfinger/webfingerget.go b/internal/api/wellknown/webfinger/webfingerget.go @@ -0,0 +1,91 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 webfinger + +import ( + "errors" + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +// WebfingerGETRequest swagger:operation GET /.well-known/webfinger webfingerGet +// +// Handles webfinger account lookup requests. +// +// For example, a GET to `https://goblin.technology/.well-known/webfinger?resource=acct:tobi@goblin.technology` would return: +// +// ``` +// +// {"subject":"acct:tobi@goblin.technology","aliases":["https://goblin.technology/users/tobi","https://goblin.technology/@tobi"],"links":[{"rel":"http://webfinger.net/rel/profile-page","type":"text/html","href":"https://goblin.technology/@tobi"},{"rel":"self","type":"application/activity+json","href":"https://goblin.technology/users/tobi"}]} +// +// ``` +// +// See: https://webfinger.net/ +// +// --- +// tags: +// - .well-known +// +// produces: +// - application/json +// +// responses: +// '200': +// schema: +// "$ref": "#/definitions/wellKnownResponse" +func (m *Module) WebfingerGETRequest(c *gin.Context) { + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + resourceQuery, set := c.GetQuery("resource") + if !set || resourceQuery == "" { + err := errors.New("no 'resource' in request query") + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + requestedUsername, requestedHost, err := util.ExtractWebfingerParts(resourceQuery) + if err != nil { + err := fmt.Errorf("bad webfinger request with resource query %s: %w", resourceQuery, err) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + if requestedHost != config.GetHost() && requestedHost != config.GetAccountDomain() { + err := fmt.Errorf("requested host %s does not belong to this instance", requestedHost) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + resp, errWithCode := m.processor.GetWebfingerAccount(c.Request.Context(), requestedUsername) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + c.JSON(http.StatusOK, resp) +} diff --git a/internal/api/wellknown/webfinger/webfingerget_test.go b/internal/api/wellknown/webfinger/webfingerget_test.go @@ -0,0 +1,171 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 webfinger_test + +import ( + "context" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/wellknown/webfinger" + "github.com/superseriousbusiness/gotosocial/internal/concurrency" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/messages" + "github.com/superseriousbusiness/gotosocial/internal/processing" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type WebfingerGetTestSuite struct { + WebfingerStandardTestSuite +} + +func (suite *WebfingerGetTestSuite) TestFingerUser() { + targetAccount := suite.testAccounts["local_account_1"] + + // setup request + host := config.GetHost() + requestPath := fmt.Sprintf("/%s?resource=acct:%s@%s", webfinger.WebfingerBasePath, targetAccount.Username, host) + + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Request = httptest.NewRequest(http.MethodGet, requestPath, nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/json") + + // trigger the function being tested + suite.webfingerModule.WebfingerGETRequest(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) + + suite.Equal(`{"subject":"acct:the_mighty_zork@localhost:8080","aliases":["http://localhost:8080/users/the_mighty_zork","http://localhost:8080/@the_mighty_zork"],"links":[{"rel":"http://webfinger.net/rel/profile-page","type":"text/html","href":"http://localhost:8080/@the_mighty_zork"},{"rel":"self","type":"application/activity+json","href":"http://localhost:8080/users/the_mighty_zork"}]}`, string(b)) +} + +func (suite *WebfingerGetTestSuite) TestFingerUserWithDifferentAccountDomainByHost() { + config.SetHost("gts.example.org") + config.SetAccountDomain("example.org") + + clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) + fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) + suite.processor = processing.NewProcessor(suite.tc, suite.federator, testrig.NewTestOauthServer(suite.db), testrig.NewTestMediaManager(suite.db, suite.storage), suite.storage, suite.db, suite.emailSender, clientWorker, fedWorker) + suite.webfingerModule = webfinger.New(suite.processor) + + targetAccount := accountDomainAccount() + if err := suite.db.Put(context.Background(), targetAccount); err != nil { + panic(err) + } + + // setup request + host := config.GetHost() + requestPath := fmt.Sprintf("/%s?resource=acct:%s@%s", webfinger.WebfingerBasePath, targetAccount.Username, host) + + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Request = httptest.NewRequest(http.MethodGet, requestPath, nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/json") + + // trigger the function being tested + suite.webfingerModule.WebfingerGETRequest(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) + + suite.Equal(`{"subject":"acct:aaaaa@example.org","aliases":["http://gts.example.org/users/aaaaa","http://gts.example.org/@aaaaa"],"links":[{"rel":"http://webfinger.net/rel/profile-page","type":"text/html","href":"http://gts.example.org/@aaaaa"},{"rel":"self","type":"application/activity+json","href":"http://gts.example.org/users/aaaaa"}]}`, string(b)) +} + +func (suite *WebfingerGetTestSuite) TestFingerUserWithDifferentAccountDomainByAccountDomain() { + config.SetHost("gts.example.org") + config.SetAccountDomain("example.org") + + clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) + fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) + suite.processor = processing.NewProcessor(suite.tc, suite.federator, testrig.NewTestOauthServer(suite.db), testrig.NewTestMediaManager(suite.db, suite.storage), suite.storage, suite.db, suite.emailSender, clientWorker, fedWorker) + suite.webfingerModule = webfinger.New(suite.processor) + + targetAccount := accountDomainAccount() + if err := suite.db.Put(context.Background(), targetAccount); err != nil { + panic(err) + } + + // setup request + accountDomain := config.GetAccountDomain() + requestPath := fmt.Sprintf("/%s?resource=acct:%s@%s", webfinger.WebfingerBasePath, targetAccount.Username, accountDomain) + + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Request = httptest.NewRequest(http.MethodGet, requestPath, nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/json") + + // trigger the function being tested + suite.webfingerModule.WebfingerGETRequest(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) + + suite.Equal(`{"subject":"acct:aaaaa@example.org","aliases":["http://gts.example.org/users/aaaaa","http://gts.example.org/@aaaaa"],"links":[{"rel":"http://webfinger.net/rel/profile-page","type":"text/html","href":"http://gts.example.org/@aaaaa"},{"rel":"self","type":"application/activity+json","href":"http://gts.example.org/users/aaaaa"}]}`, string(b)) +} + +func (suite *WebfingerGetTestSuite) TestFingerUserWithoutAcct() { + targetAccount := suite.testAccounts["local_account_1"] + + // setup request -- leave out the 'acct:' prefix, which is prettymuch what pixelfed currently does + host := config.GetHost() + requestPath := fmt.Sprintf("/%s?resource=%s@%s", webfinger.WebfingerBasePath, targetAccount.Username, host) + + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Request = httptest.NewRequest(http.MethodGet, requestPath, nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/json") + + // trigger the function being tested + suite.webfingerModule.WebfingerGETRequest(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) + + suite.Equal(`{"subject":"acct:the_mighty_zork@localhost:8080","aliases":["http://localhost:8080/users/the_mighty_zork","http://localhost:8080/@the_mighty_zork"],"links":[{"rel":"http://webfinger.net/rel/profile-page","type":"text/html","href":"http://localhost:8080/@the_mighty_zork"},{"rel":"self","type":"application/activity+json","href":"http://localhost:8080/users/the_mighty_zork"}]}`, string(b)) +} + +func TestWebfingerGetTestSuite(t *testing.T) { + suite.Run(t, new(WebfingerGetTestSuite)) +} diff --git a/internal/config/defaults.go b/internal/config/defaults.go @@ -105,7 +105,7 @@ var Defaults = Configuration{ SyslogAddress: "localhost:514", AdvancedCookiesSamesite: "lax", - AdvancedRateLimitRequests: 1000, // per 5 minutes + AdvancedRateLimitRequests: 300, // 1 per second per 5 minutes Cache: CacheConfiguration{ GTS: GTSCacheConfiguration{ diff --git a/internal/db/db.go b/internal/db/db.go @@ -53,7 +53,7 @@ type DB interface { // TagStringsToTags takes a slice of deduplicated, lowercase tags in the form "somehashtag", which have been // used in a status. It takes the id of the account that wrote the status, and the id of the status itself, and then - // returns a slice of *model.Tag corresponding to the given tags. If the tag already exists in database, that tag + // returns a slice of *apimodel.Tag corresponding to the given tags. If the tag already exists in database, that tag // will be returned. Otherwise a pointer to a new tag struct will be created and returned. // // Note: this func doesn't/shouldn't do any manipulation of the tags in the DB, it's just for checking diff --git a/internal/middleware/cachecontrol.go b/internal/middleware/cachecontrol.go @@ -0,0 +1,35 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 middleware + +import ( + "strings" + + "github.com/gin-gonic/gin" +) + +// CacheControl returns a new gin middleware which allows callers to control cache settings on response headers. +// +// For directives, see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control +func CacheControl(directives ...string) gin.HandlerFunc { + ccHeader := strings.Join(directives, ", ") + return func(c *gin.Context) { + c.Header("Cache-Control", ccHeader) + } +} diff --git a/internal/middleware/cors.go b/internal/middleware/cors.go @@ -0,0 +1,86 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 middleware + +import ( + "time" + + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" +) + +// CORS returns a new gin middleware which allows CORS requests to be processed. +// This is necessary in order for web/browser-based clients like Pinafore to work. +func CORS() gin.HandlerFunc { + cfg := cors.Config{ + // todo: use config to customize this + AllowAllOrigins: true, + + // adds the following: + // "chrome-extension://" + // "safari-extension://" + // "moz-extension://" + // "ms-browser-extension://" + AllowBrowserExtensions: true, + AllowMethods: []string{ + "POST", + "PUT", + "DELETE", + "GET", + "PATCH", + "OPTIONS", + }, + AllowHeaders: []string{ + // basic cors stuff + "Origin", + "Content-Length", + "Content-Type", + + // needed to pass oauth bearer tokens + "Authorization", + + // needed for websocket upgrade requests + "Upgrade", + "Sec-WebSocket-Extensions", + "Sec-WebSocket-Key", + "Sec-WebSocket-Protocol", + "Sec-WebSocket-Version", + "Connection", + }, + AllowWebSockets: true, + ExposeHeaders: []string{ + // needed for accessing next/prev links when making GET timeline requests + "Link", + + // needed so clients can handle rate limits + "X-RateLimit-Reset", + "X-RateLimit-Limit", + "X-RateLimit-Remaining", + "X-Request-Id", + + // websocket stuff + "Connection", + "Sec-WebSocket-Accept", + "Upgrade", + }, + MaxAge: 2 * time.Minute, + } + + return cors.New(cfg) +} diff --git a/internal/middleware/extraheaders.go b/internal/middleware/extraheaders.go @@ -0,0 +1,37 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 middleware + +import "github.com/gin-gonic/gin" + +// ExtraHeaders returns a new gin middleware which adds various extra headers to the response. +func ExtraHeaders() gin.HandlerFunc { + return func(c *gin.Context) { + // Inform all callers which server implementation this is. + c.Header("Server", "gotosocial") + // Prevent google chrome cohort tracking. Originally this was referred + // to as FlocBlock. Floc was replaced by Topics in 2022 and the spec says + // that interest-cohort will also block Topics (as of 2022-Nov). + // + // See: https://smartframe.io/blog/google-topics-api-everything-you-need-to-know + // + // See: https://github.com/patcg-individual-drafts/topics + c.Header("Permissions-Policy", "browsing-topics=()") + } +} diff --git a/internal/middleware/gzip.go b/internal/middleware/gzip.go @@ -0,0 +1,30 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 middleware + +import ( + ginGzip "github.com/gin-contrib/gzip" + "github.com/gin-gonic/gin" +) + +// Gzip returns a gzip gin middleware using default compression. +func Gzip() gin.HandlerFunc { + // todo: make this configurable + return ginGzip.Gzip(ginGzip.DefaultCompression) +} diff --git a/internal/middleware/logger.go b/internal/middleware/logger.go @@ -0,0 +1,99 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 middleware + +import ( + "fmt" + "net/http" + "time" + + "codeberg.org/gruf/go-bytesize" + "codeberg.org/gruf/go-errors/v2" + "codeberg.org/gruf/go-kv" + "codeberg.org/gruf/go-logger/v2/level" + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/log" +) + +// Logger returns a gin middleware which provides request logging and panic recovery. +func Logger() gin.HandlerFunc { + return func(c *gin.Context) { + // Initialize the logging fields + fields := make(kv.Fields, 6, 7) + + // Determine pre-handler time + before := time.Now() + + // defer so that we log *after the request has completed* + defer func() { + code := c.Writer.Status() + path := c.Request.URL.Path + + if r := recover(); r != nil { + if c.Writer.Status() == 0 { + // No response was written, send a generic Internal Error + c.Writer.WriteHeader(http.StatusInternalServerError) + } + + // Append panic information to the request ctx + err := fmt.Errorf("recovered panic: %v", r) + _ = c.Error(err) + + // Dump a stacktrace to error log + callers := errors.GetCallers(3, 10) + log.WithField("stacktrace", callers).Error(err) + } + + // NOTE: + // It is very important here that we are ONLY logging + // the request path, and none of the query parameters. + // Query parameters can contain sensitive information + // and could lead to storing plaintext API keys in logs + + // Set request logging fields + fields[0] = kv.Field{"latency", time.Since(before)} + fields[1] = kv.Field{"clientIP", c.ClientIP()} + fields[2] = kv.Field{"userAgent", c.Request.UserAgent()} + fields[3] = kv.Field{"method", c.Request.Method} + fields[4] = kv.Field{"statusCode", code} + fields[5] = kv.Field{"path", path} + + // Create log entry with fields + l := log.WithFields(fields...) + + // Default is info + lvl := level.INFO + + if code >= 500 { + // This is a server error + lvl = level.ERROR + l = l.WithField("error", c.Errors) + } + + // Generate a nicer looking bytecount + size := bytesize.Size(c.Writer.Size()) + + // Finally, write log entry with status text body size + l.Logf(lvl, "%s: wrote %s", http.StatusText(code), size) + }() + + // Process request + c.Next() + } +} diff --git a/internal/middleware/ratelimit.go b/internal/middleware/ratelimit.go @@ -0,0 +1,77 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 middleware + +import ( + "net" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/ulule/limiter/v3" + limitergin "github.com/ulule/limiter/v3/drivers/middleware/gin" + "github.com/ulule/limiter/v3/drivers/store/memory" +) + +const rateLimitPeriod = 5 * time.Minute + +// RateLimit returns a gin middleware that will automatically rate limit caller (by IP address), +// and enrich the response header with the following headers: +// +// - `x-ratelimit-limit` - maximum number of requests allowed per time period (fixed). +// - `x-ratelimit-remaining` - number of remaining requests that can still be performed. +// - `x-ratelimit-reset` - unix timestamp when the rate limit will reset. +// +// If `x-ratelimit-limit` is exceeded, the request is aborted and an HTTP 429 TooManyRequests +// status is returned. +// +// If the config AdvancedRateLimitRequests value is <= 0, then a noop handler will be returned, +// which performs no rate limiting. +func RateLimit() gin.HandlerFunc { + // only enable rate limit middleware if configured + // advanced-rate-limit-requests is greater than 0 + rateLimitRequests := config.GetAdvancedRateLimitRequests() + if rateLimitRequests <= 0 { + // use noop middleware if ratelimiting is disabled + return func(c *gin.Context) {} + } + + rate := limiter.Rate{ + Period: rateLimitPeriod, + Limit: int64(rateLimitRequests), + } + + limiterInstance := limiter.New( + memory.NewStore(), + rate, + limiter.WithIPv6Mask(net.CIDRMask(64, 128)), // apply /64 mask to IPv6 addresses + ) + + limitReachedHandler := func(c *gin.Context) { + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "rate limit reached"}) + } + + middleware := limitergin.NewMiddleware( + limiterInstance, + limitergin.WithLimitReachedHandler(limitReachedHandler), // use custom rate limit reached error + ) + + return middleware +} diff --git a/internal/middleware/session.go b/internal/middleware/session.go @@ -0,0 +1,95 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 middleware + +import ( + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/memstore" + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/log" + "golang.org/x/net/idna" +) + +// SessionOptions returns the standard set of options to use for each session. +func SessionOptions() sessions.Options { + var samesite http.SameSite + switch strings.TrimSpace(strings.ToLower(config.GetAdvancedCookiesSamesite())) { + case "lax": + samesite = http.SameSiteLaxMode + case "strict": + samesite = http.SameSiteStrictMode + default: + log.Warnf("%s set to %s which is not recognized, defaulting to 'lax'", config.AdvancedCookiesSamesiteFlag(), config.GetAdvancedCookiesSamesite()) + samesite = http.SameSiteLaxMode + } + + return sessions.Options{ + Path: "/", + Domain: config.GetHost(), + // 2 minutes + MaxAge: 120, + // only set secure over https + Secure: config.GetProtocol() == "https", + // forbid javascript from inspecting cookie + HttpOnly: true, + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-cookie-same-site-00#section-4.1.1 + SameSite: samesite, + } +} + +// SessionName is a utility function that derives an appropriate session name from the hostname. +func SessionName() (string, error) { + // parse the protocol + host + protocol := config.GetProtocol() + host := config.GetHost() + u, err := url.Parse(fmt.Sprintf("%s://%s", protocol, host)) + if err != nil { + return "", err + } + + // take the hostname without any port attached + strippedHostname := u.Hostname() + if strippedHostname == "" { + return "", fmt.Errorf("could not derive hostname without port from %s://%s", protocol, host) + } + + // make sure IDNs are converted to punycode or the cookie library breaks: + // see https://en.wikipedia.org/wiki/Punycode + punyHostname, err := idna.New().ToASCII(strippedHostname) + if err != nil { + return "", fmt.Errorf("could not convert %s to punycode: %s", strippedHostname, err) + } + + return fmt.Sprintf("gotosocial-%s", punyHostname), nil +} + +// Session returns a new gin middleware that implements session cookies using the given +// sessionName, authentication key, and encryption key. Session name can be derived from the +// SessionName utility function in this package. +func Session(sessionName string, auth []byte, crypt []byte) gin.HandlerFunc { + store := memstore.NewStore(auth, crypt) + store.Options(SessionOptions()) + return sessions.Sessions(sessionName, store) +} diff --git a/internal/middleware/session_test.go b/internal/middleware/session_test.go @@ -0,0 +1,95 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 middleware_test + +import ( + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/middleware" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type SessionTestSuite struct { + suite.Suite +} + +func (suite *SessionTestSuite) SetupTest() { + testrig.InitTestConfig() +} + +func (suite *SessionTestSuite) TestDeriveSessionNameLocalhostWithPort() { + config.SetProtocol("http") + config.SetHost("localhost:8080") + + sessionName, err := middleware.SessionName() + suite.NoError(err) + suite.Equal("gotosocial-localhost", sessionName) +} + +func (suite *SessionTestSuite) TestDeriveSessionNameLocalhost() { + config.SetProtocol("http") + config.SetHost("localhost") + + sessionName, err := middleware.SessionName() + suite.NoError(err) + suite.Equal("gotosocial-localhost", sessionName) +} + +func (suite *SessionTestSuite) TestDeriveSessionNoProtocol() { + config.SetProtocol("") + config.SetHost("localhost") + + sessionName, err := middleware.SessionName() + suite.EqualError(err, "parse \"://localhost\": missing protocol scheme") + suite.Equal("", sessionName) +} + +func (suite *SessionTestSuite) TestDeriveSessionNoHost() { + config.SetProtocol("https") + config.SetHost("") + config.SetPort(0) + + sessionName, err := middleware.SessionName() + suite.EqualError(err, "could not derive hostname without port from https://") + suite.Equal("", sessionName) +} + +func (suite *SessionTestSuite) TestDeriveSessionOK() { + config.SetProtocol("https") + config.SetHost("example.org") + + sessionName, err := middleware.SessionName() + suite.NoError(err) + suite.Equal("gotosocial-example.org", sessionName) +} + +func (suite *SessionTestSuite) TestDeriveSessionIDNOK() { + config.SetProtocol("https") + config.SetHost("fóid.org") + + sessionName, err := middleware.SessionName() + suite.NoError(err) + suite.Equal("gotosocial-xn--fid-gna.org", sessionName) +} + +func TestSessionTestSuite(t *testing.T) { + suite.Run(t, &SessionTestSuite{}) +} diff --git a/internal/middleware/signaturecheck.go b/internal/middleware/signaturecheck.go @@ -0,0 +1,93 @@ +package middleware + +import ( + "context" + "fmt" + "net/http" + "net/url" + + "github.com/superseriousbusiness/gotosocial/internal/ap" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/log" + + "github.com/gin-gonic/gin" + "github.com/go-fed/httpsig" +) + +var ( + // this mimics an untyped error returned by httpsig when no signature is present; + // define it here so that we can use it to decide what to log without hitting + // performance too hard + noSignatureError = fmt.Sprintf("neither %q nor %q have signature parameters", httpsig.Signature, httpsig.Authorization) + signatureHeader = string(httpsig.Signature) + authorizationHeader = string(httpsig.Authorization) +) + +// SignatureCheck returns a gin middleware for checking http signatures. +// +// The middleware first checks whether an incoming http request has been http-signed with a well-formed signature. +// +// If so, it will check if the domain that signed the request is permitted to access the server, using the provided isURIBlocked function. +// +// If it is permitted, the handler will set the key verifier and the signature in the gin context for use down the line. +// +// If the domain is blocked, the middleware will abort the request chain instead with http code 403 forbidden. +// +// In case of an error, the request will be aborted with http code 500 internal server error. +func SignatureCheck(isURIBlocked func(context.Context, *url.URL) (bool, db.Error)) func(*gin.Context) { + return func(c *gin.Context) { + // create the verifier from the request, this will error if the request wasn't signed + verifier, err := httpsig.NewVerifier(c.Request) + if err != nil { + // Something went wrong, so we need to return regardless, but only actually + // *abort* the request with 401 if a signature was present but malformed + if err.Error() != noSignatureError { + log.Debugf("http signature was present but invalid: %s", err) + c.AbortWithStatus(http.StatusUnauthorized) + } + return + } + + // The request was signed! + // The key ID should be given in the signature so that we know where to fetch it from the remote server. + // This will be something like https://example.org/users/whatever_requesting_user#main-key + requestingPublicKeyIDString := verifier.KeyId() + requestingPublicKeyID, err := url.Parse(requestingPublicKeyIDString) + if err != nil { + log.Debugf("http signature requesting public key id %s could not be parsed as a url: %s", requestingPublicKeyIDString, err) + c.AbortWithStatus(http.StatusUnauthorized) + return + } else if requestingPublicKeyID == nil { + // Key can sometimes be nil, according to url parse function: + // 'Trying to parse a hostname and path without a scheme is invalid but may not necessarily return an error, due to parsing ambiguities' + log.Debugf("http signature requesting public key id %s was nil after parsing as a url", requestingPublicKeyIDString) + c.AbortWithStatus(http.StatusUnauthorized) + return + } + + // we managed to parse the url! + // if the domain is blocked we want to bail as early as possible + if blocked, err := isURIBlocked(c.Request.Context(), requestingPublicKeyID); err != nil { + log.Errorf("could not tell if domain %s was blocked or not: %s", requestingPublicKeyID.Host, err) + c.AbortWithStatus(http.StatusInternalServerError) + return + } else if blocked { + log.Infof("domain %s is blocked", requestingPublicKeyID.Host) + c.AbortWithStatus(http.StatusForbidden) + return + } + + // assume signature was set on Signature header (most common behavior), + // but fall back to Authorization header if necessary + var signature string + if s := c.GetHeader(signatureHeader); s != "" { + signature = s + } else { + signature = c.GetHeader(authorizationHeader) + } + + // set the verifier and signature on the context here to save some work further down the line + c.Set(string(ap.ContextRequestingPublicKeyVerifier), verifier) + c.Set(string(ap.ContextRequestingPublicKeySignature), signature) + } +} diff --git a/internal/middleware/tokencheck.go b/internal/middleware/tokencheck.go @@ -0,0 +1,140 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 middleware + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/log" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/oauth2/v4" +) + +// TokenCheck returns a new gin middleware for validating oauth tokens in requests. +// +// The middleware checks the request Authorization header for a valid oauth Bearer token. +// +// If no token was set in the Authorization header, or the token was invalid, the handler will return. +// +// If a valid oauth Bearer token was provided, it will be set on the gin context for further use. +// +// Then, it will check which *gtsmodel.User the token belongs to. If the user is not confirmed, not approved, +// or has been disabled, then the middleware will return early. Otherwise, the User will be set on the +// gin context for further processing by other functions. +// +// Next, it will look up the *gtsmodel.Account for the User. If the Account has been suspended, then the +// middleware will return early. Otherwise, it will set the Account on the gin context too. +// +// Finally, it will check the client ID of the token to see if a *gtsmodel.Application can be retrieved +// for that client ID. This will also be set on the gin context. +// +// If an invalid token is presented, or a user/account/application can't be found, then this middleware +// won't abort the request, since the server might want to still allow public requests that don't have a +// Bearer token set (eg., for public instance information and so on). +func TokenCheck(dbConn db.DB, validateBearerToken func(r *http.Request) (oauth2.TokenInfo, error)) func(*gin.Context) { + return func(c *gin.Context) { + ctx := c.Request.Context() + + if c.Request.Header.Get("Authorization") == "" { + // no token set in the header, we can just bail + return + } + + ti, err := validateBearerToken(c.Copy().Request) + if err != nil { + log.Debugf("token was passed in Authorization header but we could not validate it: %s", err) + return + } + c.Set(oauth.SessionAuthorizedToken, ti) + + // check for user-level token + if userID := ti.GetUserID(); userID != "" { + log.Tracef("authenticated user %s with bearer token, scope is %s", userID, ti.GetScope()) + + // fetch user for this token + user, err := dbConn.GetUserByID(ctx, userID) + if err != nil { + if err != db.ErrNoEntries { + log.Errorf("database error looking for user with id %s: %s", userID, err) + return + } + log.Warnf("no user found for userID %s", userID) + return + } + + if user.ConfirmedAt.IsZero() { + log.Warnf("authenticated user %s has never confirmed thier email address", userID) + return + } + + if !*user.Approved { + log.Warnf("authenticated user %s's account was never approved by an admin", userID) + return + } + + if *user.Disabled { + log.Warnf("authenticated user %s's account was disabled'", userID) + return + } + + c.Set(oauth.SessionAuthorizedUser, user) + + // fetch account for this token + if user.Account == nil { + acct, err := dbConn.GetAccountByID(ctx, user.AccountID) + if err != nil { + if err != db.ErrNoEntries { + log.Errorf("database error looking for account with id %s: %s", user.AccountID, err) + return + } + log.Warnf("no account found for userID %s", userID) + return + } + user.Account = acct + } + + if !user.Account.SuspendedAt.IsZero() { + log.Warnf("authenticated user %s's account (accountId=%s) has been suspended", userID, user.AccountID) + return + } + + c.Set(oauth.SessionAuthorizedAccount, user.Account) + } + + // check for application token + if clientID := ti.GetClientID(); clientID != "" { + log.Tracef("authenticated client %s with bearer token, scope is %s", clientID, ti.GetScope()) + + // fetch app for this token + app := &gtsmodel.Application{} + if err := dbConn.GetWhere(ctx, []db.Where{{Key: "client_id", Value: clientID}}, app); err != nil { + if err != db.ErrNoEntries { + log.Errorf("database error looking for application with clientID %s: %s", clientID, err) + return + } + log.Warnf("no app found for client %s", clientID) + return + } + c.Set(oauth.SessionAuthorizedApplication, app) + } + } +} diff --git a/internal/middleware/useragent.go b/internal/middleware/useragent.go @@ -0,0 +1,39 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public 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 middleware + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" +) + +// UserAgent returns a gin middleware which aborts requests with +// empty user agent strings, returning code 418 - I'm a teapot. +func UserAgent() gin.HandlerFunc { + // todo: make this configurable + return func(c *gin.Context) { + if ua := c.Request.UserAgent(); ua == "" { + code := http.StatusTeapot + err := errors.New(http.StatusText(code) + ": no user-agent sent with request") + c.AbortWithStatusJSON(code, gin.H{"error": err.Error()}) + } + } +} diff --git a/internal/oidc/idp.go b/internal/oidc/idp.go @@ -52,15 +52,7 @@ type idp struct { } // NewIDP returns a new IDP configured with the given config. -// If the passed config contains a nil value for the OIDCConfig, or OIDCConfig.Enabled -// is set to false, then nil, nil will be returned. If OIDCConfig.Enabled is true, -// then the other OIDC config fields must also be set. func NewIDP(ctx context.Context) (IDP, error) { - if !config.GetOIDCEnabled() { - // oidc isn't enabled so we don't need to do anything - return nil, nil - } - // validate config fields idpName := config.GetOIDCIdpName() if idpName == "" { diff --git a/internal/processing/federation.go b/internal/processing/federation.go @@ -59,12 +59,12 @@ func (p *processor) GetWebfingerAccount(ctx context.Context, requestedUsername s return p.federationProcessor.GetWebfingerAccount(ctx, requestedUsername) } -func (p *processor) GetNodeInfoRel(ctx context.Context, request *http.Request) (*apimodel.WellKnownResponse, gtserror.WithCode) { - return p.federationProcessor.GetNodeInfoRel(ctx, request) +func (p *processor) GetNodeInfoRel(ctx context.Context) (*apimodel.WellKnownResponse, gtserror.WithCode) { + return p.federationProcessor.GetNodeInfoRel(ctx) } -func (p *processor) GetNodeInfo(ctx context.Context, request *http.Request) (*apimodel.Nodeinfo, gtserror.WithCode) { - return p.federationProcessor.GetNodeInfo(ctx, request) +func (p *processor) GetNodeInfo(ctx context.Context) (*apimodel.Nodeinfo, gtserror.WithCode) { + return p.federationProcessor.GetNodeInfo(ctx) } func (p *processor) InboxPost(ctx context.Context, w http.ResponseWriter, r *http.Request) (bool, error) { diff --git a/internal/processing/federation/federation.go b/internal/processing/federation/federation.go @@ -60,10 +60,10 @@ type Processor interface { GetEmoji(ctx context.Context, requestedEmojiID string, requestURL *url.URL) (interface{}, gtserror.WithCode) // GetNodeInfoRel returns a well known response giving the path to node info. - GetNodeInfoRel(ctx context.Context, request *http.Request) (*apimodel.WellKnownResponse, gtserror.WithCode) + GetNodeInfoRel(ctx context.Context) (*apimodel.WellKnownResponse, gtserror.WithCode) // GetNodeInfo returns a node info struct in response to a node info request. - GetNodeInfo(ctx context.Context, request *http.Request) (*apimodel.Nodeinfo, gtserror.WithCode) + GetNodeInfo(ctx context.Context) (*apimodel.Nodeinfo, gtserror.WithCode) // GetOutbox returns the activitypub representation of a local user's outbox. // This contains links to PUBLIC posts made by this user. diff --git a/internal/processing/federation/getnodeinfo.go b/internal/processing/federation/getnodeinfo.go @@ -21,7 +21,6 @@ package federation import ( "context" "fmt" - "net/http" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/config" @@ -38,7 +37,7 @@ var ( nodeInfoProtocols = []string{"activitypub"} ) -func (p *processor) GetNodeInfoRel(ctx context.Context, request *http.Request) (*apimodel.WellKnownResponse, gtserror.WithCode) { +func (p *processor) GetNodeInfoRel(ctx context.Context) (*apimodel.WellKnownResponse, gtserror.WithCode) { protocol := config.GetProtocol() host := config.GetHost() @@ -52,7 +51,7 @@ func (p *processor) GetNodeInfoRel(ctx context.Context, request *http.Request) ( }, nil } -func (p *processor) GetNodeInfo(ctx context.Context, request *http.Request) (*apimodel.Nodeinfo, gtserror.WithCode) { +func (p *processor) GetNodeInfo(ctx context.Context) (*apimodel.Nodeinfo, gtserror.WithCode) { openRegistration := config.GetAccountsRegistrationOpen() softwareVersion := config.GetSoftwareVersion() diff --git a/internal/processing/fromclientapi_test.go b/internal/processing/fromclientapi_test.go @@ -25,7 +25,7 @@ import ( "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/ap" - "github.com/superseriousbusiness/gotosocial/internal/api/model" + 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/messages" @@ -102,7 +102,7 @@ func (suite *FromClientAPITestSuite) TestProcessStreamNewStatus() { suite.Equal(stream.EventTypeUpdate, msg.Event) suite.NotEmpty(msg.Payload) suite.EqualValues([]string{stream.TimelineHome}, msg.Stream) - statusStreamed := &model.Status{} + statusStreamed := &apimodel.Status{} err = json.Unmarshal([]byte(msg.Payload), statusStreamed) suite.NoError(err) suite.Equal("01FN4B2F88TF9676DYNXWE1WSS", statusStreamed.ID) diff --git a/internal/processing/fromfederator_test.go b/internal/processing/fromfederator_test.go @@ -27,7 +27,7 @@ import ( "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/ap" - "github.com/superseriousbusiness/gotosocial/internal/api/model" + 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/id" @@ -171,7 +171,7 @@ func (suite *FromFederatorTestSuite) TestProcessReplyMention() { suite.Equal(stream.EventTypeNotification, msg.Event) suite.NotEmpty(msg.Payload) suite.EqualValues([]string{stream.TimelineHome}, msg.Stream) - notifStreamed := &model.Notification{} + notifStreamed := &apimodel.Notification{} err = json.Unmarshal([]byte(msg.Payload), notifStreamed) suite.NoError(err) suite.Equal("mention", notifStreamed.Type) @@ -439,7 +439,7 @@ func (suite *FromFederatorTestSuite) TestProcessFollowRequestLocked() { suite.Equal(stream.EventTypeNotification, msg.Event) suite.NotEmpty(msg.Payload) suite.EqualValues([]string{stream.TimelineHome}, msg.Stream) - notif := &model.Notification{} + notif := &apimodel.Notification{} err = json.Unmarshal([]byte(msg.Payload), notif) suite.NoError(err) suite.Equal("follow_request", notif.Type) @@ -537,7 +537,7 @@ func (suite *FromFederatorTestSuite) TestProcessFollowRequestUnlocked() { suite.Equal(stream.EventTypeNotification, msg.Event) suite.NotEmpty(msg.Payload) suite.EqualValues([]string{stream.TimelineHome}, msg.Stream) - notif := &model.Notification{} + notif := &apimodel.Notification{} err = json.Unmarshal([]byte(msg.Payload), notif) suite.NoError(err) suite.Equal("follow", notif.Type) diff --git a/internal/processing/oauth.go b/internal/processing/oauth.go @@ -22,6 +22,7 @@ import ( "net/http" "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/oauth2/v4" ) func (p *processor) OAuthHandleAuthorizeRequest(w http.ResponseWriter, r *http.Request) gtserror.WithCode { @@ -33,3 +34,8 @@ func (p *processor) OAuthHandleTokenRequest(r *http.Request) (map[string]interfa // todo: some kind of metrics stuff here return p.oauthServer.HandleTokenRequest(r) } + +func (p *processor) OAuthValidateBearerToken(r *http.Request) (oauth2.TokenInfo, error) { + // todo: some kind of metrics stuff here + return p.oauthServer.ValidationBearerToken(r) +} diff --git a/internal/processing/processor.go b/internal/processing/processor.go @@ -46,6 +46,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/timeline" "github.com/superseriousbusiness/gotosocial/internal/typeutils" "github.com/superseriousbusiness/gotosocial/internal/visibility" + "github.com/superseriousbusiness/oauth2/v4" ) // Processor should be passed to api modules (see internal/apimodule/...). It is used for @@ -183,6 +184,7 @@ type Processor interface { OAuthHandleTokenRequest(r *http.Request) (map[string]interface{}, gtserror.WithCode) OAuthHandleAuthorizeRequest(w http.ResponseWriter, r *http.Request) gtserror.WithCode + OAuthValidateBearerToken(r *http.Request) (oauth2.TokenInfo, error) // SearchGet performs a search with the given params, resolving/dereferencing remotely as desired SearchGet(ctx context.Context, authed *oauth.Auth, searchQuery *apimodel.SearchQuery) (*apimodel.SearchResult, gtserror.WithCode) @@ -260,9 +262,9 @@ type Processor interface { // GetWebfingerAccount handles the GET for a webfinger resource. Most commonly, it will be used for returning account lookups. GetWebfingerAccount(ctx context.Context, requestedUsername string) (*apimodel.WellKnownResponse, gtserror.WithCode) // GetNodeInfoRel returns a well known response giving the path to node info. - GetNodeInfoRel(ctx context.Context, request *http.Request) (*apimodel.WellKnownResponse, gtserror.WithCode) + GetNodeInfoRel(ctx context.Context) (*apimodel.WellKnownResponse, gtserror.WithCode) // GetNodeInfo returns a node info struct in response to a node info request. - GetNodeInfo(ctx context.Context, request *http.Request) (*apimodel.Nodeinfo, gtserror.WithCode) + GetNodeInfo(ctx context.Context) (*apimodel.Nodeinfo, gtserror.WithCode) // InboxPost handles POST requests to a user's inbox for new activitypub messages. // // InboxPost returns true if the request was handled as an ActivityPub POST to an actor's inbox. diff --git a/internal/processing/status/create_test.go b/internal/processing/status/create_test.go @@ -23,7 +23,7 @@ import ( "testing" "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/api/model" + 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/gtsmodel" @@ -39,20 +39,20 @@ func (suite *StatusCreateTestSuite) TestProcessContentWarningWithQuotationMarks( creatingAccount := suite.testAccounts["local_account_1"] creatingApplication := suite.testApplications["application_1"] - statusCreateForm := &model.AdvancedStatusCreateForm{ - StatusCreateRequest: model.StatusCreateRequest{ + statusCreateForm := &apimodel.AdvancedStatusCreateForm{ + StatusCreateRequest: apimodel.StatusCreateRequest{ Status: "poopoo peepee", MediaIDs: []string{}, Poll: nil, InReplyToID: "", Sensitive: false, SpoilerText: "\"test\"", // these should not be html-escaped when the final text is rendered - Visibility: model.VisibilityPublic, + Visibility: apimodel.VisibilityPublic, ScheduledAt: "", Language: "en", - Format: model.StatusFormatPlain, + Format: apimodel.StatusFormatPlain, }, - AdvancedVisibilityFlagsForm: model.AdvancedVisibilityFlagsForm{ + AdvancedVisibilityFlagsForm: apimodel.AdvancedVisibilityFlagsForm{ Federated: nil, Boostable: nil, Replyable: nil, @@ -73,20 +73,20 @@ func (suite *StatusCreateTestSuite) TestProcessContentWarningWithHTMLEscapedQuot creatingAccount := suite.testAccounts["local_account_1"] creatingApplication := suite.testApplications["application_1"] - statusCreateForm := &model.AdvancedStatusCreateForm{ - StatusCreateRequest: model.StatusCreateRequest{ + statusCreateForm := &apimodel.AdvancedStatusCreateForm{ + StatusCreateRequest: apimodel.StatusCreateRequest{ Status: "poopoo peepee", MediaIDs: []string{}, Poll: nil, InReplyToID: "", Sensitive: false, SpoilerText: "&#34test&#34", // the html-escaped quotation marks should appear as normal quotation marks in the finished text - Visibility: model.VisibilityPublic, + Visibility: apimodel.VisibilityPublic, ScheduledAt: "", Language: "en", - Format: model.StatusFormatPlain, + Format: apimodel.StatusFormatPlain, }, - AdvancedVisibilityFlagsForm: model.AdvancedVisibilityFlagsForm{ + AdvancedVisibilityFlagsForm: apimodel.AdvancedVisibilityFlagsForm{ Federated: nil, Boostable: nil, Replyable: nil, @@ -112,19 +112,19 @@ func (suite *StatusCreateTestSuite) TestProcessStatusMarkdownWithUnderscoreEmoji creatingAccount := suite.testAccounts["local_account_1"] creatingApplication := suite.testApplications["application_1"] - statusCreateForm := &model.AdvancedStatusCreateForm{ - StatusCreateRequest: model.StatusCreateRequest{ + statusCreateForm := &apimodel.AdvancedStatusCreateForm{ + StatusCreateRequest: apimodel.StatusCreateRequest{ Status: "poopoo peepee :_rainbow_:", MediaIDs: []string{}, Poll: nil, InReplyToID: "", Sensitive: false, - Visibility: model.VisibilityPublic, + Visibility: apimodel.VisibilityPublic, ScheduledAt: "", Language: "en", - Format: model.StatusFormatMarkdown, + Format: apimodel.StatusFormatMarkdown, }, - AdvancedVisibilityFlagsForm: model.AdvancedVisibilityFlagsForm{ + AdvancedVisibilityFlagsForm: apimodel.AdvancedVisibilityFlagsForm{ Federated: nil, Boostable: nil, Replyable: nil, @@ -145,20 +145,20 @@ func (suite *StatusCreateTestSuite) TestProcessStatusMarkdownWithSpoilerTextEmoj creatingAccount := suite.testAccounts["local_account_1"] creatingApplication := suite.testApplications["application_1"] - statusCreateForm := &model.AdvancedStatusCreateForm{ - StatusCreateRequest: model.StatusCreateRequest{ + statusCreateForm := &apimodel.AdvancedStatusCreateForm{ + StatusCreateRequest: apimodel.StatusCreateRequest{ Status: "poopoo peepee", SpoilerText: "testing something :rainbow:", MediaIDs: []string{}, Poll: nil, InReplyToID: "", Sensitive: false, - Visibility: model.VisibilityPublic, + Visibility: apimodel.VisibilityPublic, ScheduledAt: "", Language: "en", - Format: model.StatusFormatMarkdown, + Format: apimodel.StatusFormatMarkdown, }, - AdvancedVisibilityFlagsForm: model.AdvancedVisibilityFlagsForm{ + AdvancedVisibilityFlagsForm: apimodel.AdvancedVisibilityFlagsForm{ Federated: nil, Boostable: nil, Replyable: nil, @@ -183,20 +183,20 @@ func (suite *StatusCreateTestSuite) TestProcessMediaDescriptionTooShort() { creatingAccount := suite.testAccounts["local_account_1"] creatingApplication := suite.testApplications["application_1"] - statusCreateForm := &model.AdvancedStatusCreateForm{ - StatusCreateRequest: model.StatusCreateRequest{ + statusCreateForm := &apimodel.AdvancedStatusCreateForm{ + StatusCreateRequest: apimodel.StatusCreateRequest{ Status: "poopoo peepee", MediaIDs: []string{suite.testAttachments["local_account_1_unattached_1"].ID}, Poll: nil, InReplyToID: "", Sensitive: false, SpoilerText: "", - Visibility: model.VisibilityPublic, + Visibility: apimodel.VisibilityPublic, ScheduledAt: "", Language: "en", - Format: model.StatusFormatPlain, + Format: apimodel.StatusFormatPlain, }, - AdvancedVisibilityFlagsForm: model.AdvancedVisibilityFlagsForm{ + AdvancedVisibilityFlagsForm: apimodel.AdvancedVisibilityFlagsForm{ Federated: nil, Boostable: nil, Replyable: nil, diff --git a/internal/processing/status/util.go b/internal/processing/status/util.go @@ -23,7 +23,6 @@ import ( "errors" "fmt" - "github.com/superseriousbusiness/gotosocial/internal/api/model" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" @@ -303,11 +302,11 @@ func (p *processor) ProcessContent(ctx context.Context, form *apimodel.AdvancedS switch acct.StatusFormat { case "plain": - form.Format = model.StatusFormatPlain + form.Format = apimodel.StatusFormatPlain case "markdown": - form.Format = model.StatusFormatMarkdown + form.Format = apimodel.StatusFormatMarkdown default: - form.Format = model.StatusFormatDefault + form.Format = apimodel.StatusFormatDefault } } diff --git a/internal/processing/status/util_test.go b/internal/processing/status/util_test.go @@ -24,7 +24,7 @@ import ( "testing" "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/api/model" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" ) @@ -45,20 +45,20 @@ func (suite *UtilTestSuite) TestProcessMentions1() { creatingAccount := suite.testAccounts["local_account_1"] mentionedAccount := suite.testAccounts["remote_account_1"] - form := &model.AdvancedStatusCreateForm{ - StatusCreateRequest: model.StatusCreateRequest{ + form := &apimodel.AdvancedStatusCreateForm{ + StatusCreateRequest: apimodel.StatusCreateRequest{ Status: statusText1, MediaIDs: []string{}, Poll: nil, InReplyToID: "", Sensitive: false, SpoilerText: "", - Visibility: model.VisibilityPublic, + Visibility: apimodel.VisibilityPublic, ScheduledAt: "", Language: "en", - Format: model.StatusFormatPlain, + Format: apimodel.StatusFormatPlain, }, - AdvancedVisibilityFlagsForm: model.AdvancedVisibilityFlagsForm{ + AdvancedVisibilityFlagsForm: apimodel.AdvancedVisibilityFlagsForm{ Federated: nil, Boostable: nil, Replyable: nil, @@ -94,20 +94,20 @@ func (suite *UtilTestSuite) TestProcessContentFull1() { */ // we need to partially process the status first since processContent expects a status with some stuff already set on it creatingAccount := suite.testAccounts["local_account_1"] - form := &model.AdvancedStatusCreateForm{ - StatusCreateRequest: model.StatusCreateRequest{ + form := &apimodel.AdvancedStatusCreateForm{ + StatusCreateRequest: apimodel.StatusCreateRequest{ Status: statusText1, MediaIDs: []string{}, Poll: nil, InReplyToID: "", Sensitive: false, SpoilerText: "", - Visibility: model.VisibilityPublic, + Visibility: apimodel.VisibilityPublic, ScheduledAt: "", Language: "en", - Format: model.StatusFormatPlain, + Format: apimodel.StatusFormatPlain, }, - AdvancedVisibilityFlagsForm: model.AdvancedVisibilityFlagsForm{ + AdvancedVisibilityFlagsForm: apimodel.AdvancedVisibilityFlagsForm{ Federated: nil, Boostable: nil, Replyable: nil, @@ -142,20 +142,20 @@ func (suite *UtilTestSuite) TestProcessContentPartial1() { */ // we need to partially process the status first since processContent expects a status with some stuff already set on it creatingAccount := suite.testAccounts["local_account_1"] - form := &model.AdvancedStatusCreateForm{ - StatusCreateRequest: model.StatusCreateRequest{ + form := &apimodel.AdvancedStatusCreateForm{ + StatusCreateRequest: apimodel.StatusCreateRequest{ Status: statusText1, MediaIDs: []string{}, Poll: nil, InReplyToID: "", Sensitive: false, SpoilerText: "", - Visibility: model.VisibilityPublic, + Visibility: apimodel.VisibilityPublic, ScheduledAt: "", Language: "en", - Format: model.StatusFormatPlain, + Format: apimodel.StatusFormatPlain, }, - AdvancedVisibilityFlagsForm: model.AdvancedVisibilityFlagsForm{ + AdvancedVisibilityFlagsForm: apimodel.AdvancedVisibilityFlagsForm{ Federated: nil, Boostable: nil, Replyable: nil, @@ -184,20 +184,20 @@ func (suite *UtilTestSuite) TestProcessMentions2() { creatingAccount := suite.testAccounts["local_account_1"] mentionedAccount := suite.testAccounts["remote_account_1"] - form := &model.AdvancedStatusCreateForm{ - StatusCreateRequest: model.StatusCreateRequest{ + form := &apimodel.AdvancedStatusCreateForm{ + StatusCreateRequest: apimodel.StatusCreateRequest{ Status: statusText2, MediaIDs: []string{}, Poll: nil, InReplyToID: "", Sensitive: false, SpoilerText: "", - Visibility: model.VisibilityPublic, + Visibility: apimodel.VisibilityPublic, ScheduledAt: "", Language: "en", - Format: model.StatusFormatPlain, + Format: apimodel.StatusFormatPlain, }, - AdvancedVisibilityFlagsForm: model.AdvancedVisibilityFlagsForm{ + AdvancedVisibilityFlagsForm: apimodel.AdvancedVisibilityFlagsForm{ Federated: nil, Boostable: nil, Replyable: nil, @@ -233,20 +233,20 @@ func (suite *UtilTestSuite) TestProcessContentFull2() { */ // we need to partially process the status first since processContent expects a status with some stuff already set on it creatingAccount := suite.testAccounts["local_account_1"] - form := &model.AdvancedStatusCreateForm{ - StatusCreateRequest: model.StatusCreateRequest{ + form := &apimodel.AdvancedStatusCreateForm{ + StatusCreateRequest: apimodel.StatusCreateRequest{ Status: statusText2, MediaIDs: []string{}, Poll: nil, InReplyToID: "", Sensitive: false, SpoilerText: "", - Visibility: model.VisibilityPublic, + Visibility: apimodel.VisibilityPublic, ScheduledAt: "", Language: "en", - Format: model.StatusFormatPlain, + Format: apimodel.StatusFormatPlain, }, - AdvancedVisibilityFlagsForm: model.AdvancedVisibilityFlagsForm{ + AdvancedVisibilityFlagsForm: apimodel.AdvancedVisibilityFlagsForm{ Federated: nil, Boostable: nil, Replyable: nil, @@ -282,20 +282,20 @@ func (suite *UtilTestSuite) TestProcessContentPartial2() { */ // we need to partially process the status first since processContent expects a status with some stuff already set on it creatingAccount := suite.testAccounts["local_account_1"] - form := &model.AdvancedStatusCreateForm{ - StatusCreateRequest: model.StatusCreateRequest{ + form := &apimodel.AdvancedStatusCreateForm{ + StatusCreateRequest: apimodel.StatusCreateRequest{ Status: statusText2, MediaIDs: []string{}, Poll: nil, InReplyToID: "", Sensitive: false, SpoilerText: "", - Visibility: model.VisibilityPublic, + Visibility: apimodel.VisibilityPublic, ScheduledAt: "", Language: "en", - Format: model.StatusFormatPlain, + Format: apimodel.StatusFormatPlain, }, - AdvancedVisibilityFlagsForm: model.AdvancedVisibilityFlagsForm{ + AdvancedVisibilityFlagsForm: apimodel.AdvancedVisibilityFlagsForm{ Federated: nil, Boostable: nil, Replyable: nil, diff --git a/internal/router/attach.go b/internal/router/attach.go @@ -20,29 +20,18 @@ package router import "github.com/gin-gonic/gin" -// AttachHandler attaches the given gin.HandlerFunc to the router with the specified method and path. -// If the path is set to ANY, then the handlerfunc will be used for ALL methods at its given path. -func (r *router) AttachHandler(method string, path string, handler gin.HandlerFunc) { - if method == "ANY" { - r.engine.Any(path, handler) - } else { - r.engine.Handle(method, path, handler) - } -} - -// AttachMiddleware attaches a gin middleware to the router that will be used globally -func (r *router) AttachMiddleware(middleware gin.HandlerFunc) { - r.engine.Use(middleware) +func (r *router) AttachGlobalMiddleware(handlers ...gin.HandlerFunc) gin.IRoutes { + return r.engine.Use(handlers...) } -// AttachNoRouteHandler attaches a gin.HandlerFunc to NoRoute to handle 404's func (r *router) AttachNoRouteHandler(handler gin.HandlerFunc) { r.engine.NoRoute(handler) } -// AttachGroup attaches the given handlers into a group with the given relativePath as -// base path for that group. It then returns the *gin.RouterGroup so that the caller -// can add any extra middlewares etc specific to that group, as desired. func (r *router) AttachGroup(relativePath string, handlers ...gin.HandlerFunc) *gin.RouterGroup { return r.engine.Group(relativePath, handlers...) } + +func (r *router) AttachHandler(method string, path string, handler gin.HandlerFunc) { + r.engine.Handle(method, path, handler) +} diff --git a/internal/router/cors.go b/internal/router/cors.go @@ -1,87 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 router - -import ( - "time" - - "github.com/gin-contrib/cors" - "github.com/gin-gonic/gin" -) - -var corsConfig = cors.Config{ - // TODO: make this customizable so instance admins can specify an origin for CORS requests - AllowAllOrigins: true, - - // adds the following: - // "chrome-extension://" - // "safari-extension://" - // "moz-extension://" - // "ms-browser-extension://" - AllowBrowserExtensions: true, - AllowMethods: []string{ - "POST", - "PUT", - "DELETE", - "GET", - "PATCH", - "OPTIONS", - }, - AllowHeaders: []string{ - // basic cors stuff - "Origin", - "Content-Length", - "Content-Type", - - // needed to pass oauth bearer tokens - "Authorization", - - // needed for websocket upgrade requests - "Upgrade", - "Sec-WebSocket-Extensions", - "Sec-WebSocket-Key", - "Sec-WebSocket-Protocol", - "Sec-WebSocket-Version", - "Connection", - }, - AllowWebSockets: true, - ExposeHeaders: []string{ - // needed for accessing next/prev links when making GET timeline requests - "Link", - - // needed so clients can handle rate limits - "X-RateLimit-Reset", - "X-RateLimit-Limit", - "X-RateLimit-Remaining", - "X-Request-Id", - - // websocket stuff - "Connection", - "Sec-WebSocket-Accept", - "Upgrade", - }, - MaxAge: 2 * time.Minute, -} - -// useCors attaches the corsConfig above to the given gin engine -func useCors(engine *gin.Engine) error { - c := cors.New(corsConfig) - engine.Use(c) - return nil -} diff --git a/internal/router/gzip.go b/internal/router/gzip.go @@ -1,30 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 router - -import ( - ginGzip "github.com/gin-contrib/gzip" - "github.com/gin-gonic/gin" -) - -func useGzip(engine *gin.Engine) error { - gzipMiddleware := ginGzip.Gzip(ginGzip.DefaultCompression) - engine.Use(gzipMiddleware) - return nil -} diff --git a/internal/router/logger.go b/internal/router/logger.go @@ -1,96 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 router - -import ( - "fmt" - "net/http" - "time" - - "codeberg.org/gruf/go-bytesize" - "codeberg.org/gruf/go-errors/v2" - "codeberg.org/gruf/go-kv" - "codeberg.org/gruf/go-logger/v2/level" - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/log" -) - -// loggingMiddleware provides a request logging and panic recovery gin handler. -func loggingMiddleware(c *gin.Context) { - // Initialize the logging fields - fields := make(kv.Fields, 6, 7) - - // Determine pre-handler time - before := time.Now() - - defer func() { - code := c.Writer.Status() - path := c.Request.URL.Path - - if r := recover(); r != nil { - if c.Writer.Status() == 0 { - // No response was written, send a generic Internal Error - c.Writer.WriteHeader(http.StatusInternalServerError) - } - - // Append panic information to the request ctx - err := fmt.Errorf("recovered panic: %v", r) - _ = c.Error(err) - - // Dump a stacktrace to error log - callers := errors.GetCallers(3, 10) - log.WithField("stacktrace", callers).Error(err) - } - - // NOTE: - // It is very important here that we are ONLY logging - // the request path, and none of the query parameters. - // Query parameters can contain sensitive information - // and could lead to storing plaintext API keys in logs - - // Set request logging fields - fields[0] = kv.Field{"latency", time.Since(before)} - fields[1] = kv.Field{"clientIP", c.ClientIP()} - fields[2] = kv.Field{"userAgent", c.Request.UserAgent()} - fields[3] = kv.Field{"method", c.Request.Method} - fields[4] = kv.Field{"statusCode", code} - fields[5] = kv.Field{"path", path} - - // Create log entry with fields - l := log.WithFields(fields...) - - // Default is info - lvl := level.INFO - - if code >= 500 { - // This is a server error - lvl = level.ERROR - l = l.WithField("error", c.Errors) - } - - // Generate a nicer looking bytecount - size := bytesize.Size(c.Writer.Size()) - - // Finally, write log entry with status text body size - l.Logf(lvl, "%s: wrote %s", http.StatusText(code), size) - }() - - // Process request - c.Next() -} diff --git a/internal/router/router.go b/internal/router/router.go @@ -25,34 +25,39 @@ import ( "net/http" "time" + "codeberg.org/gruf/go-bytesize" "codeberg.org/gruf/go-debug" "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/log" "golang.org/x/crypto/acme/autocert" ) const ( - readTimeout = 60 * time.Second - writeTimeout = 30 * time.Second - idleTimeout = 30 * time.Second - readHeaderTimeout = 30 * time.Second - shutdownTimeout = 30 * time.Second + readTimeout = 60 * time.Second + writeTimeout = 30 * time.Second + idleTimeout = 30 * time.Second + readHeaderTimeout = 30 * time.Second + shutdownTimeout = 30 * time.Second + maxMultipartMemory = int64(8 * bytesize.MiB) ) // Router provides the REST interface for gotosocial, using gin. type Router interface { - // Attach a gin handler to the router with the given method and path - AttachHandler(method string, path string, f gin.HandlerFunc) - // Attach a gin middleware to the router that will be used globally - AttachMiddleware(handler gin.HandlerFunc) + // Attach global gin middlewares to this router. + AttachGlobalMiddleware(handlers ...gin.HandlerFunc) gin.IRoutes + // AttachGroup attaches the given handlers into a group with the given relativePath as + // base path for that group. It then returns the *gin.RouterGroup so that the caller + // can add any extra middlewares etc specific to that group, as desired. + AttachGroup(relativePath string, handlers ...gin.HandlerFunc) *gin.RouterGroup + // Attach a single gin handler to the router with the given method and path. + // To make middleware management easier, AttachGroup should be preferred where possible. + // However, this function can be used for attaching single handlers that only require + // global middlewares. + AttachHandler(method string, path string, handler gin.HandlerFunc) + // Attach 404 NoRoute handler AttachNoRouteHandler(handler gin.HandlerFunc) - // Attach a router group, and receive that group back. - // More middlewares and handlers can then be attached on - // the group by the caller. - AttachGroup(path string, handlers ...gin.HandlerFunc) *gin.RouterGroup // Start the router Start() // Stop the router @@ -142,19 +147,20 @@ func (r *router) Stop(ctx context.Context) error { return nil } -// New returns a new Router with the specified configuration. +// New returns a new Router. +// +// The router's Attach functions should be used *before* the router is Started. // -// The given DB is only used in the New function for parsing config values, and is not otherwise -// pinned to the router. -func New(ctx context.Context, db db.DB) (Router, error) { - gin.SetMode(gin.ReleaseMode) +// When the router's work is finished, Stop should be called on it to close connections gracefully. +// +// The provided context will be used as the base context for all requests passing +// through the underlying http.Server, so this should be a long-running context. +func New(ctx context.Context) (Router, error) { + gin.SetMode(gin.TestMode) // create the actual engine here -- this is the core request routing handler for gts engine := gin.New() - engine.Use(loggingMiddleware) - - // 8 MiB - engine.MaxMultipartMemory = 8 << 20 + engine.MaxMultipartMemory = maxMultipartMemory // set up IP forwarding via x-forward-* headers. trustedProxies := config.GetTrustedProxies() @@ -162,21 +168,6 @@ func New(ctx context.Context, db db.DB) (Router, error) { return nil, err } - // enable cors on the engine - if err := useCors(engine); err != nil { - return nil, err - } - - // enable gzip compression on the engine - if err := useGzip(engine); err != nil { - return nil, err - } - - // enable session store middleware on the engine - if err := useSession(ctx, db, engine); err != nil { - return nil, err - } - // set template functions LoadTemplateFunctions(engine) diff --git a/internal/router/session.go b/internal/router/session.go @@ -1,111 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 router - -import ( - "context" - "errors" - "fmt" - "net/http" - "net/url" - "strings" - - "github.com/gin-contrib/sessions" - "github.com/gin-contrib/sessions/memstore" - "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/log" - "golang.org/x/net/idna" -) - -// SessionOptions returns the standard set of options to use for each session. -func SessionOptions() sessions.Options { - var samesite http.SameSite - switch strings.TrimSpace(strings.ToLower(config.GetAdvancedCookiesSamesite())) { - case "lax": - samesite = http.SameSiteLaxMode - case "strict": - samesite = http.SameSiteStrictMode - default: - log.Warnf("%s set to %s which is not recognized, defaulting to 'lax'", config.AdvancedCookiesSamesiteFlag(), config.GetAdvancedCookiesSamesite()) - samesite = http.SameSiteLaxMode - } - - return sessions.Options{ - Path: "/", - Domain: config.GetHost(), - // 2 minutes - MaxAge: 120, - // only set secure over https - Secure: config.GetProtocol() == "https", - // forbid javascript from inspecting cookie - HttpOnly: true, - // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-cookie-same-site-00#section-4.1.1 - SameSite: samesite, - } -} - -// SessionName is a utility function that derives an appropriate session name from the hostname. -func SessionName() (string, error) { - // parse the protocol + host - protocol := config.GetProtocol() - host := config.GetHost() - u, err := url.Parse(fmt.Sprintf("%s://%s", protocol, host)) - if err != nil { - return "", err - } - - // take the hostname without any port attached - strippedHostname := u.Hostname() - if strippedHostname == "" { - return "", fmt.Errorf("could not derive hostname without port from %s://%s", protocol, host) - } - - // make sure IDNs are converted to punycode or the cookie library breaks: - // see https://en.wikipedia.org/wiki/Punycode - punyHostname, err := idna.New().ToASCII(strippedHostname) - if err != nil { - return "", fmt.Errorf("could not convert %s to punycode: %s", strippedHostname, err) - } - - return fmt.Sprintf("gotosocial-%s", punyHostname), nil -} - -func useSession(ctx context.Context, sessionDB db.Session, engine *gin.Engine) error { - // check if we have a saved router session already - rs, err := sessionDB.GetSession(ctx) - if err != nil { - return fmt.Errorf("error using session: %s", err) - } - if rs == nil || rs.Auth == nil || rs.Crypt == nil { - return errors.New("router session was nil") - } - - store := memstore.NewStore(rs.Auth, rs.Crypt) - store.Options(SessionOptions()) - - sessionName, err := SessionName() - if err != nil { - return err - } - - engine.Use(sessions.Sessions(sessionName, store)) - return nil -} diff --git a/internal/router/session_test.go b/internal/router/session_test.go @@ -1,95 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public 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 router_test - -import ( - "testing" - - "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/router" - "github.com/superseriousbusiness/gotosocial/testrig" -) - -type SessionTestSuite struct { - suite.Suite -} - -func (suite *SessionTestSuite) SetupTest() { - testrig.InitTestConfig() -} - -func (suite *SessionTestSuite) TestDeriveSessionNameLocalhostWithPort() { - config.SetProtocol("http") - config.SetHost("localhost:8080") - - sessionName, err := router.SessionName() - suite.NoError(err) - suite.Equal("gotosocial-localhost", sessionName) -} - -func (suite *SessionTestSuite) TestDeriveSessionNameLocalhost() { - config.SetProtocol("http") - config.SetHost("localhost") - - sessionName, err := router.SessionName() - suite.NoError(err) - suite.Equal("gotosocial-localhost", sessionName) -} - -func (suite *SessionTestSuite) TestDeriveSessionNoProtocol() { - config.SetProtocol("") - config.SetHost("localhost") - - sessionName, err := router.SessionName() - suite.EqualError(err, "parse \"://localhost\": missing protocol scheme") - suite.Equal("", sessionName) -} - -func (suite *SessionTestSuite) TestDeriveSessionNoHost() { - config.SetProtocol("https") - config.SetHost("") - config.SetPort(0) - - sessionName, err := router.SessionName() - suite.EqualError(err, "could not derive hostname without port from https://") - suite.Equal("", sessionName) -} - -func (suite *SessionTestSuite) TestDeriveSessionOK() { - config.SetProtocol("https") - config.SetHost("example.org") - - sessionName, err := router.SessionName() - suite.NoError(err) - suite.Equal("gotosocial-example.org", sessionName) -} - -func (suite *SessionTestSuite) TestDeriveSessionIDNOK() { - config.SetProtocol("https") - config.SetHost("fóid.org") - - sessionName, err := router.SessionName() - suite.NoError(err) - suite.Equal("gotosocial-xn--fid-gna.org", sessionName) -} - -func TestSessionTestSuite(t *testing.T) { - suite.Run(t, &SessionTestSuite{}) -} diff --git a/internal/router/template.go b/internal/router/template.go @@ -26,7 +26,7 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api/model" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/text" @@ -130,19 +130,19 @@ type iconWithLabel struct { label string } -func visibilityIcon(visibility model.Visibility) template.HTML { +func visibilityIcon(visibility apimodel.Visibility) template.HTML { var icon iconWithLabel switch visibility { - case model.VisibilityPublic: + case apimodel.VisibilityPublic: icon = iconWithLabel{"globe", "public"} - case model.VisibilityUnlisted: + case apimodel.VisibilityUnlisted: icon = iconWithLabel{"unlock", "unlisted"} - case model.VisibilityPrivate: + case apimodel.VisibilityPrivate: icon = iconWithLabel{"lock", "private"} - case model.VisibilityMutualsOnly: + case apimodel.VisibilityMutualsOnly: icon = iconWithLabel{"handshake-o", "mutuals only"} - case model.VisibilityDirect: + case apimodel.VisibilityDirect: icon = iconWithLabel{"envelope", "direct"} } @@ -151,7 +151,7 @@ func visibilityIcon(visibility model.Visibility) template.HTML { } // text is a template.HTML to affirm that the input of this function is already escaped -func emojify(emojis []model.Emoji, inputText template.HTML) template.HTML { +func emojify(emojis []apimodel.Emoji, inputText template.HTML) template.HTML { out := text.Emojify(emojis, string(inputText)) /* #nosec G203 */ diff --git a/internal/text/emojify.go b/internal/text/emojify.go @@ -22,7 +22,7 @@ import ( "bytes" "html" - "github.com/superseriousbusiness/gotosocial/internal/api/model" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/regexes" ) @@ -30,8 +30,8 @@ import ( // // Callers should ensure that inputText and resulting text are escaped // appropriately depending on what they're used for. -func Emojify(emojis []model.Emoji, inputText string) string { - emojisMap := make(map[string]model.Emoji, len(emojis)) +func Emojify(emojis []apimodel.Emoji, inputText string) string { + emojisMap := make(map[string]apimodel.Emoji, len(emojis)) for _, emoji := range emojis { shortcode := ":" + emoji.Shortcode + ":" diff --git a/internal/transport/deliver.go b/internal/transport/deliver.go @@ -27,7 +27,7 @@ import ( "strings" "sync" - "github.com/superseriousbusiness/gotosocial/internal/api" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/config" ) @@ -80,7 +80,7 @@ func (t *transport) Deliver(ctx context.Context, b []byte, to *url.URL) error { return err } - req.Header.Add("Content-Type", string(api.AppActivityLDJSON)) + req.Header.Add("Content-Type", string(apiutil.AppActivityLDJSON)) req.Header.Add("Accept-Charset", "utf-8") req.Header.Set("Host", to.Host) diff --git a/internal/transport/dereference.go b/internal/transport/dereference.go @@ -26,7 +26,7 @@ import ( "net/http" "net/url" - "github.com/superseriousbusiness/gotosocial/internal/api" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/uris" ) @@ -61,7 +61,7 @@ func (t *transport) Dereference(ctx context.Context, iri *url.URL) ([]byte, erro return nil, err } - req.Header.Add("Accept", string(api.AppActivityLDJSON)+","+string(api.AppActivityJSON)) + req.Header.Add("Accept", string(apiutil.AppActivityLDJSON)+","+string(apiutil.AppActivityJSON)) req.Header.Add("Accept-Charset", "utf-8") req.Header.Set("Host", iri.Host) diff --git a/internal/transport/derefinstance.go b/internal/transport/derefinstance.go @@ -28,8 +28,8 @@ import ( "net/url" "strings" - "github.com/superseriousbusiness/gotosocial/internal/api" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/log" @@ -92,7 +92,7 @@ func dereferenceByAPIV1Instance(ctx context.Context, t *transport, iri *url.URL) return nil, err } - req.Header.Add("Accept", string(api.AppJSON)) + req.Header.Add("Accept", string(apiutil.AppJSON)) req.Header.Set("Host", cleanIRI.Host) resp, err := t.GET(req) @@ -242,7 +242,7 @@ func callNodeInfoWellKnown(ctx context.Context, t *transport, iri *url.URL) (*ur if err != nil { return nil, err } - req.Header.Add("Accept", string(api.AppJSON)) + req.Header.Add("Accept", string(apiutil.AppJSON)) req.Header.Set("Host", cleanIRI.Host) resp, err := t.GET(req) @@ -293,7 +293,7 @@ func callNodeInfo(ctx context.Context, t *transport, iri *url.URL) (*apimodel.No if err != nil { return nil, err } - req.Header.Add("Accept", string(api.AppJSON)) + req.Header.Add("Accept", string(apiutil.AppJSON)) req.Header.Set("Host", iri.Host) resp, err := t.GET(req) diff --git a/internal/transport/finger.go b/internal/transport/finger.go @@ -24,7 +24,7 @@ import ( "io" "net/http" - "github.com/superseriousbusiness/gotosocial/internal/api" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" ) func (t *transport) Finger(ctx context.Context, targetUsername string, targetDomain string) ([]byte, error) { @@ -39,7 +39,7 @@ func (t *transport) Finger(ctx context.Context, targetUsername string, targetDom if err != nil { return nil, err } - req.Header.Add("Accept", string(api.AppJSON)) + req.Header.Add("Accept", string(apiutil.AppJSON)) req.Header.Add("Accept", "application/jrd+json") req.Header.Set("Host", req.URL.Host) diff --git a/internal/typeutils/converter.go b/internal/typeutils/converter.go @@ -26,7 +26,7 @@ import ( "github.com/gorilla/feeds" "github.com/superseriousbusiness/activity/streams/vocab" "github.com/superseriousbusiness/gotosocial/internal/ap" - "github.com/superseriousbusiness/gotosocial/internal/api/model" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" ) @@ -44,49 +44,49 @@ type TypeConverter interface { // AccountToAPIAccountSensitive takes a db model account as a param, and returns a populated apitype 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. - AccountToAPIAccountSensitive(ctx context.Context, account *gtsmodel.Account) (*model.Account, error) + AccountToAPIAccountSensitive(ctx context.Context, account *gtsmodel.Account) (*apimodel.Account, error) // AccountToAPIAccountPublic takes a db model account as a param, and returns a populated apitype 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. - AccountToAPIAccountPublic(ctx context.Context, account *gtsmodel.Account) (*model.Account, error) + AccountToAPIAccountPublic(ctx context.Context, account *gtsmodel.Account) (*apimodel.Account, error) // AccountToAPIAccountBlocked takes a db model account as a param, and returns a apitype account, or an error if // something goes wrong. The returned account will be a bare minimum representation of the account. This function should be used // when someone wants to view an account they've blocked. - AccountToAPIAccountBlocked(ctx context.Context, account *gtsmodel.Account) (*model.Account, error) + AccountToAPIAccountBlocked(ctx context.Context, account *gtsmodel.Account) (*apimodel.Account, error) // AppToAPIAppSensitive takes a db model application as a param, and returns a populated apitype 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. - AppToAPIAppSensitive(ctx context.Context, application *gtsmodel.Application) (*model.Application, error) + AppToAPIAppSensitive(ctx context.Context, application *gtsmodel.Application) (*apimodel.Application, error) // AppToAPIAppPublic takes a db model application as a param, and returns a populated apitype 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. - AppToAPIAppPublic(ctx context.Context, application *gtsmodel.Application) (*model.Application, error) + AppToAPIAppPublic(ctx context.Context, application *gtsmodel.Application) (*apimodel.Application, error) // AttachmentToAPIAttachment converts a gts model media attacahment into its api representation for serialization on the API. - AttachmentToAPIAttachment(ctx context.Context, attachment *gtsmodel.MediaAttachment) (model.Attachment, error) + AttachmentToAPIAttachment(ctx context.Context, attachment *gtsmodel.MediaAttachment) (apimodel.Attachment, error) // MentionToAPIMention converts a gts model mention into its api (frontend) representation for serialization on the API. - MentionToAPIMention(ctx context.Context, m *gtsmodel.Mention) (model.Mention, error) + MentionToAPIMention(ctx context.Context, m *gtsmodel.Mention) (apimodel.Mention, error) // EmojiToAPIEmoji converts a gts model emoji into its api (frontend) representation for serialization on the API. - EmojiToAPIEmoji(ctx context.Context, e *gtsmodel.Emoji) (model.Emoji, error) + EmojiToAPIEmoji(ctx context.Context, e *gtsmodel.Emoji) (apimodel.Emoji, error) // EmojiToAdminAPIEmoji converts a gts model emoji into an API representation with extra admin information. - EmojiToAdminAPIEmoji(ctx context.Context, e *gtsmodel.Emoji) (*model.AdminEmoji, error) + EmojiToAdminAPIEmoji(ctx context.Context, e *gtsmodel.Emoji) (*apimodel.AdminEmoji, error) // EmojiCategoryToAPIEmojiCategory converts a gts model emoji category into its api (frontend) representation. - EmojiCategoryToAPIEmojiCategory(ctx context.Context, category *gtsmodel.EmojiCategory) (*model.EmojiCategory, error) + EmojiCategoryToAPIEmojiCategory(ctx context.Context, category *gtsmodel.EmojiCategory) (*apimodel.EmojiCategory, error) // TagToAPITag converts a gts model tag into its api (frontend) representation for serialization on the API. - TagToAPITag(ctx context.Context, t *gtsmodel.Tag) (model.Tag, error) + TagToAPITag(ctx context.Context, t *gtsmodel.Tag) (apimodel.Tag, error) // StatusToAPIStatus converts a gts model status into its api (frontend) representation for serialization on the API. // // Requesting account can be nil. - StatusToAPIStatus(ctx context.Context, s *gtsmodel.Status, requestingAccount *gtsmodel.Account) (*model.Status, error) + StatusToAPIStatus(ctx context.Context, s *gtsmodel.Status, requestingAccount *gtsmodel.Account) (*apimodel.Status, error) // VisToAPIVis converts a gts visibility into its api equivalent - VisToAPIVis(ctx context.Context, m gtsmodel.Visibility) model.Visibility + VisToAPIVis(ctx context.Context, m gtsmodel.Visibility) apimodel.Visibility // InstanceToAPIInstance converts a gts instance into its api equivalent for serving at /api/v1/instance - InstanceToAPIInstance(ctx context.Context, i *gtsmodel.Instance) (*model.Instance, error) + InstanceToAPIInstance(ctx context.Context, i *gtsmodel.Instance) (*apimodel.Instance, error) // RelationshipToAPIRelationship converts a gts relationship into its api equivalent for serving in various places - RelationshipToAPIRelationship(ctx context.Context, r *gtsmodel.Relationship) (*model.Relationship, error) + RelationshipToAPIRelationship(ctx context.Context, r *gtsmodel.Relationship) (*apimodel.Relationship, error) // NotificationToAPINotification converts a gts notification into a api notification - NotificationToAPINotification(ctx context.Context, n *gtsmodel.Notification) (*model.Notification, error) + NotificationToAPINotification(ctx context.Context, n *gtsmodel.Notification) (*apimodel.Notification, error) // DomainBlockToAPIDomainBlock converts a gts model domin block into a api domain block, for serving at /api/v1/admin/domain_blocks - DomainBlockToAPIDomainBlock(ctx context.Context, b *gtsmodel.DomainBlock, export bool) (*model.DomainBlock, error) + DomainBlockToAPIDomainBlock(ctx context.Context, b *gtsmodel.DomainBlock, export bool) (*apimodel.DomainBlock, error) /* INTERNAL (gts) MODEL TO FRONTEND (rss) MODEL @@ -99,7 +99,7 @@ type TypeConverter interface { */ // APIVisToVis converts an API model visibility into its internal gts equivalent. - APIVisToVis(m model.Visibility) gtsmodel.Visibility + APIVisToVis(m apimodel.Visibility) gtsmodel.Visibility /* ACTIVITYSTREAMS MODEL TO INTERNAL (gts) MODEL diff --git a/internal/typeutils/frontendtointernal.go b/internal/typeutils/frontendtointernal.go @@ -19,21 +19,21 @@ package typeutils import ( - "github.com/superseriousbusiness/gotosocial/internal/api/model" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" ) -func (c *converter) APIVisToVis(m model.Visibility) gtsmodel.Visibility { +func (c *converter) APIVisToVis(m apimodel.Visibility) gtsmodel.Visibility { switch m { - case model.VisibilityPublic: + case apimodel.VisibilityPublic: return gtsmodel.VisibilityPublic - case model.VisibilityUnlisted: + case apimodel.VisibilityUnlisted: return gtsmodel.VisibilityUnlocked - case model.VisibilityPrivate: + case apimodel.VisibilityPrivate: return gtsmodel.VisibilityFollowersOnly - case model.VisibilityMutualsOnly: + case apimodel.VisibilityMutualsOnly: return gtsmodel.VisibilityMutualsOnly - case model.VisibilityDirect: + case apimodel.VisibilityDirect: return gtsmodel.VisibilityDirect } return "" diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go @@ -26,7 +26,7 @@ import ( "strconv" "strings" - "github.com/superseriousbusiness/gotosocial/internal/api/model" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtserror" @@ -45,7 +45,7 @@ const ( instancePollsMaxExpiration = 2629746 // seconds ) -func (c *converter) AccountToAPIAccountSensitive(ctx context.Context, a *gtsmodel.Account) (*model.Account, error) { +func (c *converter) AccountToAPIAccountSensitive(ctx context.Context, a *gtsmodel.Account) (*apimodel.Account, error) { // we can build this sensitive account easily by first getting the public account.... apiAccount, err := c.AccountToAPIAccountPublic(ctx, a) if err != nil { @@ -66,12 +66,12 @@ func (c *converter) AccountToAPIAccountSensitive(ctx context.Context, a *gtsmode frc = len(frs) } - statusFormat := string(model.StatusFormatDefault) + statusFormat := string(apimodel.StatusFormatDefault) if a.StatusFormat != "" { statusFormat = a.StatusFormat } - apiAccount.Source = &model.Source{ + apiAccount.Source = &apimodel.Source{ Privacy: c.VisToAPIVis(ctx, a.Privacy), Sensitive: *a.Sensitive, Language: a.Language, @@ -84,7 +84,7 @@ func (c *converter) AccountToAPIAccountSensitive(ctx context.Context, a *gtsmode return apiAccount, nil } -func (c *converter) AccountToAPIAccountPublic(ctx context.Context, a *gtsmodel.Account) (*model.Account, error) { +func (c *converter) AccountToAPIAccountPublic(ctx context.Context, a *gtsmodel.Account) (*apimodel.Account, error) { // count followers followersCount, err := c.db.CountAccountFollowedBy(ctx, a.ID, false) if err != nil { @@ -146,11 +146,11 @@ func (c *converter) AccountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A } // preallocate frontend fields slice - fields := make([]model.Field, len(a.Fields)) + fields := make([]apimodel.Field, len(a.Fields)) // Convert account GTS model fields to frontend for i, field := range a.Fields { - mField := model.Field{ + mField := apimodel.Field{ Name: field.Name, Value: field.Value, } @@ -168,7 +168,7 @@ func (c *converter) AccountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A var ( acct string - role = model.AccountRoleUnknown + role = apimodel.AccountRoleUnknown ) if a.Domain != "" { @@ -184,11 +184,11 @@ func (c *converter) AccountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A switch { case *user.Admin: - role = model.AccountRoleAdmin + role = apimodel.AccountRoleAdmin case *user.Moderator: - role = model.AccountRoleModerator + role = apimodel.AccountRoleModerator default: - role = model.AccountRoleUser + role = apimodel.AccountRoleUser } } @@ -197,7 +197,7 @@ func (c *converter) AccountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A suspended = true } - accountFrontend := &model.Account{ + accountFrontend := &apimodel.Account{ ID: a.ID, Username: a.Username, Acct: acct, @@ -229,7 +229,7 @@ func (c *converter) AccountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A return accountFrontend, nil } -func (c *converter) AccountToAPIAccountBlocked(ctx context.Context, a *gtsmodel.Account) (*model.Account, error) { +func (c *converter) AccountToAPIAccountBlocked(ctx context.Context, a *gtsmodel.Account) (*apimodel.Account, error) { var acct string if a.Domain != "" { // this is a remote user @@ -244,7 +244,7 @@ func (c *converter) AccountToAPIAccountBlocked(ctx context.Context, a *gtsmodel. suspended = true } - return &model.Account{ + return &apimodel.Account{ ID: a.ID, Username: a.Username, Acct: acct, @@ -256,8 +256,8 @@ func (c *converter) AccountToAPIAccountBlocked(ctx context.Context, a *gtsmodel. }, nil } -func (c *converter) AppToAPIAppSensitive(ctx context.Context, a *gtsmodel.Application) (*model.Application, error) { - return &model.Application{ +func (c *converter) AppToAPIAppSensitive(ctx context.Context, a *gtsmodel.Application) (*apimodel.Application, error) { + return &apimodel.Application{ ID: a.ID, Name: a.Name, Website: a.Website, @@ -267,33 +267,33 @@ func (c *converter) AppToAPIAppSensitive(ctx context.Context, a *gtsmodel.Applic }, nil } -func (c *converter) AppToAPIAppPublic(ctx context.Context, a *gtsmodel.Application) (*model.Application, error) { - return &model.Application{ +func (c *converter) AppToAPIAppPublic(ctx context.Context, a *gtsmodel.Application) (*apimodel.Application, error) { + return &apimodel.Application{ Name: a.Name, Website: a.Website, }, nil } -func (c *converter) AttachmentToAPIAttachment(ctx context.Context, a *gtsmodel.MediaAttachment) (model.Attachment, error) { - apiAttachment := model.Attachment{ +func (c *converter) AttachmentToAPIAttachment(ctx context.Context, a *gtsmodel.MediaAttachment) (apimodel.Attachment, error) { + apiAttachment := apimodel.Attachment{ ID: a.ID, Type: strings.ToLower(string(a.Type)), TextURL: a.URL, PreviewURL: a.Thumbnail.URL, - Meta: model.MediaMeta{ - Original: model.MediaDimensions{ + Meta: apimodel.MediaMeta{ + Original: apimodel.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{ + Small: apimodel.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{ + Focus: apimodel.MediaFocus{ X: a.FileMeta.Focus.X, Y: a.FileMeta.Focus.Y, }, @@ -337,11 +337,11 @@ func (c *converter) AttachmentToAPIAttachment(ctx context.Context, a *gtsmodel.M return apiAttachment, nil } -func (c *converter) MentionToAPIMention(ctx context.Context, m *gtsmodel.Mention) (model.Mention, error) { +func (c *converter) MentionToAPIMention(ctx context.Context, m *gtsmodel.Mention) (apimodel.Mention, error) { if m.TargetAccount == nil { targetAccount, err := c.db.GetAccountByID(ctx, m.TargetAccountID) if err != nil { - return model.Mention{}, err + return apimodel.Mention{}, err } m.TargetAccount = targetAccount } @@ -358,7 +358,7 @@ func (c *converter) MentionToAPIMention(ctx context.Context, m *gtsmodel.Mention acct = fmt.Sprintf("%s@%s", m.TargetAccount.Username, m.TargetAccount.Domain) } - return model.Mention{ + return apimodel.Mention{ ID: m.TargetAccount.ID, Username: m.TargetAccount.Username, URL: m.TargetAccount.URL, @@ -366,20 +366,20 @@ func (c *converter) MentionToAPIMention(ctx context.Context, m *gtsmodel.Mention }, nil } -func (c *converter) EmojiToAPIEmoji(ctx context.Context, e *gtsmodel.Emoji) (model.Emoji, error) { +func (c *converter) EmojiToAPIEmoji(ctx context.Context, e *gtsmodel.Emoji) (apimodel.Emoji, error) { var category string if e.CategoryID != "" { if e.Category == nil { var err error e.Category, err = c.db.GetEmojiCategory(ctx, e.CategoryID) if err != nil { - return model.Emoji{}, err + return apimodel.Emoji{}, err } } category = e.Category.Name } - return model.Emoji{ + return apimodel.Emoji{ Shortcode: e.Shortcode, URL: e.ImageURL, StaticURL: e.ImageStaticURL, @@ -388,13 +388,13 @@ func (c *converter) EmojiToAPIEmoji(ctx context.Context, e *gtsmodel.Emoji) (mod }, nil } -func (c *converter) EmojiToAdminAPIEmoji(ctx context.Context, e *gtsmodel.Emoji) (*model.AdminEmoji, error) { +func (c *converter) EmojiToAdminAPIEmoji(ctx context.Context, e *gtsmodel.Emoji) (*apimodel.AdminEmoji, error) { emoji, err := c.EmojiToAPIEmoji(ctx, e) if err != nil { return nil, err } - return &model.AdminEmoji{ + return &apimodel.AdminEmoji{ Emoji: emoji, ID: e.ID, Disabled: *e.Disabled, @@ -406,21 +406,21 @@ func (c *converter) EmojiToAdminAPIEmoji(ctx context.Context, e *gtsmodel.Emoji) }, nil } -func (c *converter) EmojiCategoryToAPIEmojiCategory(ctx context.Context, category *gtsmodel.EmojiCategory) (*model.EmojiCategory, error) { - return &model.EmojiCategory{ +func (c *converter) EmojiCategoryToAPIEmojiCategory(ctx context.Context, category *gtsmodel.EmojiCategory) (*apimodel.EmojiCategory, error) { + return &apimodel.EmojiCategory{ ID: category.ID, Name: category.Name, }, nil } -func (c *converter) TagToAPITag(ctx context.Context, t *gtsmodel.Tag) (model.Tag, error) { - return model.Tag{ +func (c *converter) TagToAPITag(ctx context.Context, t *gtsmodel.Tag) (apimodel.Tag, error) { + return apimodel.Tag{ Name: t.Name, URL: t.URL, }, nil } -func (c *converter) StatusToAPIStatus(ctx context.Context, s *gtsmodel.Status, requestingAccount *gtsmodel.Account) (*model.Status, error) { +func (c *converter) StatusToAPIStatus(ctx context.Context, s *gtsmodel.Status, requestingAccount *gtsmodel.Account) (*apimodel.Status, error) { repliesCount, err := c.db.CountStatusReplies(ctx, s) if err != nil { return nil, fmt.Errorf("error counting replies: %s", err) @@ -436,7 +436,7 @@ func (c *converter) StatusToAPIStatus(ctx context.Context, s *gtsmodel.Status, r return nil, fmt.Errorf("error counting faves: %s", err) } - var apiRebloggedStatus *model.Status + var apiRebloggedStatus *apimodel.Status if s.BoostOfID != "" { // the boosted status might have been set on this struct already so check first before doing db calls if s.BoostOf == nil { @@ -465,7 +465,7 @@ func (c *converter) StatusToAPIStatus(ctx context.Context, s *gtsmodel.Status, r } } - var apiApplication *model.Application + var apiApplication *apimodel.Application if s.CreatedWithApplicationID != "" { gtsApplication := &gtsmodel.Application{} if err := c.db.GetByID(ctx, s.CreatedWithApplicationID, gtsApplication); err != nil { @@ -528,7 +528,7 @@ func (c *converter) StatusToAPIStatus(ctx context.Context, s *gtsmodel.Status, r language = &s.Language } - apiStatus := &model.Status{ + apiStatus := &apimodel.Status{ ID: s.ID, CreatedAt: util.FormatISO8601(s.CreatedAt), InReplyToID: nil, @@ -572,29 +572,29 @@ func (c *converter) StatusToAPIStatus(ctx context.Context, s *gtsmodel.Status, r } if apiRebloggedStatus != nil { - apiStatus.Reblog = &model.StatusReblogged{Status: apiRebloggedStatus} + apiStatus.Reblog = &apimodel.StatusReblogged{Status: apiRebloggedStatus} } return apiStatus, nil } // VisToapi converts a gts visibility into its api equivalent -func (c *converter) VisToAPIVis(ctx context.Context, m gtsmodel.Visibility) model.Visibility { +func (c *converter) VisToAPIVis(ctx context.Context, m gtsmodel.Visibility) apimodel.Visibility { switch m { case gtsmodel.VisibilityPublic: - return model.VisibilityPublic + return apimodel.VisibilityPublic case gtsmodel.VisibilityUnlocked: - return model.VisibilityUnlisted + return apimodel.VisibilityUnlisted case gtsmodel.VisibilityFollowersOnly, gtsmodel.VisibilityMutualsOnly: - return model.VisibilityPrivate + return apimodel.VisibilityPrivate case gtsmodel.VisibilityDirect: - return model.VisibilityDirect + return apimodel.VisibilityDirect } return "" } -func (c *converter) InstanceToAPIInstance(ctx context.Context, i *gtsmodel.Instance) (*model.Instance, error) { - mi := &model.Instance{ +func (c *converter) InstanceToAPIInstance(ctx context.Context, i *gtsmodel.Instance) (*apimodel.Instance, error) { + mi := &apimodel.Instance{ URI: i.URI, Title: i.Title, Description: i.Description, @@ -650,19 +650,19 @@ func (c *converter) InstanceToAPIInstance(ctx context.Context, i *gtsmodel.Insta mi.ApprovalRequired = config.GetAccountsApprovalRequired() mi.InvitesEnabled = false // TODO mi.MaxTootChars = uint(config.GetStatusesMaxChars()) - mi.URLS = &model.InstanceURLs{ + mi.URLS = &apimodel.InstanceURLs{ StreamingAPI: "wss://" + host, } mi.Version = config.GetSoftwareVersion() // todo: remove hardcoded values and put them in config somewhere - mi.Configuration = &model.InstanceConfiguration{ - Statuses: &model.InstanceConfigurationStatuses{ + mi.Configuration = &apimodel.InstanceConfiguration{ + Statuses: &apimodel.InstanceConfigurationStatuses{ MaxCharacters: config.GetStatusesMaxChars(), MaxMediaAttachments: config.GetStatusesMediaMaxFiles(), CharactersReservedPerURL: instanceStatusesCharactersReservedPerURL, }, - MediaAttachments: &model.InstanceConfigurationMediaAttachments{ + MediaAttachments: &apimodel.InstanceConfigurationMediaAttachments{ SupportedMimeTypes: media.AllSupportedMIMETypes(), ImageSizeLimit: int(config.GetMediaImageMaxSize()), // bytes ImageMatrixLimit: instanceMediaAttachmentsImageMatrixLimit, // height*width @@ -670,16 +670,16 @@ func (c *converter) InstanceToAPIInstance(ctx context.Context, i *gtsmodel.Insta VideoFrameRateLimit: instanceMediaAttachmentsVideoFrameRateLimit, VideoMatrixLimit: instanceMediaAttachmentsVideoMatrixLimit, // height*width }, - Polls: &model.InstanceConfigurationPolls{ + Polls: &apimodel.InstanceConfigurationPolls{ MaxOptions: config.GetStatusesPollMaxOptions(), MaxCharactersPerOption: config.GetStatusesPollOptionMaxChars(), MinExpiration: instancePollsMinExpiration, // seconds MaxExpiration: instancePollsMaxExpiration, // seconds }, - Accounts: &model.InstanceConfigurationAccounts{ + Accounts: &apimodel.InstanceConfigurationAccounts{ AllowCustomCSS: config.GetAccountsAllowCustomCSS(), }, - Emojis: &model.InstanceConfigurationEmojis{ + Emojis: &apimodel.InstanceConfigurationEmojis{ EmojiSizeLimit: int(config.GetMediaEmojiLocalMaxSize()), // bytes }, } @@ -702,8 +702,8 @@ func (c *converter) InstanceToAPIInstance(ctx context.Context, i *gtsmodel.Insta return mi, nil } -func (c *converter) RelationshipToAPIRelationship(ctx context.Context, r *gtsmodel.Relationship) (*model.Relationship, error) { - return &model.Relationship{ +func (c *converter) RelationshipToAPIRelationship(ctx context.Context, r *gtsmodel.Relationship) (*apimodel.Relationship, error) { + return &apimodel.Relationship{ ID: r.ID, Following: r.Following, ShowingReblogs: r.ShowingReblogs, @@ -720,7 +720,7 @@ func (c *converter) RelationshipToAPIRelationship(ctx context.Context, r *gtsmod }, nil } -func (c *converter) NotificationToAPINotification(ctx context.Context, n *gtsmodel.Notification) (*model.Notification, error) { +func (c *converter) NotificationToAPINotification(ctx context.Context, n *gtsmodel.Notification) (*apimodel.Notification, error) { if n.TargetAccount == nil { tAccount, err := c.db.GetAccountByID(ctx, n.TargetAccountID) if err != nil { @@ -742,7 +742,7 @@ func (c *converter) NotificationToAPINotification(ctx context.Context, n *gtsmod return nil, fmt.Errorf("NotificationToapi: error converting account to api: %s", err) } - var apiStatus *model.Status + var apiStatus *apimodel.Status if n.StatusID != "" { if n.Status == nil { status, err := c.db.GetStatusByID(ctx, n.StatusID) @@ -772,7 +772,7 @@ func (c *converter) NotificationToAPINotification(ctx context.Context, n *gtsmod apiStatus = apiStatus.Reblog.Status } - return &model.Notification{ + return &apimodel.Notification{ ID: n.ID, Type: string(n.NotificationType), CreatedAt: util.FormatISO8601(n.CreatedAt), @@ -781,9 +781,9 @@ func (c *converter) NotificationToAPINotification(ctx context.Context, n *gtsmod }, nil } -func (c *converter) DomainBlockToAPIDomainBlock(ctx context.Context, b *gtsmodel.DomainBlock, export bool) (*model.DomainBlock, error) { - domainBlock := &model.DomainBlock{ - Domain: model.Domain{ +func (c *converter) DomainBlockToAPIDomainBlock(ctx context.Context, b *gtsmodel.DomainBlock, export bool) (*apimodel.DomainBlock, error) { + domainBlock := &apimodel.DomainBlock{ + Domain: apimodel.Domain{ Domain: b.Domain, PublicComment: b.PublicComment, }, @@ -803,7 +803,7 @@ func (c *converter) DomainBlockToAPIDomainBlock(ctx context.Context, b *gtsmodel } // convertAttachmentsToAPIAttachments will convert a slice of GTS model attachments to frontend API model attachments, falling back to IDs if no GTS models supplied. -func (c *converter) convertAttachmentsToAPIAttachments(ctx context.Context, attachments []*gtsmodel.MediaAttachment, attachmentIDs []string) ([]model.Attachment, error) { +func (c *converter) convertAttachmentsToAPIAttachments(ctx context.Context, attachments []*gtsmodel.MediaAttachment, attachmentIDs []string) ([]apimodel.Attachment, error) { var errs gtserror.MultiError if len(attachments) == 0 { @@ -824,7 +824,7 @@ func (c *converter) convertAttachmentsToAPIAttachments(ctx context.Context, atta } // Preallocate expected frontend slice - apiAttachments := make([]model.Attachment, 0, len(attachments)) + apiAttachments := make([]apimodel.Attachment, 0, len(attachments)) // Convert GTS models to frontend models for _, attachment := range attachments { @@ -840,7 +840,7 @@ func (c *converter) convertAttachmentsToAPIAttachments(ctx context.Context, atta } // convertEmojisToAPIEmojis will convert a slice of GTS model emojis to frontend API model emojis, falling back to IDs if no GTS models supplied. -func (c *converter) convertEmojisToAPIEmojis(ctx context.Context, emojis []*gtsmodel.Emoji, emojiIDs []string) ([]model.Emoji, error) { +func (c *converter) convertEmojisToAPIEmojis(ctx context.Context, emojis []*gtsmodel.Emoji, emojiIDs []string) ([]apimodel.Emoji, error) { var errs gtserror.MultiError if len(emojis) == 0 { @@ -861,7 +861,7 @@ func (c *converter) convertEmojisToAPIEmojis(ctx context.Context, emojis []*gtsm } // Preallocate expected frontend slice - apiEmojis := make([]model.Emoji, 0, len(emojis)) + apiEmojis := make([]apimodel.Emoji, 0, len(emojis)) // Convert GTS models to frontend models for _, emoji := range emojis { @@ -877,7 +877,7 @@ func (c *converter) convertEmojisToAPIEmojis(ctx context.Context, emojis []*gtsm } // convertMentionsToAPIMentions will convert a slice of GTS model mentions to frontend API model mentions, falling back to IDs if no GTS models supplied. -func (c *converter) convertMentionsToAPIMentions(ctx context.Context, mentions []*gtsmodel.Mention, mentionIDs []string) ([]model.Mention, error) { +func (c *converter) convertMentionsToAPIMentions(ctx context.Context, mentions []*gtsmodel.Mention, mentionIDs []string) ([]apimodel.Mention, error) { var errs gtserror.MultiError if len(mentions) == 0 { @@ -893,7 +893,7 @@ func (c *converter) convertMentionsToAPIMentions(ctx context.Context, mentions [ } // Preallocate expected frontend slice - apiMentions := make([]model.Mention, 0, len(mentions)) + apiMentions := make([]apimodel.Mention, 0, len(mentions)) // Convert GTS models to frontend models for _, mention := range mentions { @@ -909,7 +909,7 @@ func (c *converter) convertMentionsToAPIMentions(ctx context.Context, mentions [ } // convertTagsToAPITags will convert a slice of GTS model tags to frontend API model tags, falling back to IDs if no GTS models supplied. -func (c *converter) convertTagsToAPITags(ctx context.Context, tags []*gtsmodel.Tag, tagIDs []string) ([]model.Tag, error) { +func (c *converter) convertTagsToAPITags(ctx context.Context, tags []*gtsmodel.Tag, tagIDs []string) ([]apimodel.Tag, error) { var errs gtserror.MultiError if len(tags) == 0 { @@ -930,7 +930,7 @@ func (c *converter) convertTagsToAPITags(ctx context.Context, tags []*gtsmodel.T } // Preallocate expected frontend slice - apiTags := make([]model.Tag, 0, len(tags)) + apiTags := make([]apimodel.Tag, 0, len(tags)) // Convert GTS models to frontend models for _, tag := range tags { diff --git a/internal/typeutils/internaltorss.go b/internal/typeutils/internaltorss.go @@ -25,7 +25,7 @@ import ( "strings" "github.com/gorilla/feeds" - "github.com/superseriousbusiness/gotosocial/internal/api/model" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/log" @@ -123,7 +123,7 @@ func (c *converter) StatusToRSSItem(ctx context.Context, s *gtsmodel.Status) (*f } // Content - apiEmojis := []model.Emoji{} + apiEmojis := []apimodel.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 api ones if s.Emojis != nil { diff --git a/internal/web/assets.go b/internal/web/assets.go @@ -22,11 +22,9 @@ import ( "fmt" "net/http" "path" - "path/filepath" "strings" "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/log" ) @@ -53,22 +51,6 @@ func (fs fileSystem) Open(path string) (http.File, error) { return f, nil } -func (m *Module) mountAssetsFilesystem(group *gin.RouterGroup) { - webAssetsAbsFilePath, err := filepath.Abs(config.GetWebAssetBaseDir()) - if err != nil { - log.Panicf("mountAssetsFilesystem: error getting absolute path of assets dir: %s", err) - } - - fs := fileSystem{http.Dir(webAssetsAbsFilePath)} - - // use the cache middleware on all handlers in this group - group.Use(m.assetsCacheControlMiddleware(fs)) - - // serve static file system in the root of this group, - // will end up being something like "/assets/" - group.StaticFS("/", fs) -} - // getAssetFileInfo tries to fetch the ETag for the given filePath from the module's // assetsETagCache. If it can't be found there, it uses the provided http.FileSystem // to generate a new ETag to go in the cache, which it then returns. @@ -115,6 +97,9 @@ func (m *Module) getAssetETag(filePath string, fs http.FileSystem) (string, erro // // See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match // and: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control +// +// todo: move this middleware out of the 'web' package and into the 'middleware' +// package along with the other middlewares func (m *Module) assetsCacheControlMiddleware(fs http.FileSystem) gin.HandlerFunc { return func(c *gin.Context) { // set this Cache-Control header to instruct clients to validate the response with us diff --git a/internal/web/base.go b/internal/web/base.go @@ -23,7 +23,7 @@ import ( "strings" "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/gtserror" ) @@ -39,7 +39,7 @@ func (m *Module) baseHandler(c *gin.Context) { host := config.GetHost() instance, err := m.processor.InstanceGet(c.Request.Context(), host) if err != nil { - api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) return } diff --git a/internal/web/confirmemail.go b/internal/web/confirmemail.go @@ -23,7 +23,7 @@ import ( "net/http" "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/gtserror" ) @@ -34,20 +34,20 @@ func (m *Module) confirmEmailGETHandler(c *gin.Context) { // if there's no token in the query, just serve the 404 web handler token := c.Query(tokenParam) if token == "" { - api.ErrorHandler(c, gtserror.NewErrorNotFound(errors.New(http.StatusText(http.StatusNotFound))), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorNotFound(errors.New(http.StatusText(http.StatusNotFound))), m.processor.InstanceGet) return } user, errWithCode := m.processor.UserConfirmEmail(ctx, token) if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } host := config.GetHost() instance, err := m.processor.InstanceGet(ctx, host) if err != nil { - api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) return } diff --git a/internal/web/customcss.go b/internal/web/customcss.go @@ -24,22 +24,22 @@ import ( "strings" "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/gtserror" ) -const textCSSUTF8 = string(api.TextCSS + "; charset=utf-8") +const textCSSUTF8 = string(apiutil.TextCSS + "; charset=utf-8") func (m *Module) customCSSGETHandler(c *gin.Context) { if !config.GetAccountsAllowCustomCSS() { err := errors.New("accounts-allow-custom-css is not enabled on this instance") - api.ErrorHandler(c, gtserror.NewErrorNotFound(err), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorNotFound(err), m.processor.InstanceGet) return } - if _, err := api.NegotiateAccept(c, api.TextCSS); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + if _, err := apiutil.NegotiateAccept(c, apiutil.TextCSS); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } @@ -47,13 +47,13 @@ func (m *Module) customCSSGETHandler(c *gin.Context) { username := strings.ToLower(c.Param(usernameKey)) if username == "" { err := errors.New("no account username specified") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } customCSS, errWithCode := m.processor.AccountGetCustomCSSForUsername(c.Request.Context(), username) if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/web/profile.go b/internal/web/profile.go @@ -28,8 +28,8 @@ import ( "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/ap" - "github.com/superseriousbusiness/gotosocial/internal/api" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" @@ -45,21 +45,21 @@ func (m *Module) profileGETHandler(c *gin.Context) { authed, err := oauth.Authed(c, false, false, false, false) if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } username := strings.ToLower(c.Param(usernameKey)) if username == "" { err := errors.New("no account username specified") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } host := config.GetHost() instance, err := m.processor.InstanceGet(ctx, host) if err != nil { - api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) return } @@ -69,14 +69,14 @@ func (m *Module) profileGETHandler(c *gin.Context) { account, errWithCode := m.processor.AccountGetLocalByUsername(ctx, authed, username) if errWithCode != nil { - api.ErrorHandler(c, errWithCode, instanceGet) + apiutil.ErrorHandler(c, errWithCode, instanceGet) return } // if we're getting an AP request on this endpoint we // should render the account's AP representation instead - accept := c.NegotiateFormat(string(api.TextHTML), string(api.AppActivityJSON), string(api.AppActivityLDJSON)) - if accept == string(api.AppActivityJSON) || accept == string(api.AppActivityLDJSON) { + accept := c.NegotiateFormat(string(apiutil.TextHTML), string(apiutil.AppActivityJSON), string(apiutil.AppActivityLDJSON)) + if accept == string(apiutil.AppActivityJSON) || accept == string(apiutil.AppActivityLDJSON) { m.returnAPProfile(ctx, c, username, accept) return } @@ -89,7 +89,7 @@ func (m *Module) profileGETHandler(c *gin.Context) { // only allow search engines / robots to view this page if account is discoverable var robotsMeta string if account.Discoverable { - robotsMeta = robotsAllowSome + robotsMeta = robotsMetaAllowSome } // we should only show the 'back to top' button if the @@ -105,7 +105,7 @@ func (m *Module) profileGETHandler(c *gin.Context) { statusResp, errWithCode := m.processor.AccountWebStatusesGet(ctx, account.ID, maxStatusID) if errWithCode != nil { - api.ErrorHandler(c, errWithCode, instanceGet) + apiutil.ErrorHandler(c, errWithCode, instanceGet) return } @@ -145,14 +145,14 @@ func (m *Module) returnAPProfile(ctx context.Context, c *gin.Context, username s user, errWithCode := m.processor.GetFediUser(ctx, username, c.Request.URL) if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) //nolint:contextcheck + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) //nolint:contextcheck return } b, mErr := json.Marshal(user) if mErr != nil { err := fmt.Errorf("could not marshal json: %s", mErr) - api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) //nolint:contextcheck + apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) //nolint:contextcheck return } diff --git a/internal/web/robots.go b/internal/web/robots.go @@ -18,7 +18,45 @@ package web -// https://developers.google.com/search/docs/crawling-indexing/robots-meta-tag#robotsmeta +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + const ( - robotsAllowSome = "nofollow, noarchive, nositelinkssearchbox, max-image-preview:standard" + robotsPath = "/robots.txt" + robotsMetaAllowSome = "nofollow, noarchive, nositelinkssearchbox, max-image-preview:standard" // https://developers.google.com/search/docs/crawling-indexing/robots-meta-tag#robotsmeta + robotsTxt = `# GoToSocial robots.txt -- to edit, see internal/web/robots.go +# more info @ https://developers.google.com/search/docs/crawling-indexing/robots/intro +User-agent: * +Crawl-delay: 500 +# api stuff +Disallow: /api/ +# auth/login stuff +Disallow: /auth/ +Disallow: /oauth/ +Disallow: /check_your_email +Disallow: /wait_for_approval +Disallow: /account_disabled +# well known stuff +Disallow: /.well-known/ +# files +Disallow: /fileserver/ +# s2s AP stuff +Disallow: /users/ +Disallow: /emoji/ +# panels +Disallow: /admin +Disallow: /user +Disallow: /settings/` ) + +// robotsGETHandler returns a decent robots.txt that prevents crawling +// the api, auth pages, settings pages, etc. +// +// More granular robots meta tags are then applied for web pages +// depending on user preferences (see internal/web). +func (m *Module) robotsGETHandler(c *gin.Context) { + c.String(http.StatusOK, robotsTxt) +} diff --git a/internal/web/rss.go b/internal/web/rss.go @@ -27,12 +27,12 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/log" ) -const appRSSUTF8 = string(api.AppRSSXML + "; charset=utf-8") +const appRSSUTF8 = string(apiutil.AppRSSXML + "; charset=utf-8") func (m *Module) GetRSSETag(urlPath string, lastModified time.Time, getRSSFeed func() (string, gtserror.WithCode)) (string, error) { if cachedETag, ok := m.eTagCache.Get(urlPath); ok && !lastModified.After(cachedETag.lastModified) { @@ -81,8 +81,8 @@ func (m *Module) rssFeedGETHandler(c *gin.Context) { c.Header(cacheControlHeader, cacheControlNoCache) ctx := c.Request.Context() - if _, err := api.NegotiateAccept(c, api.AppRSSXML); err != nil { - api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + if _, err := apiutil.NegotiateAccept(c, apiutil.AppRSSXML); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } @@ -90,7 +90,7 @@ func (m *Module) rssFeedGETHandler(c *gin.Context) { username := strings.ToLower(c.Param(usernameKey)) if username == "" { err := errors.New("no account username specified") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } @@ -99,7 +99,7 @@ func (m *Module) rssFeedGETHandler(c *gin.Context) { getRssFeed, accountLastPostedPublic, errWithCode := m.processor.AccountGetRSSFeedForUsername(ctx, username) if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } @@ -111,13 +111,13 @@ func (m *Module) rssFeedGETHandler(c *gin.Context) { // we either have no cache entry for this, or we have an expired cache entry; generate a new one rssFeed, errWithCode = getRssFeed() if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } eTag, err := generateEtag(bytes.NewBufferString(rssFeed)) if err != nil { - api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) return } @@ -145,7 +145,7 @@ func (m *Module) rssFeedGETHandler(c *gin.Context) { // we had a cache entry already so we didn't call to get the rss feed yet rssFeed, errWithCode = getRssFeed() if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } } diff --git a/internal/web/settings-panel.go b/internal/web/settings-panel.go @@ -22,7 +22,7 @@ import ( "net/http" "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/gtserror" ) @@ -31,7 +31,7 @@ func (m *Module) SettingsPanelHandler(c *gin.Context) { host := config.GetHost() instance, err := m.processor.InstanceGet(c.Request.Context(), host) if err != nil { - api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) return } diff --git a/internal/web/thread.go b/internal/web/thread.go @@ -28,8 +28,8 @@ import ( "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/ap" - "github.com/superseriousbusiness/gotosocial/internal/api" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" @@ -40,7 +40,7 @@ func (m *Module) threadGETHandler(c *gin.Context) { authed, err := oauth.Authed(c, false, false, false, false) if err != nil { - api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } @@ -48,7 +48,7 @@ func (m *Module) threadGETHandler(c *gin.Context) { username := strings.ToLower(c.Param(usernameKey)) if username == "" { err := errors.New("no account username specified") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } @@ -56,14 +56,14 @@ func (m *Module) threadGETHandler(c *gin.Context) { statusID := strings.ToUpper(c.Param(statusIDKey)) if statusID == "" { err := errors.New("no status id specified") - api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } host := config.GetHost() instance, err := m.processor.InstanceGet(ctx, host) if err != nil { - api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) return } @@ -74,33 +74,33 @@ func (m *Module) threadGETHandler(c *gin.Context) { // do this check to make sure the status is actually from a local account, // we shouldn't render threads from statuses that don't belong to us! if _, errWithCode := m.processor.AccountGetLocalByUsername(ctx, authed, username); errWithCode != nil { - api.ErrorHandler(c, errWithCode, instanceGet) + apiutil.ErrorHandler(c, errWithCode, instanceGet) return } status, errWithCode := m.processor.StatusGet(ctx, authed, statusID) if errWithCode != nil { - api.ErrorHandler(c, errWithCode, instanceGet) + apiutil.ErrorHandler(c, errWithCode, instanceGet) return } if !strings.EqualFold(username, status.Account.Username) { err := gtserror.NewErrorNotFound(errors.New("path username not equal to status author username")) - api.ErrorHandler(c, gtserror.NewErrorNotFound(err), instanceGet) + apiutil.ErrorHandler(c, gtserror.NewErrorNotFound(err), instanceGet) return } // if we're getting an AP request on this endpoint we // should render the status's AP representation instead - accept := c.NegotiateFormat(string(api.TextHTML), string(api.AppActivityJSON), string(api.AppActivityLDJSON)) - if accept == string(api.AppActivityJSON) || accept == string(api.AppActivityLDJSON) { + accept := c.NegotiateFormat(string(apiutil.TextHTML), string(apiutil.AppActivityJSON), string(apiutil.AppActivityLDJSON)) + if accept == string(apiutil.AppActivityJSON) || accept == string(apiutil.AppActivityLDJSON) { m.returnAPStatus(ctx, c, username, statusID, accept) return } context, errWithCode := m.processor.StatusGetContext(ctx, authed, statusID) if errWithCode != nil { - api.ErrorHandler(c, errWithCode, instanceGet) + apiutil.ErrorHandler(c, errWithCode, instanceGet) return } @@ -135,14 +135,14 @@ func (m *Module) returnAPStatus(ctx context.Context, c *gin.Context, username st status, errWithCode := m.processor.GetFediStatus(ctx, username, statusID, c.Request.URL) if errWithCode != nil { - api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) //nolint:contextcheck + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet) //nolint:contextcheck return } b, mErr := json.Marshal(status) if mErr != nil { err := fmt.Errorf("could not marshal json: %s", mErr) - api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) //nolint:contextcheck + apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) //nolint:contextcheck return } diff --git a/internal/web/web.go b/internal/web/web.go @@ -19,28 +19,30 @@ package web import ( - "errors" "net/http" + "path/filepath" "codeberg.org/gruf/go-cache/v3" "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/api" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/processing" "github.com/superseriousbusiness/gotosocial/internal/router" "github.com/superseriousbusiness/gotosocial/internal/uris" ) const ( - confirmEmailPath = "/" + uris.ConfirmEmailPath - profilePath = "/@:" + usernameKey - customCSSPath = profilePath + "/custom.css" - rssFeedPath = profilePath + "/feed.rss" - statusPath = profilePath + "/statuses/:" + statusIDKey - assetsPathPrefix = "/assets" - distPathPrefix = assetsPathPrefix + "/dist" - userPanelPath = "/settings/user" - adminPanelPath = "/settings/admin" + confirmEmailPath = "/" + uris.ConfirmEmailPath + profilePath = "/@:" + usernameKey + customCSSPath = profilePath + "/custom.css" + rssFeedPath = profilePath + "/feed.rss" + statusPath = profilePath + "/statuses/:" + statusIDKey + assetsPathPrefix = "/assets" + distPathPrefix = assetsPathPrefix + "/dist" + settingsPathPrefix = "/settings" + settingsPanelGlob = settingsPathPrefix + "/*panel" + userPanelPath = settingsPathPrefix + "/user" + adminPanelPath = settingsPathPrefix + "/admin" tokenParam = "token" usernameKey = "username" @@ -54,67 +56,54 @@ const ( lastModifiedHeader = "Last-Modified" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Last-Modified ) -// Module implements the api.ClientModule interface for web pages. type Module struct { processor processing.Processor eTagCache cache.Cache[string, eTagCacheEntry] } -// New returns a new api.ClientModule for web pages. -func New(processor processing.Processor) api.ClientModule { +func New(processor processing.Processor) *Module { return &Module{ processor: processor, eTagCache: newETagCache(), } } -// Route satisfies the RESTAPIModule interface -func (m *Module) Route(s router.Router) error { +func (m *Module) Route(r router.Router) { // serve static files from assets dir at /assets - assetsGroup := s.AttachGroup(assetsPathPrefix) - m.mountAssetsFilesystem(assetsGroup) - - s.AttachHandler(http.MethodGet, "/settings", m.SettingsPanelHandler) - s.AttachHandler(http.MethodGet, "/settings/*panel", m.SettingsPanelHandler) - - // User panel redirects - // used by clients - s.AttachHandler(http.MethodGet, "/auth/edit", func(c *gin.Context) { - c.Redirect(http.StatusMovedPermanently, userPanelPath) - }) - - // old version of settings panel - s.AttachHandler(http.MethodGet, "/user", func(c *gin.Context) { - c.Redirect(http.StatusMovedPermanently, userPanelPath) - }) - - // Admin panel redirects - // old version of settings panel - s.AttachHandler(http.MethodGet, "/admin", func(c *gin.Context) { - c.Redirect(http.StatusMovedPermanently, adminPanelPath) - }) - - // serve front-page - s.AttachHandler(http.MethodGet, "/", m.baseHandler) + assetsGroup := r.AttachGroup(assetsPathPrefix) + webAssetsAbsFilePath, err := filepath.Abs(config.GetWebAssetBaseDir()) + if err != nil { + log.Panicf("error getting absolute path of assets dir: %s", err) + } - // serve profile pages at /@username - s.AttachHandler(http.MethodGet, profilePath, m.profileGETHandler) + fs := fileSystem{http.Dir(webAssetsAbsFilePath)} - // serve custom css at /@username/custom.css - s.AttachHandler(http.MethodGet, customCSSPath, m.customCSSGETHandler) + // use the cache middleware on all handlers in this group + assetsGroup.Use(m.assetsCacheControlMiddleware(fs)) - s.AttachHandler(http.MethodGet, rssFeedPath, m.rssFeedGETHandler) + // serve static file system in the root of this group, + // will end up being something like "/assets/" + assetsGroup.StaticFS("/", fs) - // serve statuses - s.AttachHandler(http.MethodGet, statusPath, m.threadGETHandler) + /* + Attach individual web handlers which require no specific middlewares + */ - // serve email confirmation page at /confirm_email?token=whatever - s.AttachHandler(http.MethodGet, confirmEmailPath, m.confirmEmailGETHandler) + r.AttachHandler(http.MethodGet, "/", m.baseHandler) // front-page + r.AttachHandler(http.MethodGet, settingsPathPrefix, m.SettingsPanelHandler) + r.AttachHandler(http.MethodGet, settingsPanelGlob, m.SettingsPanelHandler) + r.AttachHandler(http.MethodGet, profilePath, m.profileGETHandler) + r.AttachHandler(http.MethodGet, customCSSPath, m.customCSSGETHandler) + r.AttachHandler(http.MethodGet, rssFeedPath, m.rssFeedGETHandler) + r.AttachHandler(http.MethodGet, statusPath, m.threadGETHandler) + r.AttachHandler(http.MethodGet, confirmEmailPath, m.confirmEmailGETHandler) + r.AttachHandler(http.MethodGet, robotsPath, m.robotsGETHandler) - // 404 handler - s.AttachNoRouteHandler(func(c *gin.Context) { - api.ErrorHandler(c, gtserror.NewErrorNotFound(errors.New(http.StatusText(http.StatusNotFound))), m.processor.InstanceGet) - }) + /* + Attach redirects from old endpoints to current ones for backwards compatibility + */ - return nil + r.AttachHandler(http.MethodGet, "/auth/edit", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, userPanelPath) }) + r.AttachHandler(http.MethodGet, "/user", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, userPanelPath) }) + r.AttachHandler(http.MethodGet, "/admin", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, adminPanelPath) }) } diff --git a/testrig/router.go b/testrig/router.go @@ -51,7 +51,7 @@ func NewTestRouter(db db.DB) router.Router { } } - r, err := router.New(context.Background(), db) + r, err := router.New(context.Background()) if err != nil { panic(err) }