From 941893a774c83802afdc4cc76e1d30c59b6c5585 Mon Sep 17 00:00:00 2001
From: tobi <31960611+tsmethurst@users.noreply.github.com>
Date: Mon, 2 Jan 2023 13:10:50 +0100
Subject: [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
---
internal/api/activitypub.go | 66 +++
internal/api/activitypub/emoji/emoji.go | 47 ++
internal/api/activitypub/emoji/emojiget.go | 59 +++
internal/api/activitypub/emoji/emojiget_test.go | 137 ++++++
internal/api/activitypub/users/common.go | 57 +++
internal/api/activitypub/users/followers.go | 67 +++
internal/api/activitypub/users/following.go | 67 +++
internal/api/activitypub/users/inboxpost.go | 51 +++
internal/api/activitypub/users/inboxpost_test.go | 500 +++++++++++++++++++++
internal/api/activitypub/users/outboxget.go | 145 ++++++
internal/api/activitypub/users/outboxget_test.go | 216 +++++++++
internal/api/activitypub/users/publickeyget.go | 71 +++
internal/api/activitypub/users/repliesget.go | 166 +++++++
internal/api/activitypub/users/repliesget_test.go | 239 ++++++++++
internal/api/activitypub/users/statusget.go | 75 ++++
internal/api/activitypub/users/statusget_test.go | 162 +++++++
internal/api/activitypub/users/user.go | 80 ++++
internal/api/activitypub/users/user_test.go | 103 +++++
internal/api/activitypub/users/userget.go | 75 ++++
internal/api/activitypub/users/userget_test.go | 177 ++++++++
internal/api/apimodule.go | 37 --
internal/api/auth.go | 64 +++
internal/api/auth/auth.go | 117 +++++
internal/api/auth/auth_test.go | 129 ++++++
internal/api/auth/authorize.go | 335 ++++++++++++++
internal/api/auth/authorize_test.go | 118 +++++
internal/api/auth/callback.go | 317 +++++++++++++
internal/api/auth/oob.go | 113 +++++
internal/api/auth/signin.go | 145 ++++++
internal/api/auth/token.go | 115 +++++
internal/api/auth/token_test.go | 215 +++++++++
internal/api/client.go | 129 ++++++
internal/api/client/account/account.go | 141 ------
internal/api/client/account/account_test.go | 127 ------
internal/api/client/account/accountcreate.go | 150 -------
internal/api/client/account/accountcreate_test.go | 19 -
internal/api/client/account/accountdelete.go | 95 ----
internal/api/client/account/accountdelete_test.go | 101 -----
internal/api/client/account/accountget.go | 95 ----
internal/api/client/account/accountupdate.go | 216 ---------
internal/api/client/account/accountupdate_test.go | 452 -------------------
internal/api/client/account/accountverify.go | 78 ----
internal/api/client/account/accountverify_test.go | 91 ----
internal/api/client/account/block.go | 95 ----
internal/api/client/account/block_test.go | 74 ---
internal/api/client/account/follow.go | 124 -----
internal/api/client/account/follow_test.go | 75 ----
internal/api/client/account/followers.go | 98 ----
internal/api/client/account/following.go | 98 ----
internal/api/client/account/relationships.go | 93 ----
internal/api/client/account/statuses.go | 246 ----------
internal/api/client/account/statuses_test.go | 123 -----
internal/api/client/account/unblock.go | 96 ----
internal/api/client/account/unfollow.go | 96 ----
internal/api/client/accounts/account_test.go | 127 ++++++
internal/api/client/accounts/accountcreate.go | 150 +++++++
internal/api/client/accounts/accountcreate_test.go | 19 +
internal/api/client/accounts/accountdelete.go | 95 ++++
internal/api/client/accounts/accountdelete_test.go | 101 +++++
internal/api/client/accounts/accountget.go | 95 ++++
internal/api/client/accounts/accounts.go | 119 +++++
internal/api/client/accounts/accountupdate.go | 216 +++++++++
internal/api/client/accounts/accountupdate_test.go | 452 +++++++++++++++++++
internal/api/client/accounts/accountverify.go | 78 ++++
internal/api/client/accounts/accountverify_test.go | 91 ++++
internal/api/client/accounts/block.go | 95 ++++
internal/api/client/accounts/block_test.go | 74 +++
internal/api/client/accounts/follow.go | 124 +++++
internal/api/client/accounts/follow_test.go | 75 ++++
internal/api/client/accounts/followers.go | 98 ++++
internal/api/client/accounts/following.go | 98 ++++
internal/api/client/accounts/relationships.go | 93 ++++
internal/api/client/accounts/statuses.go | 246 ++++++++++
internal/api/client/accounts/statuses_test.go | 123 +++++
internal/api/client/accounts/unblock.go | 96 ++++
internal/api/client/accounts/unfollow.go | 96 ++++
internal/api/client/admin/accountaction.go | 18 +-
internal/api/client/admin/admin.go | 41 +-
internal/api/client/admin/admin_test.go | 2 +-
internal/api/client/admin/domainblockcreate.go | 26 +-
internal/api/client/admin/domainblockdelete.go | 14 +-
internal/api/client/admin/domainblockget.go | 16 +-
internal/api/client/admin/domainblocksget.go | 14 +-
internal/api/client/admin/emojicategoriesget.go | 12 +-
internal/api/client/admin/emojicreate.go | 22 +-
internal/api/client/admin/emojidelete.go | 14 +-
internal/api/client/admin/emojiget.go | 14 +-
internal/api/client/admin/emojisget.go | 16 +-
internal/api/client/admin/emojiupdate.go | 36 +-
internal/api/client/admin/mediacleanup.go | 14 +-
internal/api/client/admin/mediarefetch.go | 8 +-
internal/api/client/app/app.go | 48 --
internal/api/client/app/app_test.go | 21 -
internal/api/client/app/appcreate.go | 126 ------
internal/api/client/apps/appcreate.go | 126 ++++++
internal/api/client/apps/apps.go | 43 ++
internal/api/client/auth/auth.go | 105 -----
internal/api/client/auth/auth_test.go | 139 ------
internal/api/client/auth/authorize.go | 335 --------------
internal/api/client/auth/authorize_test.go | 118 -----
internal/api/client/auth/callback.go | 311 -------------
internal/api/client/auth/oob.go | 111 -----
internal/api/client/auth/signin.go | 145 ------
internal/api/client/auth/token.go | 115 -----
internal/api/client/auth/token_test.go | 215 ---------
internal/api/client/auth/util.go | 31 --
internal/api/client/blocks/blocks.go | 17 +-
internal/api/client/blocks/blocksget.go | 12 +-
internal/api/client/bookmarks/bookmarks.go | 13 +-
internal/api/client/bookmarks/bookmarks_test.go | 10 +-
internal/api/client/bookmarks/bookmarksget.go | 14 +-
internal/api/client/customemojis/customemojis.go | 45 ++
.../api/client/customemojis/customemojisget.go | 76 ++++
internal/api/client/emoji/emoji.go | 50 ---
internal/api/client/emoji/emojisget.go | 58 ---
internal/api/client/favourites/favourites.go | 17 +-
internal/api/client/favourites/favourites_test.go | 2 +-
internal/api/client/favourites/favouritesget.go | 12 +-
internal/api/client/fileserver/fileserver.go | 64 ---
internal/api/client/fileserver/fileserver_test.go | 109 -----
internal/api/client/fileserver/servefile.go | 135 ------
internal/api/client/fileserver/servefile_test.go | 272 -----------
internal/api/client/filter/filter.go | 50 ---
internal/api/client/filter/filtersget.go | 25 --
internal/api/client/filters/filter.go | 45 ++
internal/api/client/filters/filtersget.go | 25 ++
internal/api/client/followrequest/authorize.go | 98 ----
.../api/client/followrequest/authorize_test.go | 115 -----
internal/api/client/followrequest/followrequest.go | 61 ---
.../api/client/followrequest/followrequest_test.go | 122 -----
internal/api/client/followrequest/get.go | 93 ----
internal/api/client/followrequest/get_test.go | 78 ----
internal/api/client/followrequest/reject.go | 96 ----
internal/api/client/followrequest/reject_test.go | 87 ----
internal/api/client/followrequests/authorize.go | 98 ++++
.../api/client/followrequests/authorize_test.go | 115 +++++
.../api/client/followrequests/followrequest.go | 56 +++
.../client/followrequests/followrequest_test.go | 122 +++++
internal/api/client/followrequests/get.go | 93 ++++
internal/api/client/followrequests/get_test.go | 78 ++++
internal/api/client/followrequests/reject.go | 96 ++++
internal/api/client/followrequests/reject_test.go | 87 ++++
internal/api/client/instance/instance.go | 21 +-
internal/api/client/instance/instance_test.go | 2 +-
internal/api/client/instance/instanceget.go | 8 +-
internal/api/client/instance/instancepatch.go | 22 +-
internal/api/client/instance/instancepeersget.go | 12 +-
internal/api/client/list/list.go | 50 ---
internal/api/client/list/listsgets.go | 25 --
internal/api/client/lists/list.go | 45 ++
internal/api/client/lists/listsgets.go | 44 ++
internal/api/client/media/media.go | 27 +-
internal/api/client/media/mediacreate.go | 34 +-
internal/api/client/media/mediacreate_test.go | 53 +--
internal/api/client/media/mediaget.go | 18 +-
internal/api/client/media/mediaupdate.go | 28 +-
internal/api/client/media/mediaupdate_test.go | 31 +-
internal/api/client/notification/notification.go | 66 ---
.../api/client/notification/notificationsclear.go | 80 ----
.../api/client/notification/notificationsget.go | 159 -------
internal/api/client/notifications/notifications.go | 61 +++
.../api/client/notifications/notificationsclear.go | 80 ++++
.../api/client/notifications/notificationsget.go | 159 +++++++
internal/api/client/search/search.go | 23 +-
internal/api/client/search/search_test.go | 2 +-
internal/api/client/search/searchget.go | 26 +-
internal/api/client/status/status.go | 123 -----
internal/api/client/status/status_test.go | 98 ----
internal/api/client/status/statusbookmark.go | 98 ----
internal/api/client/status/statusbookmark_test.go | 83 ----
internal/api/client/status/statusboost.go | 101 -----
internal/api/client/status/statusboost_test.go | 247 ----------
internal/api/client/status/statusboostedby.go | 89 ----
internal/api/client/status/statusboostedby_test.go | 112 -----
internal/api/client/status/statuscontext.go | 100 -----
internal/api/client/status/statuscreate.go | 172 -------
internal/api/client/status/statuscreate_test.go | 398 ----------------
internal/api/client/status/statusdelete.go | 100 -----
internal/api/client/status/statusdelete_test.go | 91 ----
internal/api/client/status/statusfave.go | 97 ----
internal/api/client/status/statusfave_test.go | 131 ------
internal/api/client/status/statusfavedby.go | 98 ----
internal/api/client/status/statusfavedby_test.go | 88 ----
internal/api/client/status/statusget.go | 97 ----
internal/api/client/status/statusget_test.go | 33 --
internal/api/client/status/statusunbookmark.go | 98 ----
.../api/client/status/statusunbookmark_test.go | 78 ----
internal/api/client/status/statusunboost.go | 98 ----
internal/api/client/status/statusunfave.go | 97 ----
internal/api/client/status/statusunfave_test.go | 143 ------
internal/api/client/statuses/status.go | 100 +++++
internal/api/client/statuses/status_test.go | 98 ++++
internal/api/client/statuses/statusbookmark.go | 98 ++++
.../api/client/statuses/statusbookmark_test.go | 83 ++++
internal/api/client/statuses/statusboost.go | 101 +++++
internal/api/client/statuses/statusboost_test.go | 247 ++++++++++
internal/api/client/statuses/statusboostedby.go | 89 ++++
.../api/client/statuses/statusboostedby_test.go | 112 +++++
internal/api/client/statuses/statuscontext.go | 100 +++++
internal/api/client/statuses/statuscreate.go | 172 +++++++
internal/api/client/statuses/statuscreate_test.go | 398 ++++++++++++++++
internal/api/client/statuses/statusdelete.go | 100 +++++
internal/api/client/statuses/statusdelete_test.go | 91 ++++
internal/api/client/statuses/statusfave.go | 97 ++++
internal/api/client/statuses/statusfave_test.go | 132 ++++++
internal/api/client/statuses/statusfavedby.go | 98 ++++
internal/api/client/statuses/statusfavedby_test.go | 88 ++++
internal/api/client/statuses/statusget.go | 97 ++++
internal/api/client/statuses/statusget_test.go | 33 ++
internal/api/client/statuses/statusunbookmark.go | 98 ++++
.../api/client/statuses/statusunbookmark_test.go | 78 ++++
internal/api/client/statuses/statusunboost.go | 98 ++++
internal/api/client/statuses/statusunfave.go | 97 ++++
internal/api/client/statuses/statusunfave_test.go | 143 ++++++
internal/api/client/streaming/stream.go | 60 ++-
internal/api/client/streaming/streaming.go | 19 +-
internal/api/client/streaming/streaming_test.go | 2 +-
internal/api/client/timeline/home.go | 176 --------
internal/api/client/timeline/public.go | 187 --------
internal/api/client/timeline/timeline.go | 65 ---
internal/api/client/timelines/home.go | 176 ++++++++
internal/api/client/timelines/public.go | 187 ++++++++
internal/api/client/timelines/timeline.go | 60 +++
internal/api/client/user/passwordchange.go | 20 +-
internal/api/client/user/user.go | 17 +-
internal/api/client/user/user_test.go | 2 +-
internal/api/errorhandling.go | 132 ------
internal/api/fileserver.go | 55 +++
internal/api/fileserver/fileserver.go | 54 +++
internal/api/fileserver/fileserver_test.go | 109 +++++
internal/api/fileserver/servefile.go | 130 ++++++
internal/api/fileserver/servefile_test.go | 272 +++++++++++
internal/api/mime.go | 36 --
internal/api/negotiate.go | 104 -----
internal/api/nodeinfo.go | 50 +++
internal/api/nodeinfo/nodeinfo.go | 46 ++
internal/api/nodeinfo/nodeinfoget.go | 66 +++
internal/api/s2s/emoji/emoji.go | 53 ---
internal/api/s2s/emoji/emojiget.go | 74 ---
internal/api/s2s/emoji/emojiget_test.go | 138 ------
internal/api/s2s/nodeinfo/nodeinfo.go | 53 ---
internal/api/s2s/nodeinfo/nodeinfoget.go | 66 ---
internal/api/s2s/nodeinfo/wellknownget.go | 60 ---
internal/api/s2s/user/common.go | 81 ----
internal/api/s2s/user/followers.go | 67 ---
internal/api/s2s/user/following.go | 67 ---
internal/api/s2s/user/inboxpost.go | 51 ---
internal/api/s2s/user/inboxpost_test.go | 500 ---------------------
internal/api/s2s/user/outboxget.go | 145 ------
internal/api/s2s/user/outboxget_test.go | 216 ---------
internal/api/s2s/user/publickeyget.go | 71 ---
internal/api/s2s/user/repliesget.go | 166 -------
internal/api/s2s/user/repliesget_test.go | 239 ----------
internal/api/s2s/user/statusget.go | 75 ----
internal/api/s2s/user/statusget_test.go | 162 -------
internal/api/s2s/user/user.go | 89 ----
internal/api/s2s/user/user_test.go | 103 -----
internal/api/s2s/user/userget.go | 75 ----
internal/api/s2s/user/userget_test.go | 177 --------
internal/api/s2s/webfinger/webfinger.go | 50 ---
internal/api/s2s/webfinger/webfinger_test.go | 137 ------
internal/api/s2s/webfinger/webfingerget.go | 102 -----
internal/api/s2s/webfinger/webfingerget_test.go | 171 -------
internal/api/security/extraheaders.go | 26 --
internal/api/security/flocblock.go | 31 --
internal/api/security/ratelimit.go | 70 ---
internal/api/security/robots.go | 57 ---
internal/api/security/security.go | 65 ---
internal/api/security/signaturecheck.go | 51 ---
internal/api/security/tokencheck.go | 120 -----
internal/api/security/useragentblock.go | 35 --
internal/api/util/errorhandling.go | 132 ++++++
internal/api/util/mime.go | 36 ++
internal/api/util/negotiate.go | 104 +++++
internal/api/util/signaturectx.go | 41 ++
internal/api/wellknown.go | 55 +++
internal/api/wellknown/nodeinfo/nodeinfo.go | 47 ++
internal/api/wellknown/nodeinfo/nodeinfoget.go | 60 +++
internal/api/wellknown/webfinger/webfinger.go | 46 ++
internal/api/wellknown/webfinger/webfinger_test.go | 134 ++++++
internal/api/wellknown/webfinger/webfingerget.go | 91 ++++
.../api/wellknown/webfinger/webfingerget_test.go | 171 +++++++
internal/config/defaults.go | 2 +-
internal/db/db.go | 2 +-
internal/middleware/cachecontrol.go | 35 ++
internal/middleware/cors.go | 86 ++++
internal/middleware/extraheaders.go | 37 ++
internal/middleware/gzip.go | 30 ++
internal/middleware/logger.go | 99 ++++
internal/middleware/ratelimit.go | 77 ++++
internal/middleware/session.go | 95 ++++
internal/middleware/session_test.go | 95 ++++
internal/middleware/signaturecheck.go | 93 ++++
internal/middleware/tokencheck.go | 140 ++++++
internal/middleware/useragent.go | 39 ++
internal/oidc/idp.go | 8 -
internal/processing/federation.go | 8 +-
internal/processing/federation/federation.go | 4 +-
internal/processing/federation/getnodeinfo.go | 5 +-
internal/processing/fromclientapi_test.go | 4 +-
internal/processing/fromfederator_test.go | 8 +-
internal/processing/oauth.go | 6 +
internal/processing/processor.go | 6 +-
internal/processing/status/create_test.go | 52 +--
internal/processing/status/util.go | 7 +-
internal/processing/status/util_test.go | 62 +--
internal/router/attach.go | 23 +-
internal/router/cors.go | 87 ----
internal/router/gzip.go | 30 --
internal/router/logger.go | 96 ----
internal/router/router.go | 67 ++-
internal/router/session.go | 111 -----
internal/router/session_test.go | 95 ----
internal/router/template.go | 16 +-
internal/text/emojify.go | 6 +-
internal/transport/deliver.go | 4 +-
internal/transport/dereference.go | 4 +-
internal/transport/derefinstance.go | 8 +-
internal/transport/finger.go | 4 +-
internal/typeutils/converter.go | 38 +-
internal/typeutils/frontendtointernal.go | 14 +-
internal/typeutils/internaltofrontend.go | 142 +++---
internal/typeutils/internaltorss.go | 4 +-
internal/web/assets.go | 21 +-
internal/web/base.go | 4 +-
internal/web/confirmemail.go | 8 +-
internal/web/customcss.go | 14 +-
internal/web/profile.go | 22 +-
internal/web/robots.go | 42 +-
internal/web/rss.go | 18 +-
internal/web/settings-panel.go | 4 +-
internal/web/thread.go | 26 +-
internal/web/web.go | 101 ++---
333 files changed, 15163 insertions(+), 14965 deletions(-)
create mode 100644 internal/api/activitypub.go
create mode 100644 internal/api/activitypub/emoji/emoji.go
create mode 100644 internal/api/activitypub/emoji/emojiget.go
create mode 100644 internal/api/activitypub/emoji/emojiget_test.go
create mode 100644 internal/api/activitypub/users/common.go
create mode 100644 internal/api/activitypub/users/followers.go
create mode 100644 internal/api/activitypub/users/following.go
create mode 100644 internal/api/activitypub/users/inboxpost.go
create mode 100644 internal/api/activitypub/users/inboxpost_test.go
create mode 100644 internal/api/activitypub/users/outboxget.go
create mode 100644 internal/api/activitypub/users/outboxget_test.go
create mode 100644 internal/api/activitypub/users/publickeyget.go
create mode 100644 internal/api/activitypub/users/repliesget.go
create mode 100644 internal/api/activitypub/users/repliesget_test.go
create mode 100644 internal/api/activitypub/users/statusget.go
create mode 100644 internal/api/activitypub/users/statusget_test.go
create mode 100644 internal/api/activitypub/users/user.go
create mode 100644 internal/api/activitypub/users/user_test.go
create mode 100644 internal/api/activitypub/users/userget.go
create mode 100644 internal/api/activitypub/users/userget_test.go
delete mode 100644 internal/api/apimodule.go
create mode 100644 internal/api/auth.go
create mode 100644 internal/api/auth/auth.go
create mode 100644 internal/api/auth/auth_test.go
create mode 100644 internal/api/auth/authorize.go
create mode 100644 internal/api/auth/authorize_test.go
create mode 100644 internal/api/auth/callback.go
create mode 100644 internal/api/auth/oob.go
create mode 100644 internal/api/auth/signin.go
create mode 100644 internal/api/auth/token.go
create mode 100644 internal/api/auth/token_test.go
create mode 100644 internal/api/client.go
delete mode 100644 internal/api/client/account/account.go
delete mode 100644 internal/api/client/account/account_test.go
delete mode 100644 internal/api/client/account/accountcreate.go
delete mode 100644 internal/api/client/account/accountcreate_test.go
delete mode 100644 internal/api/client/account/accountdelete.go
delete mode 100644 internal/api/client/account/accountdelete_test.go
delete mode 100644 internal/api/client/account/accountget.go
delete mode 100644 internal/api/client/account/accountupdate.go
delete mode 100644 internal/api/client/account/accountupdate_test.go
delete mode 100644 internal/api/client/account/accountverify.go
delete mode 100644 internal/api/client/account/accountverify_test.go
delete mode 100644 internal/api/client/account/block.go
delete mode 100644 internal/api/client/account/block_test.go
delete mode 100644 internal/api/client/account/follow.go
delete mode 100644 internal/api/client/account/follow_test.go
delete mode 100644 internal/api/client/account/followers.go
delete mode 100644 internal/api/client/account/following.go
delete mode 100644 internal/api/client/account/relationships.go
delete mode 100644 internal/api/client/account/statuses.go
delete mode 100644 internal/api/client/account/statuses_test.go
delete mode 100644 internal/api/client/account/unblock.go
delete mode 100644 internal/api/client/account/unfollow.go
create mode 100644 internal/api/client/accounts/account_test.go
create mode 100644 internal/api/client/accounts/accountcreate.go
create mode 100644 internal/api/client/accounts/accountcreate_test.go
create mode 100644 internal/api/client/accounts/accountdelete.go
create mode 100644 internal/api/client/accounts/accountdelete_test.go
create mode 100644 internal/api/client/accounts/accountget.go
create mode 100644 internal/api/client/accounts/accounts.go
create mode 100644 internal/api/client/accounts/accountupdate.go
create mode 100644 internal/api/client/accounts/accountupdate_test.go
create mode 100644 internal/api/client/accounts/accountverify.go
create mode 100644 internal/api/client/accounts/accountverify_test.go
create mode 100644 internal/api/client/accounts/block.go
create mode 100644 internal/api/client/accounts/block_test.go
create mode 100644 internal/api/client/accounts/follow.go
create mode 100644 internal/api/client/accounts/follow_test.go
create mode 100644 internal/api/client/accounts/followers.go
create mode 100644 internal/api/client/accounts/following.go
create mode 100644 internal/api/client/accounts/relationships.go
create mode 100644 internal/api/client/accounts/statuses.go
create mode 100644 internal/api/client/accounts/statuses_test.go
create mode 100644 internal/api/client/accounts/unblock.go
create mode 100644 internal/api/client/accounts/unfollow.go
delete mode 100644 internal/api/client/app/app.go
delete mode 100644 internal/api/client/app/app_test.go
delete mode 100644 internal/api/client/app/appcreate.go
create mode 100644 internal/api/client/apps/appcreate.go
create mode 100644 internal/api/client/apps/apps.go
delete mode 100644 internal/api/client/auth/auth.go
delete mode 100644 internal/api/client/auth/auth_test.go
delete mode 100644 internal/api/client/auth/authorize.go
delete mode 100644 internal/api/client/auth/authorize_test.go
delete mode 100644 internal/api/client/auth/callback.go
delete mode 100644 internal/api/client/auth/oob.go
delete mode 100644 internal/api/client/auth/signin.go
delete mode 100644 internal/api/client/auth/token.go
delete mode 100644 internal/api/client/auth/token_test.go
delete mode 100644 internal/api/client/auth/util.go
create mode 100644 internal/api/client/customemojis/customemojis.go
create mode 100644 internal/api/client/customemojis/customemojisget.go
delete mode 100644 internal/api/client/emoji/emoji.go
delete mode 100644 internal/api/client/emoji/emojisget.go
delete mode 100644 internal/api/client/fileserver/fileserver.go
delete mode 100644 internal/api/client/fileserver/fileserver_test.go
delete mode 100644 internal/api/client/fileserver/servefile.go
delete mode 100644 internal/api/client/fileserver/servefile_test.go
delete mode 100644 internal/api/client/filter/filter.go
delete mode 100644 internal/api/client/filter/filtersget.go
create mode 100644 internal/api/client/filters/filter.go
create mode 100644 internal/api/client/filters/filtersget.go
delete mode 100644 internal/api/client/followrequest/authorize.go
delete mode 100644 internal/api/client/followrequest/authorize_test.go
delete mode 100644 internal/api/client/followrequest/followrequest.go
delete mode 100644 internal/api/client/followrequest/followrequest_test.go
delete mode 100644 internal/api/client/followrequest/get.go
delete mode 100644 internal/api/client/followrequest/get_test.go
delete mode 100644 internal/api/client/followrequest/reject.go
delete mode 100644 internal/api/client/followrequest/reject_test.go
create mode 100644 internal/api/client/followrequests/authorize.go
create mode 100644 internal/api/client/followrequests/authorize_test.go
create mode 100644 internal/api/client/followrequests/followrequest.go
create mode 100644 internal/api/client/followrequests/followrequest_test.go
create mode 100644 internal/api/client/followrequests/get.go
create mode 100644 internal/api/client/followrequests/get_test.go
create mode 100644 internal/api/client/followrequests/reject.go
create mode 100644 internal/api/client/followrequests/reject_test.go
delete mode 100644 internal/api/client/list/list.go
delete mode 100644 internal/api/client/list/listsgets.go
create mode 100644 internal/api/client/lists/list.go
create mode 100644 internal/api/client/lists/listsgets.go
delete mode 100644 internal/api/client/notification/notification.go
delete mode 100644 internal/api/client/notification/notificationsclear.go
delete mode 100644 internal/api/client/notification/notificationsget.go
create mode 100644 internal/api/client/notifications/notifications.go
create mode 100644 internal/api/client/notifications/notificationsclear.go
create mode 100644 internal/api/client/notifications/notificationsget.go
delete mode 100644 internal/api/client/status/status.go
delete mode 100644 internal/api/client/status/status_test.go
delete mode 100644 internal/api/client/status/statusbookmark.go
delete mode 100644 internal/api/client/status/statusbookmark_test.go
delete mode 100644 internal/api/client/status/statusboost.go
delete mode 100644 internal/api/client/status/statusboost_test.go
delete mode 100644 internal/api/client/status/statusboostedby.go
delete mode 100644 internal/api/client/status/statusboostedby_test.go
delete mode 100644 internal/api/client/status/statuscontext.go
delete mode 100644 internal/api/client/status/statuscreate.go
delete mode 100644 internal/api/client/status/statuscreate_test.go
delete mode 100644 internal/api/client/status/statusdelete.go
delete mode 100644 internal/api/client/status/statusdelete_test.go
delete mode 100644 internal/api/client/status/statusfave.go
delete mode 100644 internal/api/client/status/statusfave_test.go
delete mode 100644 internal/api/client/status/statusfavedby.go
delete mode 100644 internal/api/client/status/statusfavedby_test.go
delete mode 100644 internal/api/client/status/statusget.go
delete mode 100644 internal/api/client/status/statusget_test.go
delete mode 100644 internal/api/client/status/statusunbookmark.go
delete mode 100644 internal/api/client/status/statusunbookmark_test.go
delete mode 100644 internal/api/client/status/statusunboost.go
delete mode 100644 internal/api/client/status/statusunfave.go
delete mode 100644 internal/api/client/status/statusunfave_test.go
create mode 100644 internal/api/client/statuses/status.go
create mode 100644 internal/api/client/statuses/status_test.go
create mode 100644 internal/api/client/statuses/statusbookmark.go
create mode 100644 internal/api/client/statuses/statusbookmark_test.go
create mode 100644 internal/api/client/statuses/statusboost.go
create mode 100644 internal/api/client/statuses/statusboost_test.go
create mode 100644 internal/api/client/statuses/statusboostedby.go
create mode 100644 internal/api/client/statuses/statusboostedby_test.go
create mode 100644 internal/api/client/statuses/statuscontext.go
create mode 100644 internal/api/client/statuses/statuscreate.go
create mode 100644 internal/api/client/statuses/statuscreate_test.go
create mode 100644 internal/api/client/statuses/statusdelete.go
create mode 100644 internal/api/client/statuses/statusdelete_test.go
create mode 100644 internal/api/client/statuses/statusfave.go
create mode 100644 internal/api/client/statuses/statusfave_test.go
create mode 100644 internal/api/client/statuses/statusfavedby.go
create mode 100644 internal/api/client/statuses/statusfavedby_test.go
create mode 100644 internal/api/client/statuses/statusget.go
create mode 100644 internal/api/client/statuses/statusget_test.go
create mode 100644 internal/api/client/statuses/statusunbookmark.go
create mode 100644 internal/api/client/statuses/statusunbookmark_test.go
create mode 100644 internal/api/client/statuses/statusunboost.go
create mode 100644 internal/api/client/statuses/statusunfave.go
create mode 100644 internal/api/client/statuses/statusunfave_test.go
delete mode 100644 internal/api/client/timeline/home.go
delete mode 100644 internal/api/client/timeline/public.go
delete mode 100644 internal/api/client/timeline/timeline.go
create mode 100644 internal/api/client/timelines/home.go
create mode 100644 internal/api/client/timelines/public.go
create mode 100644 internal/api/client/timelines/timeline.go
delete mode 100644 internal/api/errorhandling.go
create mode 100644 internal/api/fileserver.go
create mode 100644 internal/api/fileserver/fileserver.go
create mode 100644 internal/api/fileserver/fileserver_test.go
create mode 100644 internal/api/fileserver/servefile.go
create mode 100644 internal/api/fileserver/servefile_test.go
delete mode 100644 internal/api/mime.go
delete mode 100644 internal/api/negotiate.go
create mode 100644 internal/api/nodeinfo.go
create mode 100644 internal/api/nodeinfo/nodeinfo.go
create mode 100644 internal/api/nodeinfo/nodeinfoget.go
delete mode 100644 internal/api/s2s/emoji/emoji.go
delete mode 100644 internal/api/s2s/emoji/emojiget.go
delete mode 100644 internal/api/s2s/emoji/emojiget_test.go
delete mode 100644 internal/api/s2s/nodeinfo/nodeinfo.go
delete mode 100644 internal/api/s2s/nodeinfo/nodeinfoget.go
delete mode 100644 internal/api/s2s/nodeinfo/wellknownget.go
delete mode 100644 internal/api/s2s/user/common.go
delete mode 100644 internal/api/s2s/user/followers.go
delete mode 100644 internal/api/s2s/user/following.go
delete mode 100644 internal/api/s2s/user/inboxpost.go
delete mode 100644 internal/api/s2s/user/inboxpost_test.go
delete mode 100644 internal/api/s2s/user/outboxget.go
delete mode 100644 internal/api/s2s/user/outboxget_test.go
delete mode 100644 internal/api/s2s/user/publickeyget.go
delete mode 100644 internal/api/s2s/user/repliesget.go
delete mode 100644 internal/api/s2s/user/repliesget_test.go
delete mode 100644 internal/api/s2s/user/statusget.go
delete mode 100644 internal/api/s2s/user/statusget_test.go
delete mode 100644 internal/api/s2s/user/user.go
delete mode 100644 internal/api/s2s/user/user_test.go
delete mode 100644 internal/api/s2s/user/userget.go
delete mode 100644 internal/api/s2s/user/userget_test.go
delete mode 100644 internal/api/s2s/webfinger/webfinger.go
delete mode 100644 internal/api/s2s/webfinger/webfinger_test.go
delete mode 100644 internal/api/s2s/webfinger/webfingerget.go
delete mode 100644 internal/api/s2s/webfinger/webfingerget_test.go
delete mode 100644 internal/api/security/extraheaders.go
delete mode 100644 internal/api/security/flocblock.go
delete mode 100644 internal/api/security/ratelimit.go
delete mode 100644 internal/api/security/robots.go
delete mode 100644 internal/api/security/security.go
delete mode 100644 internal/api/security/signaturecheck.go
delete mode 100644 internal/api/security/tokencheck.go
delete mode 100644 internal/api/security/useragentblock.go
create mode 100644 internal/api/util/errorhandling.go
create mode 100644 internal/api/util/mime.go
create mode 100644 internal/api/util/negotiate.go
create mode 100644 internal/api/util/signaturectx.go
create mode 100644 internal/api/wellknown.go
create mode 100644 internal/api/wellknown/nodeinfo/nodeinfo.go
create mode 100644 internal/api/wellknown/nodeinfo/nodeinfoget.go
create mode 100644 internal/api/wellknown/webfinger/webfinger.go
create mode 100644 internal/api/wellknown/webfinger/webfinger_test.go
create mode 100644 internal/api/wellknown/webfinger/webfingerget.go
create mode 100644 internal/api/wellknown/webfinger/webfingerget_test.go
create mode 100644 internal/middleware/cachecontrol.go
create mode 100644 internal/middleware/cors.go
create mode 100644 internal/middleware/extraheaders.go
create mode 100644 internal/middleware/gzip.go
create mode 100644 internal/middleware/logger.go
create mode 100644 internal/middleware/ratelimit.go
create mode 100644 internal/middleware/session.go
create mode 100644 internal/middleware/session_test.go
create mode 100644 internal/middleware/signaturecheck.go
create mode 100644 internal/middleware/tokencheck.go
create mode 100644 internal/middleware/useragent.go
delete mode 100644 internal/router/cors.go
delete mode 100644 internal/router/gzip.go
delete mode 100644 internal/router/logger.go
delete mode 100644 internal/router/session.go
delete mode 100644 internal/router/session_test.go
(limited to 'internal')
diff --git a/internal/api/activitypub.go b/internal/api/activitypub.go
new file mode 100644
index 000000000..68c3b81e0
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..f0eab0f02
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..4e5ca25f4
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..5b06f7a33
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..bf7623774
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..69b9f52fd
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..64337d4cd
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..1d8f9f923
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..53b6b8aac
--- /dev/null
+++ 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 .
+*/
+
+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 := >smodel.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 := >smodel.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 := >smodel.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
new file mode 100644
index 000000000..8e5d1a751
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..cd0518350
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..d57aa2f9d
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..253166446
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..3d8e0b12a
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..a72f8fd16
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..37d311e7b
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..d2e02fb08
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..0f08366f0
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..51b0ebd72
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..511634fc7
--- /dev/null
+++ 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 .
+*/
+
+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
deleted file mode 100644
index 67dc3a850..000000000
--- a/internal/api/apimodule.go
+++ /dev/null
@@ -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 .
-*/
-
-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
new file mode 100644
index 000000000..472c6922a
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..7ce992466
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..cb92850d0
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..e504f6be2
--- /dev/null
+++ 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 .
+*/
+
+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 := >smodel.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
new file mode 100644
index 000000000..ff65d041b
--- /dev/null
+++ 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 := >smodel.User{}
+ account := >smodel.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
new file mode 100644
index 000000000..d344b5d5f
--- /dev/null
+++ 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 .
+*/
+
+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 := >smodel.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
new file mode 100644
index 000000000..97f9c0f8c
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..bae33a43b
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..17c4d8d8b
--- /dev/null
+++ 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 .
+*/
+
+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/auth/token_test.go b/internal/api/auth/token_test.go
new file mode 100644
index 000000000..50bbd6918
--- /dev/null
+++ b/internal/api/auth/token_test.go
@@ -0,0 +1,215 @@
+/*
+ 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 .
+*/
+
+package auth_test
+
+import (
+ "context"
+ "encoding/json"
+ "io/ioutil"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/suite"
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type TokenTestSuite struct {
+ AuthStandardTestSuite
+}
+
+func (suite *TokenTestSuite) TestPOSTTokenEmptyForm() {
+ ctx, recorder := suite.newContext(http.MethodPost, "oauth/token", []byte{}, "")
+ ctx.Request.Header.Set("accept", "application/json")
+
+ suite.authModule.TokenPOSTHandler(ctx)
+
+ suite.Equal(http.StatusBadRequest, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+
+ b, err := ioutil.ReadAll(result.Body)
+ suite.NoError(err)
+
+ suite.Equal(`{"error":"invalid_request","error_description":"Bad Request: grant_type was not set in the token request form, but must be set to authorization_code or client_credentials: client_id was not set in the token request form: client_secret was not set in the token request form: redirect_uri was not set in the token request form"}`, string(b))
+}
+
+func (suite *TokenTestSuite) TestRetrieveClientCredentialsOK() {
+ testClient := suite.testClients["local_account_1"]
+
+ requestBody, w, err := testrig.CreateMultipartFormData(
+ "", "",
+ map[string]string{
+ "grant_type": "client_credentials",
+ "client_id": testClient.ID,
+ "client_secret": testClient.Secret,
+ "redirect_uri": "http://localhost:8080",
+ })
+ if err != nil {
+ panic(err)
+ }
+ bodyBytes := requestBody.Bytes()
+
+ ctx, recorder := suite.newContext(http.MethodPost, "oauth/token", bodyBytes, w.FormDataContentType())
+ ctx.Request.Header.Set("accept", "application/json")
+
+ suite.authModule.TokenPOSTHandler(ctx)
+
+ suite.Equal(http.StatusOK, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+
+ b, err := ioutil.ReadAll(result.Body)
+ suite.NoError(err)
+
+ t := &apimodel.Token{}
+ err = json.Unmarshal(b, t)
+ suite.NoError(err)
+
+ suite.Equal("Bearer", t.TokenType)
+ suite.NotEmpty(t.AccessToken)
+ suite.NotEmpty(t.CreatedAt)
+ suite.WithinDuration(time.Now(), time.Unix(t.CreatedAt, 0), 1*time.Minute)
+
+ // there should be a token in the database now too
+ dbToken := >smodel.Token{}
+ err = suite.db.GetWhere(context.Background(), []db.Where{{Key: "access", Value: t.AccessToken}}, dbToken)
+ suite.NoError(err)
+ suite.NotNil(dbToken)
+}
+
+func (suite *TokenTestSuite) TestRetrieveAuthorizationCodeOK() {
+ testClient := suite.testClients["local_account_1"]
+ testUserAuthorizationToken := suite.testTokens["local_account_1_user_authorization_token"]
+
+ requestBody, w, err := testrig.CreateMultipartFormData(
+ "", "",
+ map[string]string{
+ "grant_type": "authorization_code",
+ "client_id": testClient.ID,
+ "client_secret": testClient.Secret,
+ "redirect_uri": "http://localhost:8080",
+ "code": testUserAuthorizationToken.Code,
+ })
+ if err != nil {
+ panic(err)
+ }
+ bodyBytes := requestBody.Bytes()
+
+ ctx, recorder := suite.newContext(http.MethodPost, "oauth/token", bodyBytes, w.FormDataContentType())
+ ctx.Request.Header.Set("accept", "application/json")
+
+ suite.authModule.TokenPOSTHandler(ctx)
+
+ suite.Equal(http.StatusOK, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+
+ b, err := ioutil.ReadAll(result.Body)
+ suite.NoError(err)
+
+ t := &apimodel.Token{}
+ err = json.Unmarshal(b, t)
+ suite.NoError(err)
+
+ suite.Equal("Bearer", t.TokenType)
+ suite.NotEmpty(t.AccessToken)
+ suite.NotEmpty(t.CreatedAt)
+ suite.WithinDuration(time.Now(), time.Unix(t.CreatedAt, 0), 1*time.Minute)
+
+ dbToken := >smodel.Token{}
+ err = suite.db.GetWhere(context.Background(), []db.Where{{Key: "access", Value: t.AccessToken}}, dbToken)
+ suite.NoError(err)
+ suite.NotNil(dbToken)
+}
+
+func (suite *TokenTestSuite) TestRetrieveAuthorizationCodeNoCode() {
+ testClient := suite.testClients["local_account_1"]
+
+ requestBody, w, err := testrig.CreateMultipartFormData(
+ "", "",
+ map[string]string{
+ "grant_type": "authorization_code",
+ "client_id": testClient.ID,
+ "client_secret": testClient.Secret,
+ "redirect_uri": "http://localhost:8080",
+ })
+ if err != nil {
+ panic(err)
+ }
+ bodyBytes := requestBody.Bytes()
+
+ ctx, recorder := suite.newContext(http.MethodPost, "oauth/token", bodyBytes, w.FormDataContentType())
+ ctx.Request.Header.Set("accept", "application/json")
+
+ suite.authModule.TokenPOSTHandler(ctx)
+
+ suite.Equal(http.StatusBadRequest, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+
+ b, err := ioutil.ReadAll(result.Body)
+ suite.NoError(err)
+
+ suite.Equal(`{"error":"invalid_request","error_description":"Bad Request: code was not set in the token request form, but must be set since grant_type is authorization_code"}`, string(b))
+}
+
+func (suite *TokenTestSuite) TestRetrieveAuthorizationCodeWrongGrantType() {
+ testClient := suite.testClients["local_account_1"]
+
+ requestBody, w, err := testrig.CreateMultipartFormData(
+ "", "",
+ map[string]string{
+ "grant_type": "client_credentials",
+ "client_id": testClient.ID,
+ "client_secret": testClient.Secret,
+ "redirect_uri": "http://localhost:8080",
+ "code": "peepeepoopoo",
+ })
+ if err != nil {
+ panic(err)
+ }
+ bodyBytes := requestBody.Bytes()
+
+ ctx, recorder := suite.newContext(http.MethodPost, "oauth/token", bodyBytes, w.FormDataContentType())
+ ctx.Request.Header.Set("accept", "application/json")
+
+ suite.authModule.TokenPOSTHandler(ctx)
+
+ suite.Equal(http.StatusBadRequest, recorder.Code)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+
+ b, err := ioutil.ReadAll(result.Body)
+ suite.NoError(err)
+
+ suite.Equal(`{"error":"invalid_request","error_description":"Bad Request: a code was provided in the token request form, but grant_type was not set to authorization_code"}`, string(b))
+}
+
+func TestTokenTestSuite(t *testing.T) {
+ suite.Run(t, &TokenTestSuite{})
+}
diff --git a/internal/api/client.go b/internal/api/client.go
new file mode 100644
index 000000000..7736a99f4
--- /dev/null
+++ 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 .
+*/
+
+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
deleted file mode 100644
index 4205baa2c..000000000
--- a/internal/api/client/account/account.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 90dbd6249..000000000
--- a/internal/api/client/account/account_test.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index e7b6c642d..000000000
--- a/internal/api/client/account/accountcreate.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index a4fc165bf..000000000
--- a/internal/api/client/account/accountcreate_test.go
+++ /dev/null
@@ -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 .
-// */
-
-package account_test
diff --git a/internal/api/client/account/accountdelete.go b/internal/api/client/account/accountdelete.go
deleted file mode 100644
index 53bdedd0f..000000000
--- a/internal/api/client/account/accountdelete.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 78348eabc..000000000
--- a/internal/api/client/account/accountdelete_test.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index c9aae5b2b..000000000
--- a/internal/api/client/account/accountget.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index f89259a96..000000000
--- a/internal/api/client/account/accountupdate.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 259bb69e9..000000000
--- a/internal/api/client/account/accountupdate_test.go
+++ /dev/null
@@ -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 .
-*/
-
-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("
this is my new bio read it and weep
", 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("this is my new bio read it and weep
", 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("this is my new bio read it and weep :rainbow:
", 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("this is my new bio read it and weep :rainbow:
", 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
deleted file mode 100644
index 916d0a322..000000000
--- a/internal/api/client/account/accountverify.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 886272865..000000000
--- a/internal/api/client/account/accountverify_test.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 9840c96ab..000000000
--- a/internal/api/client/account/block.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 9c75330aa..000000000
--- a/internal/api/client/account/block_test.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index cc523a7f8..000000000
--- a/internal/api/client/account/follow.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index fad67b185..000000000
--- a/internal/api/client/account/follow_test.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index cb2f4bfa6..000000000
--- a/internal/api/client/account/followers.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 3d69739c3..000000000
--- a/internal/api/client/account/following.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 56159d48e..000000000
--- a/internal/api/client/account/relationships.go
+++ /dev/null
@@ -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
deleted file mode 100644
index 7ecf3ba9f..000000000
--- a/internal/api/client/account/statuses.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 1f935896c..000000000
--- a/internal/api/client/account/statuses_test.go
+++ /dev/null
@@ -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 .
-*/
-
-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(`; rel="next", ; 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(`; rel="next", ; 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
deleted file mode 100644
index 451b7fd27..000000000
--- a/internal/api/client/account/unblock.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index fafba99fd..000000000
--- a/internal/api/client/account/unfollow.go
+++ /dev/null
@@ -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 .
-*/
-
-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
new file mode 100644
index 000000000..57d1e6c04
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..041ca7fc4
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..b2b8c715f
--- /dev/null
+++ 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 .
+// */
+
+package accounts_test
diff --git a/internal/api/client/accounts/accountdelete.go b/internal/api/client/accounts/accountdelete.go
new file mode 100644
index 000000000..f1b95e95a
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..31559d59a
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..1a6354490
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..54c6c5f22
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..5dbf0ce46
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..45a287ec8
--- /dev/null
+++ 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 .
+*/
+
+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("this is my new bio read it and weep
", 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("this is my new bio read it and weep
", 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("this is my new bio read it and weep :rainbow:
", 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("this is my new bio read it and weep :rainbow:
", 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
new file mode 100644
index 000000000..2b39d5ab2
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..e74c30aba
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..9e14ecb6e
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..474a53eb8
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..d2a8af886
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..fd15c3734
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..b464a5ad6
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..4589ad07a
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..60e7b517c
--- /dev/null
+++ 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
new file mode 100644
index 000000000..a04517feb
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..92ca9d925
--- /dev/null
+++ 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 .
+*/
+
+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(`; rel="next", ; 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(`; rel="next", ; 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
new file mode 100644
index 000000000..e0a0a978e
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..95c819903
--- /dev/null
+++ 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 .
+*/
+
+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
index 2dc84a2d0..d40404b15 100644
--- 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
index 569354c96..b4fb4d6d1 100644
--- 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
index 52c2630d9..ac3bbcb98 100644
--- 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
index 034ea8682..44410abe3 100644
--- 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
index 6f3684418..ddb07e6f6 100644
--- 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
index 3d27b585e..b9d365caa 100644
--- 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
index a4ab4ac1c..fea0ca35e 100644
--- 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
index d8b379674..e69506413 100644
--- 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
index 2a075708f..8368a12b0 100644
--- 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
index 14f3c70ff..b66116b6d 100644
--- 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
index 60f7d5948..49d586756 100644
--- 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
index 0b7cfe059..e8b3c0e49 100644
--- 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
index 695c6bcde..8402b30e9 100644
--- 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
index 157f35ab0..7f3fc11d5 100644
--- 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
index 9c8a30c1b..5618843e5 100644
--- 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
deleted file mode 100644
index 0bbeb6cc9..000000000
--- a/internal/api/client/app/app.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 5c1981ba1..000000000
--- a/internal/api/client/app/app_test.go
+++ /dev/null
@@ -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 .
-*/
-
-package app_test
-
-// TODO: write tests
diff --git a/internal/api/client/app/appcreate.go b/internal/api/client/app/appcreate.go
deleted file mode 100644
index 6060c9480..000000000
--- a/internal/api/client/app/appcreate.go
+++ /dev/null
@@ -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 .
-*/
-
-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
new file mode 100644
index 000000000..f381e9954
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..264a76f6f
--- /dev/null
+++ 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 .
+*/
+
+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
deleted file mode 100644
index 8a1d9d483..000000000
--- a/internal/api/client/auth/auth.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 75e958418..000000000
--- a/internal/api/client/auth/auth_test.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index f28d1dfc9..000000000
--- a/internal/api/client/auth/authorize.go
+++ /dev/null
@@ -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 .
-*/
-
-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 := >smodel.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
deleted file mode 100644
index 738b3b910..000000000
--- a/internal/api/client/auth/authorize_test.go
+++ /dev/null
@@ -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 := >smodel.User{}
- account := >smodel.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
deleted file mode 100644
index c97abf7aa..000000000
--- a/internal/api/client/auth/callback.go
+++ /dev/null
@@ -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 .
-*/
-
-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 := >smodel.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
deleted file mode 100644
index 92e49d328..000000000
--- a/internal/api/client/auth/oob.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 73a5de398..000000000
--- a/internal/api/client/auth/signin.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index fbbd08404..000000000
--- a/internal/api/client/auth/token.go
+++ /dev/null
@@ -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 .
-*/
-
-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/token_test.go b/internal/api/client/auth/token_test.go
deleted file mode 100644
index 50bbd6918..000000000
--- a/internal/api/client/auth/token_test.go
+++ /dev/null
@@ -1,215 +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 .
-*/
-
-package auth_test
-
-import (
- "context"
- "encoding/json"
- "io/ioutil"
- "net/http"
- "testing"
- "time"
-
- "github.com/stretchr/testify/suite"
- apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
- "github.com/superseriousbusiness/gotosocial/internal/db"
- "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
- "github.com/superseriousbusiness/gotosocial/testrig"
-)
-
-type TokenTestSuite struct {
- AuthStandardTestSuite
-}
-
-func (suite *TokenTestSuite) TestPOSTTokenEmptyForm() {
- ctx, recorder := suite.newContext(http.MethodPost, "oauth/token", []byte{}, "")
- ctx.Request.Header.Set("accept", "application/json")
-
- suite.authModule.TokenPOSTHandler(ctx)
-
- suite.Equal(http.StatusBadRequest, recorder.Code)
-
- result := recorder.Result()
- defer result.Body.Close()
-
- b, err := ioutil.ReadAll(result.Body)
- suite.NoError(err)
-
- suite.Equal(`{"error":"invalid_request","error_description":"Bad Request: grant_type was not set in the token request form, but must be set to authorization_code or client_credentials: client_id was not set in the token request form: client_secret was not set in the token request form: redirect_uri was not set in the token request form"}`, string(b))
-}
-
-func (suite *TokenTestSuite) TestRetrieveClientCredentialsOK() {
- testClient := suite.testClients["local_account_1"]
-
- requestBody, w, err := testrig.CreateMultipartFormData(
- "", "",
- map[string]string{
- "grant_type": "client_credentials",
- "client_id": testClient.ID,
- "client_secret": testClient.Secret,
- "redirect_uri": "http://localhost:8080",
- })
- if err != nil {
- panic(err)
- }
- bodyBytes := requestBody.Bytes()
-
- ctx, recorder := suite.newContext(http.MethodPost, "oauth/token", bodyBytes, w.FormDataContentType())
- ctx.Request.Header.Set("accept", "application/json")
-
- suite.authModule.TokenPOSTHandler(ctx)
-
- suite.Equal(http.StatusOK, recorder.Code)
-
- result := recorder.Result()
- defer result.Body.Close()
-
- b, err := ioutil.ReadAll(result.Body)
- suite.NoError(err)
-
- t := &apimodel.Token{}
- err = json.Unmarshal(b, t)
- suite.NoError(err)
-
- suite.Equal("Bearer", t.TokenType)
- suite.NotEmpty(t.AccessToken)
- suite.NotEmpty(t.CreatedAt)
- suite.WithinDuration(time.Now(), time.Unix(t.CreatedAt, 0), 1*time.Minute)
-
- // there should be a token in the database now too
- dbToken := >smodel.Token{}
- err = suite.db.GetWhere(context.Background(), []db.Where{{Key: "access", Value: t.AccessToken}}, dbToken)
- suite.NoError(err)
- suite.NotNil(dbToken)
-}
-
-func (suite *TokenTestSuite) TestRetrieveAuthorizationCodeOK() {
- testClient := suite.testClients["local_account_1"]
- testUserAuthorizationToken := suite.testTokens["local_account_1_user_authorization_token"]
-
- requestBody, w, err := testrig.CreateMultipartFormData(
- "", "",
- map[string]string{
- "grant_type": "authorization_code",
- "client_id": testClient.ID,
- "client_secret": testClient.Secret,
- "redirect_uri": "http://localhost:8080",
- "code": testUserAuthorizationToken.Code,
- })
- if err != nil {
- panic(err)
- }
- bodyBytes := requestBody.Bytes()
-
- ctx, recorder := suite.newContext(http.MethodPost, "oauth/token", bodyBytes, w.FormDataContentType())
- ctx.Request.Header.Set("accept", "application/json")
-
- suite.authModule.TokenPOSTHandler(ctx)
-
- suite.Equal(http.StatusOK, recorder.Code)
-
- result := recorder.Result()
- defer result.Body.Close()
-
- b, err := ioutil.ReadAll(result.Body)
- suite.NoError(err)
-
- t := &apimodel.Token{}
- err = json.Unmarshal(b, t)
- suite.NoError(err)
-
- suite.Equal("Bearer", t.TokenType)
- suite.NotEmpty(t.AccessToken)
- suite.NotEmpty(t.CreatedAt)
- suite.WithinDuration(time.Now(), time.Unix(t.CreatedAt, 0), 1*time.Minute)
-
- dbToken := >smodel.Token{}
- err = suite.db.GetWhere(context.Background(), []db.Where{{Key: "access", Value: t.AccessToken}}, dbToken)
- suite.NoError(err)
- suite.NotNil(dbToken)
-}
-
-func (suite *TokenTestSuite) TestRetrieveAuthorizationCodeNoCode() {
- testClient := suite.testClients["local_account_1"]
-
- requestBody, w, err := testrig.CreateMultipartFormData(
- "", "",
- map[string]string{
- "grant_type": "authorization_code",
- "client_id": testClient.ID,
- "client_secret": testClient.Secret,
- "redirect_uri": "http://localhost:8080",
- })
- if err != nil {
- panic(err)
- }
- bodyBytes := requestBody.Bytes()
-
- ctx, recorder := suite.newContext(http.MethodPost, "oauth/token", bodyBytes, w.FormDataContentType())
- ctx.Request.Header.Set("accept", "application/json")
-
- suite.authModule.TokenPOSTHandler(ctx)
-
- suite.Equal(http.StatusBadRequest, recorder.Code)
-
- result := recorder.Result()
- defer result.Body.Close()
-
- b, err := ioutil.ReadAll(result.Body)
- suite.NoError(err)
-
- suite.Equal(`{"error":"invalid_request","error_description":"Bad Request: code was not set in the token request form, but must be set since grant_type is authorization_code"}`, string(b))
-}
-
-func (suite *TokenTestSuite) TestRetrieveAuthorizationCodeWrongGrantType() {
- testClient := suite.testClients["local_account_1"]
-
- requestBody, w, err := testrig.CreateMultipartFormData(
- "", "",
- map[string]string{
- "grant_type": "client_credentials",
- "client_id": testClient.ID,
- "client_secret": testClient.Secret,
- "redirect_uri": "http://localhost:8080",
- "code": "peepeepoopoo",
- })
- if err != nil {
- panic(err)
- }
- bodyBytes := requestBody.Bytes()
-
- ctx, recorder := suite.newContext(http.MethodPost, "oauth/token", bodyBytes, w.FormDataContentType())
- ctx.Request.Header.Set("accept", "application/json")
-
- suite.authModule.TokenPOSTHandler(ctx)
-
- suite.Equal(http.StatusBadRequest, recorder.Code)
-
- result := recorder.Result()
- defer result.Body.Close()
-
- b, err := ioutil.ReadAll(result.Body)
- suite.NoError(err)
-
- suite.Equal(`{"error":"invalid_request","error_description":"Bad Request: a code was provided in the token request form, but grant_type was not set to authorization_code"}`, string(b))
-}
-
-func TestTokenTestSuite(t *testing.T) {
- suite.Run(t, &TokenTestSuite{})
-}
diff --git a/internal/api/client/auth/util.go b/internal/api/client/auth/util.go
deleted file mode 100644
index d59983c55..000000000
--- a/internal/api/client/auth/util.go
+++ /dev/null
@@ -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 .
-*/
-
-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
index 2211a8076..df2ee65bb 100644
--- 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
index 98f5ce6ea..290ea6617 100644
--- 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
index 492b7364c..d0273321c 100644
--- 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
index b4a4bdfb1..3bd12aee1 100644
--- 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
index dafc896ef..8f587f13d 100644
--- 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
new file mode 100644
index 000000000..ab89415d0
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..3428071d0
--- /dev/null
+++ 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 .
+*/
+
+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
deleted file mode 100644
index 871a12854..000000000
--- a/internal/api/client/emoji/emoji.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index d41e5e7df..000000000
--- a/internal/api/client/emoji/emojisget.go
+++ /dev/null
@@ -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
index f310d6873..5abc85a27 100644
--- 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
index c84da6b32..050b72536 100644
--- 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
index 5ff032b9a..9b6bb715e 100644
--- 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
deleted file mode 100644
index dcb54f986..000000000
--- a/internal/api/client/fileserver/fileserver.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index f1fab5672..000000000
--- a/internal/api/client/fileserver/fileserver_test.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index d2328a5fc..000000000
--- a/internal/api/client/fileserver/servefile.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 1ca0c60d6..000000000
--- a/internal/api/client/fileserver/servefile_test.go
+++ /dev/null
@@ -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 .
-*/
-
-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 := >smodel.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 := >smodel.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 := >smodel.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 := >smodel.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 := >smodel.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 := >smodel.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 := >smodel.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 := >smodel.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
deleted file mode 100644
index cf801e0a5..000000000
--- a/internal/api/client/filter/filter.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 8e0a0bb34..000000000
--- a/internal/api/client/filter/filtersget.go
+++ /dev/null
@@ -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
new file mode 100644
index 000000000..bdfd89ffe
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..71d6cac3e
--- /dev/null
+++ 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
deleted file mode 100644
index a5a392f76..000000000
--- a/internal/api/client/followrequest/authorize.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 693380d91..000000000
--- a/internal/api/client/followrequest/authorize_test.go
+++ /dev/null
@@ -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 .
-*/
-
-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 := >smodel.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
deleted file mode 100644
index a511d7226..000000000
--- a/internal/api/client/followrequest/followrequest.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index ca00ea054..000000000
--- a/internal/api/client/followrequest/followrequest_test.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 8a2be3686..000000000
--- a/internal/api/client/followrequest/get.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index c9b72a35b..000000000
--- a/internal/api/client/followrequest/get_test.go
+++ /dev/null
@@ -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 .
-*/
-
-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 := >smodel.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
deleted file mode 100644
index 717dbf4dd..000000000
--- a/internal/api/client/followrequest/reject.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 94c646ddc..000000000
--- a/internal/api/client/followrequest/reject_test.go
+++ /dev/null
@@ -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 .
-*/
-
-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 := >smodel.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
new file mode 100644
index 000000000..d30bb979f
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..048c462c7
--- /dev/null
+++ 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 .
+*/
+
+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 := >smodel.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
new file mode 100644
index 000000000..d9d241e63
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..c8036cd24
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..1153f0f4b
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..d4c9da0a1
--- /dev/null
+++ 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 .
+*/
+
+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 := >smodel.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
new file mode 100644
index 000000000..782f932cd
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..cea42829d
--- /dev/null
+++ 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 .
+*/
+
+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 := >smodel.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
index 16ff7c9f9..101e8cea4 100644
--- 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
index 26f29027d..33efbc847 100644
--- 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
index bcedf398b..dfb8330ff 100644
--- 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
index d4fa8ca5d..891ce8e38 100644
--- 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
index f7d05acdc..de6e40e7c 100644
--- 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
deleted file mode 100644
index c64ada43e..000000000
--- a/internal/api/client/list/list.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 246a1216a..000000000
--- a/internal/api/client/list/listsgets.go
+++ /dev/null
@@ -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
new file mode 100644
index 000000000..c14917b98
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..a4e5cbefa
--- /dev/null
+++ 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 .
+*/
+
+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
index 87cc2f091..889a4f3df 100644
--- 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
index db8b2ea56..7e29b2bb3 100644
--- 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
index 2f6fb12a4..9e787b4b9 100644
--- 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
index fd232c4c7..b22c8e79c 100644
--- 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
index 438eaca23..9cfd8a5f1 100644
--- 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
index e5abb0a91..bcf9a4dfe 100644
--- 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
deleted file mode 100644
index 6ade0b02f..000000000
--- a/internal/api/client/notification/notification.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index b97371638..000000000
--- a/internal/api/client/notification/notificationsclear.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index d6b3f5162..000000000
--- a/internal/api/client/notification/notificationsget.go
+++ /dev/null
@@ -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 .
-*/
-
-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:
-//
-// ```
-// ; rel="next", ; 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
new file mode 100644
index 000000000..235f0a678
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..48c074504
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..09000d02a
--- /dev/null
+++ 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 .
+*/
+
+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:
+//
+// ```
+// ; rel="next", ; 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
index 71370a6d5..bebe0bd61 100644
--- 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
index 11b5b80b2..3cb5e8377 100644
--- 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
index 7026213ac..15786e6e3 100644
--- 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
deleted file mode 100644
index dc32ae9b5..000000000
--- a/internal/api/client/status/status.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 7c3f094f2..000000000
--- a/internal/api/client/status/status_test.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 983becd72..000000000
--- a/internal/api/client/status/statusbookmark.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index d3da4f297..000000000
--- a/internal/api/client/status/statusbookmark_test.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index d43bedd6c..000000000
--- a/internal/api/client/status/statusboost.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 5b4b1b3cd..000000000
--- a/internal/api/client/status/statusboost_test.go
+++ /dev/null
@@ -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 .
-*/
-
-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, >smodel.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
deleted file mode 100644
index 4a175f6e9..000000000
--- a/internal/api/client/status/statusboostedby.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 0d7c9f7ab..000000000
--- a/internal/api/client/status/statusboostedby_test.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 632a151d5..000000000
--- a/internal/api/client/status/statuscontext.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index c1427411d..000000000
--- a/internal/api/client/status/statuscreate.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index c143489f3..000000000
--- a/internal/api/client/status/statuscreate_test.go
+++ /dev/null
@@ -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 .
-*/
-
-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
"
- statusMarkdownExpected = "Title
Smaller title
This is a post written in markdown
"
-)
-
-// 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("this is a brand new status! #helloworld
", 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 := >smodel.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(`hello @brand_new_person
`, 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("#test alright, should be able to post #links with fragments in them now, let's see........
docs.gotosocial.org/en/latest/user_guide/posts/#links
#gotosocial
(tobi remember to pull the docker image challenge)
", 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("here is a rainbow emoji a few times! :rainbow: :rainbow: :rainbow:
here's an emoji that isn't in the db: :test_emoji:
", 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("hello @%s this reply should work!
", 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("here's an image attachment
", 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
deleted file mode 100644
index b37dd5f14..000000000
--- a/internal/api/client/status/statusdelete.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index f97a13eec..000000000
--- a/internal/api/client/status/statusdelete_test.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 3117e7ef2..000000000
--- a/internal/api/client/status/statusfave.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index da5d2a48a..000000000
--- a/internal/api/client/status/statusfave_test.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 20ef86ded..000000000
--- a/internal/api/client/status/statusfavedby.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index e704fa724..000000000
--- a/internal/api/client/status/statusfavedby_test.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index a0d0e913c..000000000
--- a/internal/api/client/status/statusget.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index d11c9b587..000000000
--- a/internal/api/client/status/statusget_test.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index aa090f8c9..000000000
--- a/internal/api/client/status/statusunbookmark.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 09a18ab9b..000000000
--- a/internal/api/client/status/statusunbookmark_test.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 45a8e0ece..000000000
--- a/internal/api/client/status/statusunboost.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 19d3da3bd..000000000
--- a/internal/api/client/status/statusunfave.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index b8448d657..000000000
--- a/internal/api/client/status/statusunfave_test.go
+++ /dev/null
@@ -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 .
-*/
-
-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
new file mode 100644
index 000000000..7f58e8c9d
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..0bf824fdb
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..4efa53528
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..ba2de78e1
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..c8921b1b6
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..13ca2acf2
--- /dev/null
+++ 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 .
+*/
+
+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, >smodel.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
new file mode 100644
index 000000000..dc1567dba
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..576dee369
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..9a6ac9f7f
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..d36c93e77
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..3648d7520
--- /dev/null
+++ 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 .
+*/
+
+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
"
+ statusMarkdownExpected = "Title
Smaller title
This is a post written in markdown
"
+)
+
+// 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("this is a brand new status! #helloworld
", 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 := >smodel.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(`hello @brand_new_person
`, 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("#test alright, should be able to post #links with fragments in them now, let's see........
docs.gotosocial.org/en/latest/user_guide/posts/#links
#gotosocial
(tobi remember to pull the docker image challenge)
", 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("here is a rainbow emoji a few times! :rainbow: :rainbow: :rainbow:
here's an emoji that isn't in the db: :test_emoji:
", 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("hello @%s this reply should work!
", 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("here's an image attachment
", 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
new file mode 100644
index 000000000..3db7397db
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..9a9ceef8f
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..bd9ded147
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..20805d87c
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..aa0f1f8d6
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..fc04c490e
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..5e7a59027
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..e8e1fd8f4
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..117ef833b
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..9c4667ad8
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..e91081195
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..57ae88e1e
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..2ca3450a4
--- /dev/null
+++ 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 .
+*/
+
+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
index a9cb62732..de98719c2 100644
--- 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 .
+*/
+
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
index b15dfbdbd..f9d9fdf36 100644
--- 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
index 49c983fff..2f2d850c1 100644
--- 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
deleted file mode 100644
index e6135dd63..000000000
--- a/internal/api/client/timeline/home.go
+++ /dev/null
@@ -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 .
-*/
-
-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:
-//
-// ```
-// ; rel="next", ; 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
deleted file mode 100644
index fda23438b..000000000
--- a/internal/api/client/timeline/public.go
+++ /dev/null
@@ -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 .
-*/
-
-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:
-//
-// ```
-// ; rel="next", ; 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
deleted file mode 100644
index 3604a1fc2..000000000
--- a/internal/api/client/timeline/timeline.go
+++ /dev/null
@@ -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 .
-*/
-
-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
new file mode 100644
index 000000000..33af8fe5e
--- /dev/null
+++ 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 .
+*/
+
+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:
+//
+// ```
+// ; rel="next", ; 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
new file mode 100644
index 000000000..efe351a37
--- /dev/null
+++ 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 .
+*/
+
+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:
+//
+// ```
+// ; rel="next", ; 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
new file mode 100644
index 000000000..609e1855e
--- /dev/null
+++ 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 .
+*/
+
+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
index a900af897..c766d915c 100644
--- 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
index 86a0096e0..5e6002b40 100644
--- 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
index cc4fafca9..055b1f7a4 100644
--- 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
deleted file mode 100644
index f6fec4168..000000000
--- a/internal/api/errorhandling.go
+++ /dev/null
@@ -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 .
-*/
-
-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
new file mode 100644
index 000000000..8784a8663
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..bbfd7d200
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..f6de72a27
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..08ab3110b
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..efaf3896d
--- /dev/null
+++ 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 .
+*/
+
+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 := >smodel.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 := >smodel.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 := >smodel.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 := >smodel.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 := >smodel.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 := >smodel.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 := >smodel.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 := >smodel.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
deleted file mode 100644
index d02b64ab0..000000000
--- a/internal/api/mime.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index cf44f4328..000000000
--- a/internal/api/negotiate.go
+++ /dev/null
@@ -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 .
-*/
-
-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
new file mode 100644
index 000000000..906703434
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..aa6aec882
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..f2a3604eb
--- /dev/null
+++ 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 .
+*/
+
+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
deleted file mode 100644
index d448d2105..000000000
--- a/internal/api/s2s/emoji/emoji.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 28a737f9a..000000000
--- a/internal/api/s2s/emoji/emojiget.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 16bc9dc1a..000000000
--- a/internal/api/s2s/emoji/emojiget_test.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 539dcc2d1..000000000
--- a/internal/api/s2s/nodeinfo/nodeinfo.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 6cb5e1ebf..000000000
--- a/internal/api/s2s/nodeinfo/nodeinfoget.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index dc14e43a3..000000000
--- a/internal/api/s2s/nodeinfo/wellknownget.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 0a215ebbd..000000000
--- a/internal/api/s2s/user/common.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 675688311..000000000
--- a/internal/api/s2s/user/followers.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index d4404ea08..000000000
--- a/internal/api/s2s/user/following.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index fa6792cd2..000000000
--- a/internal/api/s2s/user/inboxpost.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 3821406be..000000000
--- a/internal/api/s2s/user/inboxpost_test.go
+++ /dev/null
@@ -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 .
-*/
-
-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 := >smodel.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 := >smodel.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 := >smodel.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
deleted file mode 100644
index 726f86237..000000000
--- a/internal/api/s2s/user/outboxget.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 35a048323..000000000
--- a/internal/api/s2s/user/outboxget_test.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 265c01ee5..000000000
--- a/internal/api/s2s/user/publickeyget.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index b3b20d0c2..000000000
--- a/internal/api/s2s/user/repliesget.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 5ab7f1ebd..000000000
--- a/internal/api/s2s/user/repliesget_test.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 3e3d6ea56..000000000
--- a/internal/api/s2s/user/statusget.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 42f7dbb1b..000000000
--- a/internal/api/s2s/user/statusget_test.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 1daa53ad8..000000000
--- a/internal/api/s2s/user/user.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 444e9cab5..000000000
--- a/internal/api/s2s/user/user_test.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 508c8be7d..000000000
--- a/internal/api/s2s/user/userget.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index c656911d7..000000000
--- a/internal/api/s2s/user/userget_test.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index c46ca7260..000000000
--- a/internal/api/s2s/webfinger/webfinger.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index e5d026d06..000000000
--- a/internal/api/s2s/webfinger/webfinger_test.go
+++ /dev/null
@@ -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 .
-*/
-
-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 := >smodel.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
deleted file mode 100644
index 9949140c1..000000000
--- a/internal/api/s2s/webfinger/webfingerget.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 3e91b8f6a..000000000
--- a/internal/api/s2s/webfinger/webfingerget_test.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index f66ac43b4..000000000
--- a/internal/api/security/extraheaders.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 0b61f4ef5..000000000
--- a/internal/api/security/flocblock.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 3c0d078c7..000000000
--- a/internal/api/security/ratelimit.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 5b8ba3c05..000000000
--- a/internal/api/security/robots.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 1dce111d3..000000000
--- a/internal/api/security/security.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 1c117cd1b..000000000
--- a/internal/api/security/signaturecheck.go
+++ /dev/null
@@ -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
deleted file mode 100644
index 9f2b7f36e..000000000
--- a/internal/api/security/tokencheck.go
+++ /dev/null
@@ -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 .
-*/
-
-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 := >smodel.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
deleted file mode 100644
index b117e8608..000000000
--- a/internal/api/security/useragentblock.go
+++ /dev/null
@@ -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 .
-*/
-
-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
new file mode 100644
index 000000000..97894dc36
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..396a6f9b3
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..2854ea09c
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..ec7d2c816
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..e4fe15f94
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..fd57ee08b
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..21b29ebd9
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..8d509b409
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..bb9f82ccb
--- /dev/null
+++ 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 .
+*/
+
+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 := >smodel.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
new file mode 100644
index 000000000..72d011734
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..70099b930
--- /dev/null
+++ 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 .
+*/
+
+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
index 4fd783611..6d589439a 100644
--- 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
index 8ec70d8b2..f07683cab 100644
--- 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
new file mode 100644
index 000000000..6e88daf1a
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..79b72185f
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..c7d4dd3e2
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..ddea62b63
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..7474b0fe0
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..ab947a124
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..bffbdcd92
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..d11d8c202
--- /dev/null
+++ 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 .
+*/
+
+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
new file mode 100644
index 000000000..c1f190eb5
--- /dev/null
+++ 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
new file mode 100644
index 000000000..ca93f379a
--- /dev/null
+++ 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 .
+*/
+
+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 := >smodel.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
new file mode 100644
index 000000000..e55a19434
--- /dev/null
+++ 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 .
+*/
+
+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
index 90aee81f4..24af6835d 100644
--- 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
index b7b05d0fa..951bda5c2 100644
--- 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
index c79baec3c..311ca4909 100644
--- 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
index c4aebe57d..770c411ab 100644
--- 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
index c4e06ea62..5f0670779 100644
--- 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
index 75cef43f5..a95c150fa 100644
--- 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
index 0a36bc336..285cb4d6a 100644
--- 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
index 3067c56b7..6d664f986 100644
--- 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
index 1573dd9ae..b1f7d6a3b 100644
--- 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: ""test"", // 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
index ca6e5063b..9a56f37b2 100644
--- 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
index 6f551d63d..c260aaea6 100644
--- 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
index 7c20b33d8..c65d88717 100644
--- 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
deleted file mode 100644
index c8ef040d8..000000000
--- a/internal/router/cors.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 4c5c99eee..000000000
--- a/internal/router/gzip.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 6eb271a84..000000000
--- a/internal/router/logger.go
+++ /dev/null
@@ -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 .
-*/
-
-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
index d66e5f9cd..dd3ff61d7 100644
--- 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
deleted file mode 100644
index eb4b83874..000000000
--- a/internal/router/session.go
+++ /dev/null
@@ -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 .
-*/
-
-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
deleted file mode 100644
index 5f4777a48..000000000
--- a/internal/router/session_test.go
+++ /dev/null
@@ -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 .
-*/
-
-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
index e87f6b69e..e2deb9ca8 100644
--- 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
index c9e25e5f9..673631be3 100644
--- 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
index 258fcb6c4..501e7cc92 100644
--- 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
index 589bb2f0b..988605bb9 100644
--- 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
index 5dd108f54..829bf6725 100644
--- 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
index 5523e479d..a69d31e42 100644
--- 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
index d797c3e0c..7e2753dab 100644
--- 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
index 716c41317..39d31d2dc 100644
--- 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
index d5b448e62..348d3c19a 100644
--- 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 := >smodel.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
index 609725d73..7557ca9ae 100644
--- 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
index aab4346eb..470bab752 100644
--- 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
index 51238afaf..c2fcdbe39 100644
--- 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
index 58f932bde..360e99e83 100644
--- 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
index 48f8c0f71..b12aee442 100644
--- 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
index 8a0368a3c..c562a6cff 100644
--- 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
index c3307d068..0babb31b7 100644
--- 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
index 827a19e87..27f4a34db 100644
--- 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
index f38d7522c..53e5a4f29 100644
--- 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
index 0cb6af6a6..d42b5929f 100644
--- 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
index b9e0a63ff..f263c4655 100644
--- 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) })
}
--
cgit v1.2.3