gtsocial-umbx

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

commit 32c5fd987a06e11b14a4247d13187657c14adedd
parent 71a49e2b43218d34f97b2276c43bdeb2df4a53d2
Author: Tobi Smethurst <31960611+tsmethurst@users.noreply.github.com>
Date:   Mon, 19 Apr 2021 19:42:19 +0200

Api/v1/statuses (#11)

This PR adds:
Statuses

    New status creation.
    View existing status
    Delete a status
    Fave a status
    Unfave a status
    See who's faved a status

Media

    Upload media attachment and store/retrieve it
    Upload custom emoji and store/retrieve it

Fileserver

    Serve files from storage

Testing

    Test models, testrig -- run a GTS test instance and play around with it.

Diffstat:
MPROGRESS.md | 19++++++++++---------
Mcmd/gotosocial/main.go | 114++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Minternal/apimodule/account/account.go | 62++++++++++++++++++++++++++++++++++++--------------------------
Minternal/apimodule/account/accountcreate.go | 6+++---
Minternal/apimodule/account/accountcreate_test.go | 59++++++++++++++++++++++++++++++++---------------------------
Minternal/apimodule/account/accountget.go | 6+++---
Minternal/apimodule/account/accountupdate.go | 33+++++++++++++++++----------------
Minternal/apimodule/account/accountupdate_test.go | 48++++++++++++++++++++++++++----------------------
Minternal/apimodule/account/accountverify.go | 2+-
Ainternal/apimodule/admin/admin.go | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/apimodule/admin/emojicreate.go | 130+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/apimodule/apimodule.go | 4++--
Minternal/apimodule/app/app.go | 25++++++++++++++-----------
Minternal/apimodule/app/appcreate.go | 14++++++++++----
Minternal/apimodule/auth/auth.go | 8++++----
Minternal/apimodule/auth/auth_test.go | 49+++++++++++++------------------------------------
Minternal/apimodule/auth/authorize.go | 10+++++-----
Minternal/apimodule/auth/middleware.go | 8++++----
Minternal/apimodule/auth/signin.go | 4++--
Minternal/apimodule/fileserver/fileserver.go | 56+++++++++++++++++++++++++++++++++++++-------------------
Ainternal/apimodule/fileserver/servefile.go | 243+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/apimodule/fileserver/test/servefile_test.go | 157+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/apimodule/media/media.go | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/apimodule/media/mediacreate.go | 192+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/apimodule/media/test/mediacreate_test.go | 194+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/apimodule/mock_ClientAPIModule.go | 16++++++++++++++++
Ainternal/apimodule/security/flocblock.go | 27+++++++++++++++++++++++++++
Ainternal/apimodule/security/security.go | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/apimodule/status/status.go | 138+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/apimodule/status/statuscreate.go | 463+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/apimodule/status/statusdelete.go | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/apimodule/status/statusfave.go | 136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/apimodule/status/statusfavedby.go | 128+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/apimodule/status/statusget.go | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/apimodule/status/statusunfave.go | 136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/apimodule/status/test/statuscreate_test.go | 346+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/apimodule/status/test/statusfave_test.go | 207+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/apimodule/status/test/statusfavedby_test.go | 159+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/apimodule/status/test/statusget_test.go | 168+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/apimodule/status/test/statusunfave_test.go | 219+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/config/config.go | 116++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Ainternal/config/default.go | 177+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/config/media.go | 4++++
Ainternal/config/statuses.go | 33+++++++++++++++++++++++++++++++++
Minternal/db/db.go | 138++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Rinternal/db/model/README.md -> internal/db/gtsmodel/README.md | 0
Ainternal/db/gtsmodel/account.go | 142+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/db/gtsmodel/activitystreams.go | 127+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/db/gtsmodel/application.go | 40++++++++++++++++++++++++++++++++++++++++
Ainternal/db/gtsmodel/block.go | 19+++++++++++++++++++
Ainternal/db/gtsmodel/domainblock.go | 47+++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/db/gtsmodel/emaildomainblock.go | 35+++++++++++++++++++++++++++++++++++
Ainternal/db/gtsmodel/emoji.go | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/db/gtsmodel/follow.go | 41+++++++++++++++++++++++++++++++++++++++++
Ainternal/db/gtsmodel/followrequest.go | 41+++++++++++++++++++++++++++++++++++++++++
Ainternal/db/gtsmodel/mediaattachment.go | 148+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/db/gtsmodel/mention.go | 39+++++++++++++++++++++++++++++++++++++++
Ainternal/db/gtsmodel/poll.go | 19+++++++++++++++++++
Ainternal/db/gtsmodel/status.go | 138+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/db/gtsmodel/statusbookmark.go | 35+++++++++++++++++++++++++++++++++++
Ainternal/db/gtsmodel/statusfave.go | 38++++++++++++++++++++++++++++++++++++++
Ainternal/db/gtsmodel/statusmute.go | 35+++++++++++++++++++++++++++++++++++
Ainternal/db/gtsmodel/statuspin.go | 33+++++++++++++++++++++++++++++++++
Ainternal/db/gtsmodel/tag.go | 41+++++++++++++++++++++++++++++++++++++++++
Ainternal/db/gtsmodel/user.go | 120+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/db/mock_DB.go | 185+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Dinternal/db/model/account.go | 164-------------------------------------------------------------------------------
Dinternal/db/model/application.go | 55-------------------------------------------------------
Dinternal/db/model/domainblock.go | 47-----------------------------------------------
Dinternal/db/model/emaildomainblock.go | 35-----------------------------------
Dinternal/db/model/follow.go | 41-----------------------------------------
Dinternal/db/model/followrequest.go | 41-----------------------------------------
Dinternal/db/model/mediaattachment.go | 136-------------------------------------------------------------------------------
Dinternal/db/model/status.go | 63---------------------------------------------------------------
Dinternal/db/model/user.go | 120-------------------------------------------------------------------------------
Minternal/db/pg.go | 712++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Minternal/distributor/distributor.go | 58+++++++++++++++++++++++++++++++++++-----------------------
Minternal/distributor/mock_Distributor.go | 38+++++++++++++++++++-------------------
Minternal/gotosocial/actions.go | 39+++++++++++++++++++++++++++++++++++----
Minternal/gotosocial/mock_Gotosocial.go | 14++++++++++++++
Ainternal/mastotypes/converter.go | 544+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rpkg/mastotypes/README.md -> internal/mastotypes/mastomodel/README.md | 0
Ainternal/mastotypes/mastomodel/account.go | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rpkg/mastotypes/activity.go -> internal/mastotypes/mastomodel/activity.go | 0
Rpkg/mastotypes/admin.go -> internal/mastotypes/mastomodel/admin.go | 0
Rpkg/mastotypes/announcement.go -> internal/mastotypes/mastomodel/announcement.go | 0
Rpkg/mastotypes/announcementreaction.go -> internal/mastotypes/mastomodel/announcementreaction.go | 0
Ainternal/mastotypes/mastomodel/application.go | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/mastotypes/mastomodel/attachment.go | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rpkg/mastotypes/card.go -> internal/mastotypes/mastomodel/card.go | 0
Rpkg/mastotypes/context.go -> internal/mastotypes/mastomodel/context.go | 0
Rpkg/mastotypes/conversation.go -> internal/mastotypes/mastomodel/conversation.go | 0
Ainternal/mastotypes/mastomodel/emoji.go | 48++++++++++++++++++++++++++++++++++++++++++++++++
Rpkg/mastotypes/error.go -> internal/mastotypes/mastomodel/error.go | 0
Rpkg/mastotypes/featuredtag.go -> internal/mastotypes/mastomodel/featuredtag.go | 0
Rpkg/mastotypes/field.go -> internal/mastotypes/mastomodel/field.go | 0
Rpkg/mastotypes/filter.go -> internal/mastotypes/mastomodel/filter.go | 0
Rpkg/mastotypes/history.go -> internal/mastotypes/mastomodel/history.go | 0
Rpkg/mastotypes/identityproof.go -> internal/mastotypes/mastomodel/identityproof.go | 0
Rpkg/mastotypes/instance.go -> internal/mastotypes/mastomodel/instance.go | 0
Rpkg/mastotypes/list.go -> internal/mastotypes/mastomodel/list.go | 0
Rpkg/mastotypes/marker.go -> internal/mastotypes/mastomodel/marker.go | 0
Rpkg/mastotypes/mention.go -> internal/mastotypes/mastomodel/mention.go | 0
Rpkg/mastotypes/notification.go -> internal/mastotypes/mastomodel/notification.go | 0
Rpkg/mastotypes/oauth.go -> internal/mastotypes/mastomodel/oauth.go | 0
Rpkg/mastotypes/poll.go -> internal/mastotypes/mastomodel/poll.go | 0
Rpkg/mastotypes/preferences.go -> internal/mastotypes/mastomodel/preferences.go | 0
Rpkg/mastotypes/pushsubscription.go -> internal/mastotypes/mastomodel/pushsubscription.go | 0
Rpkg/mastotypes/relationship.go -> internal/mastotypes/mastomodel/relationship.go | 0
Rpkg/mastotypes/results.go -> internal/mastotypes/mastomodel/results.go | 0
Rpkg/mastotypes/scheduledstatus.go -> internal/mastotypes/mastomodel/scheduledstatus.go | 0
Ainternal/mastotypes/mastomodel/source.go | 41+++++++++++++++++++++++++++++++++++++++++
Ainternal/mastotypes/mastomodel/status.go | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/mastotypes/mastomodel/tag.go | 27+++++++++++++++++++++++++++
Rpkg/mastotypes/token.go -> internal/mastotypes/mastomodel/token.go | 0
Ainternal/mastotypes/mock_Converter.go | 148+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/media/media.go | 338+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Minternal/media/media_test.go | 27++++++++++++++++++++-------
Minternal/media/mock_MediaHandler.go | 33++++++++++++++++++++++++++++-----
Ainternal/media/test/rainbow-original.png | 0
Ainternal/media/test/rainbow-static.png | 0
Minternal/media/util.go | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Minternal/media/util_test.go | 2+-
Minternal/oauth/server.go | 17++++++++++-------
Minternal/oauth/tokenstore.go | 16++++++++--------
Minternal/router/router.go | 12+++++++++++-
Minternal/storage/inmem.go | 24++++++++++++++++++++++++
Minternal/storage/local.go | 53+++++++++++++++++++++++++++++++++++++++++++++++++++--
Minternal/storage/mock_Storage.go | 37+++++++++++++++++++++++++++++++++++++
Minternal/storage/storage.go | 6++++++
Minternal/util/parse.go | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Ainternal/util/regexes.go | 36++++++++++++++++++++++++++++++++++++
Ainternal/util/status.go | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/util/status_test.go | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/util/validation.go | 10++++++++++
Dpkg/mastotypes/account.go | 131-------------------------------------------------------------------------------
Dpkg/mastotypes/application.go | 55-------------------------------------------------------
Dpkg/mastotypes/attachment.go | 96-------------------------------------------------------------------------------
Dpkg/mastotypes/emoji.go | 38--------------------------------------
Dpkg/mastotypes/source.go | 41-----------------------------------------
Dpkg/mastotypes/status.go | 110-------------------------------------------------------------------------------
Dpkg/mastotypes/tag.go | 23-----------------------
Mscripts/auth_flow.sh | 5++---
Atestrig/actions.go | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atestrig/config.go | 26++++++++++++++++++++++++++
Atestrig/db.go | 144+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atestrig/distributor.go | 25+++++++++++++++++++++++++
Atestrig/log.go | 28++++++++++++++++++++++++++++
Atestrig/mastoconverter.go | 29+++++++++++++++++++++++++++++
Atestrig/media/ohyou-original.jpeg | 0
Atestrig/media/ohyou-small.jpeg | 0
Atestrig/media/rainbow-original.png | 0
Atestrig/media/rainbow-static.png | 0
Atestrig/media/trent-original.gif | 0
Atestrig/media/trent-small.jpeg | 0
Atestrig/media/welcome-original.jpeg | 0
Atestrig/media/welcome-small.jpeg | 0
Atestrig/media/zork-original.jpeg | 0
Atestrig/media/zork-small.jpeg | 0
Atestrig/mediahandler.go | 31+++++++++++++++++++++++++++++++
Atestrig/oauthserver.go | 29+++++++++++++++++++++++++++++
Atestrig/router.go | 29+++++++++++++++++++++++++++++
Atestrig/storage.go | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atestrig/testmodels.go | 995+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atestrig/util.go | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
165 files changed, 10024 insertions(+), 1789 deletions(-)

diff --git a/PROGRESS.md b/PROGRESS.md @@ -69,14 +69,14 @@ * [ ] /api/v1/suggestions GET (Get suggested accounts to follow) * [ ] /api/v1/suggestions/:account_id DELETE (Delete a suggestion) * [ ] Statuses - * [ ] /api/v1/statuses POST (Create a new status) - * [ ] /api/v1/statuses/:id GET (View an existing status) - * [ ] /api/v1/statuses/:id DELETE (Delete a status) + * [x] /api/v1/statuses POST (Create a new status) + * [x] /api/v1/statuses/:id GET (View an existing status) + * [x] /api/v1/statuses/:id DELETE (Delete a status) * [ ] /api/v1/statuses/:id/context GET (View statuses above and below status ID) * [ ] /api/v1/statuses/:id/reblogged_by GET (See who has reblogged a status) - * [ ] /api/v1/statuses/:id/favourited_by GET (See who has faved a status) - * [ ] /api/v1/statuses/:id/favourite POST (Fave a status) - * [ ] /api/v1/statuses/:id/favourite POST (Unfave a status) + * [x] /api/v1/statuses/:id/favourited_by GET (See who has faved a status) + * [x] /api/v1/statuses/:id/favourite POST (Fave a status) + * [x] /api/v1/statuses/:id/unfavourite POST (Unfave a status) * [ ] /api/v1/statuses/:id/reblog POST (Reblog a status) * [ ] /api/v1/statuses/:id/unreblog POST (Undo a reblog) * [ ] /api/v1/statuses/:id/bookmark POST (Bookmark a status) @@ -86,7 +86,7 @@ * [ ] /api/v1/statuses/:id/pin POST (Pin a status to profile) * [ ] /api/v1/statuses/:id/unpin POST (Unpin a status from profile) * [ ] Media - * [ ] /api/v1/media POST (Upload a media attachment) + * [x] /api/v1/media POST (Upload a media attachment) * [ ] /api/v1/media/:id GET (Get a media attachment) * [ ] /api/v1/media/:id PUT (Update an attachment) * [ ] Polls @@ -144,6 +144,7 @@ * [ ] Custom Emojis * [ ] /api/v1/custom_emojis GET (Show this server's custom emoji) * [ ] Admin + * [x] /api/v1/admin/custom_emojis POST (Upload a custom emoji for instance-wide usage) * [ ] /api/v1/admin/accounts GET (View accounts filtered by criteria) * [ ] /api/v1/admin/accounts/:id GET (View admin level info about an account) * [ ] /api/v1/admin/accounts/:id/action POST (Perform an admin action on account) @@ -178,8 +179,8 @@ * [ ] Storage * [x] Internal/statuses/preferences etc * [x] Postgres interface - * [ ] Media storage - * [ ] Local storage interface + * [x] Media storage + * [x] Local storage interface * [ ] S3 storage interface * [ ] Cache * [ ] In-memory cache diff --git a/cmd/gotosocial/main.go b/cmd/gotosocial/main.go @@ -28,6 +28,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gotosocial" "github.com/superseriousbusiness/gotosocial/internal/log" + "github.com/superseriousbusiness/gotosocial/testrig" "github.com/urfave/cli/v2" ) @@ -35,6 +36,7 @@ import ( func main() { flagNames := config.GetFlagNames() envNames := config.GetEnvNames() + defaults := config.GetDefaults() app := &cli.App{ Usage: "a fediverse social media server", Flags: []cli.Flag{ @@ -42,32 +44,32 @@ func main() { &cli.StringFlag{ Name: flagNames.LogLevel, Usage: "Log level to run at: debug, info, warn, fatal", - Value: "info", - EnvVars: []string{"GTS_LOG_LEVEL"}, + Value: defaults.LogLevel, + EnvVars: []string{envNames.LogLevel}, }, &cli.StringFlag{ Name: flagNames.ApplicationName, Usage: "Name of the application, used in various places internally", - Value: "gotosocial", + Value: defaults.ApplicationName, EnvVars: []string{envNames.ApplicationName}, Hidden: true, }, &cli.StringFlag{ Name: flagNames.ConfigPath, Usage: "Path to a yaml file containing gotosocial configuration. Values set in this file will be overwritten by values set as env vars or arguments", - Value: "", + Value: defaults.ConfigPath, EnvVars: []string{envNames.ConfigPath}, }, &cli.StringFlag{ Name: flagNames.Host, Usage: "Hostname to use for the server (eg., example.org, gotosocial.whatever.com)", - Value: "localhost", + Value: defaults.Host, EnvVars: []string{envNames.Host}, }, &cli.StringFlag{ Name: flagNames.Protocol, Usage: "Protocol to use for the REST api of the server (only use http for debugging and tests!)", - Value: "https", + Value: defaults.Protocol, EnvVars: []string{envNames.Protocol}, }, @@ -75,36 +77,37 @@ func main() { &cli.StringFlag{ Name: flagNames.DbType, Usage: "Database type: eg., postgres", - Value: "postgres", + Value: defaults.DbType, EnvVars: []string{envNames.DbType}, }, &cli.StringFlag{ Name: flagNames.DbAddress, Usage: "Database ipv4 address or hostname", - Value: "localhost", + Value: defaults.DbAddress, EnvVars: []string{envNames.DbAddress}, }, &cli.IntFlag{ Name: flagNames.DbPort, Usage: "Database port", - Value: 5432, + Value: defaults.DbPort, EnvVars: []string{envNames.DbPort}, }, &cli.StringFlag{ Name: flagNames.DbUser, Usage: "Database username", - Value: "postgres", + Value: defaults.DbUser, EnvVars: []string{envNames.DbUser}, }, &cli.StringFlag{ Name: flagNames.DbPassword, Usage: "Database password", + Value: defaults.DbPassword, EnvVars: []string{envNames.DbPassword}, }, &cli.StringFlag{ Name: flagNames.DbDatabase, Usage: "Database name", - Value: "postgres", + Value: defaults.DbDatabase, EnvVars: []string{envNames.DbDatabase}, }, @@ -112,7 +115,7 @@ func main() { &cli.StringFlag{ Name: flagNames.TemplateBaseDir, Usage: "Basedir for html templating files for rendering pages and composing emails.", - Value: "./web/template/", + Value: defaults.TemplateBaseDir, EnvVars: []string{envNames.TemplateBaseDir}, }, @@ -120,61 +123,111 @@ func main() { &cli.BoolFlag{ Name: flagNames.AccountsOpenRegistration, Usage: "Allow anyone to submit an account signup request. If false, server will be invite-only.", - Value: true, + Value: defaults.AccountsOpenRegistration, EnvVars: []string{envNames.AccountsOpenRegistration}, }, &cli.BoolFlag{ - Name: flagNames.AccountsRequireApproval, + Name: flagNames.AccountsApprovalRequired, Usage: "Do account signups require approval by an admin or moderator before user can log in? If false, new registrations will be automatically approved.", - Value: true, - EnvVars: []string{envNames.AccountsRequireApproval}, + Value: defaults.AccountsRequireApproval, + EnvVars: []string{envNames.AccountsApprovalRequired}, + }, + &cli.BoolFlag{ + Name: flagNames.AccountsReasonRequired, + Usage: "Do new account signups require a reason to be submitted on registration?", + Value: defaults.AccountsReasonRequired, + EnvVars: []string{envNames.AccountsReasonRequired}, }, // MEDIA FLAGS &cli.IntFlag{ Name: flagNames.MediaMaxImageSize, Usage: "Max size of accepted images in bytes", - Value: 1048576, // 1mb + Value: defaults.MediaMaxImageSize, EnvVars: []string{envNames.MediaMaxImageSize}, }, &cli.IntFlag{ Name: flagNames.MediaMaxVideoSize, Usage: "Max size of accepted videos in bytes", - Value: 5242880, // 5mb + Value: defaults.MediaMaxVideoSize, EnvVars: []string{envNames.MediaMaxVideoSize}, }, + &cli.IntFlag{ + Name: flagNames.MediaMinDescriptionChars, + Usage: "Min required chars for an image description", + Value: defaults.MediaMinDescriptionChars, + EnvVars: []string{envNames.MediaMinDescriptionChars}, + }, + &cli.IntFlag{ + Name: flagNames.MediaMaxDescriptionChars, + Usage: "Max permitted chars for an image description", + Value: defaults.MediaMaxDescriptionChars, + EnvVars: []string{envNames.MediaMaxDescriptionChars}, + }, // STORAGE FLAGS &cli.StringFlag{ Name: flagNames.StorageBackend, Usage: "Storage backend to use for media attachments", - Value: "local", + Value: defaults.StorageBackend, EnvVars: []string{envNames.StorageBackend}, }, &cli.StringFlag{ Name: flagNames.StorageBasePath, - Usage: "Full path to an already-created directory where gts should store/retrieve media files", - Value: "/opt/gotosocial", + Usage: "Full path to an already-created directory where gts should store/retrieve media files. Subfolders will be created within this dir.", + Value: defaults.StorageBasePath, EnvVars: []string{envNames.StorageBasePath}, }, &cli.StringFlag{ Name: flagNames.StorageServeProtocol, Usage: "Protocol to use for serving media attachments (use https if storage is local)", - Value: "https", + Value: defaults.StorageServeProtocol, EnvVars: []string{envNames.StorageServeProtocol}, }, &cli.StringFlag{ Name: flagNames.StorageServeHost, Usage: "Hostname to serve media attachments from (use the same value as host if storage is local)", - Value: "localhost", + Value: defaults.StorageServeHost, EnvVars: []string{envNames.StorageServeHost}, }, &cli.StringFlag{ Name: flagNames.StorageServeBasePath, Usage: "Path to append to protocol and hostname to create the base path from which media files will be served (default will mostly be fine)", - Value: "/fileserver/media", + Value: defaults.StorageServeBasePath, EnvVars: []string{envNames.StorageServeBasePath}, }, + + // STATUSES FLAGS + &cli.IntFlag{ + Name: flagNames.StatusesMaxChars, + Usage: "Max permitted characters for posted statuses", + Value: defaults.StatusesMaxChars, + EnvVars: []string{envNames.StatusesMaxChars}, + }, + &cli.IntFlag{ + Name: flagNames.StatusesCWMaxChars, + Usage: "Max permitted characters for content/spoiler warnings on statuses", + Value: defaults.StatusesCWMaxChars, + EnvVars: []string{envNames.StatusesCWMaxChars}, + }, + &cli.IntFlag{ + Name: flagNames.StatusesPollMaxOptions, + Usage: "Max amount of options permitted on a poll", + Value: defaults.StatusesPollMaxOptions, + EnvVars: []string{envNames.StatusesPollMaxOptions}, + }, + &cli.IntFlag{ + Name: flagNames.StatusesPollOptionMaxChars, + Usage: "Max amount of characters for a poll option", + Value: defaults.StatusesPollOptionMaxChars, + EnvVars: []string{envNames.StatusesPollOptionMaxChars}, + }, + &cli.IntFlag{ + Name: flagNames.StatusesMaxMediaFiles, + Usage: "Maximum number of media files/attachments per status", + Value: defaults.StatusesMaxMediaFiles, + EnvVars: []string{envNames.StatusesMaxMediaFiles}, + }, }, Commands: []*cli.Command{ { @@ -203,6 +256,19 @@ func main() { }, }, }, + { + Name: "testrig", + Usage: "gotosocial testrig tasks", + Subcommands: []*cli.Command{ + { + Name: "start", + Usage: "start the gotosocial testrig", + Action: func(c *cli.Context) error { + return runAction(c, testrig.Run) + }, + }, + }, + }, }, } diff --git a/internal/apimodule/account/account.go b/internal/apimodule/account/account.go @@ -28,7 +28,9 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/apimodule" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/model" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/mastotypes" + "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/router" @@ -43,21 +45,23 @@ const ( ) type accountModule struct { - config *config.Config - db db.DB - oauthServer oauth.Server - mediaHandler media.MediaHandler - log *logrus.Logger + config *config.Config + db db.DB + oauthServer oauth.Server + mediaHandler media.MediaHandler + mastoConverter mastotypes.Converter + log *logrus.Logger } // New returns a new account module -func New(config *config.Config, db db.DB, oauthServer oauth.Server, mediaHandler media.MediaHandler, log *logrus.Logger) apimodule.ClientAPIModule { +func New(config *config.Config, db db.DB, oauthServer oauth.Server, mediaHandler media.MediaHandler, mastoConverter mastotypes.Converter, log *logrus.Logger) apimodule.ClientAPIModule { return &accountModule{ - config: config, - db: db, - oauthServer: oauthServer, - mediaHandler: mediaHandler, - log: log, + config: config, + db: db, + oauthServer: oauthServer, + mediaHandler: mediaHandler, + mastoConverter: mastoConverter, + log: log, } } @@ -65,19 +69,20 @@ func New(config *config.Config, db db.DB, oauthServer oauth.Server, mediaHandler func (m *accountModule) Route(r router.Router) error { r.AttachHandler(http.MethodPost, basePath, m.accountCreatePOSTHandler) r.AttachHandler(http.MethodGet, basePathWithID, m.muxHandler) + r.AttachHandler(http.MethodPatch, basePathWithID, m.muxHandler) return nil } func (m *accountModule) CreateTables(db db.DB) error { models := []interface{}{ - &model.User{}, - &model.Account{}, - &model.Follow{}, - &model.FollowRequest{}, - &model.Status{}, - &model.Application{}, - &model.EmailDomainBlock{}, - &model.MediaAttachment{}, + &gtsmodel.User{}, + &gtsmodel.Account{}, + &gtsmodel.Follow{}, + &gtsmodel.FollowRequest{}, + &gtsmodel.Status{}, + &gtsmodel.Application{}, + &gtsmodel.EmailDomainBlock{}, + &gtsmodel.MediaAttachment{}, } for _, m := range models { @@ -90,11 +95,16 @@ func (m *accountModule) CreateTables(db db.DB) error { func (m *accountModule) muxHandler(c *gin.Context) { ru := c.Request.RequestURI - if strings.HasPrefix(ru, verifyPath) { - m.accountVerifyGETHandler(c) - } else if strings.HasPrefix(ru, updateCredentialsPath) { - m.accountUpdateCredentialsPATCHHandler(c) - } else { - m.accountGETHandler(c) + switch c.Request.Method { + case http.MethodGet: + if strings.HasPrefix(ru, verifyPath) { + m.accountVerifyGETHandler(c) + } else { + m.accountGETHandler(c) + } + case http.MethodPatch: + if strings.HasPrefix(ru, updateCredentialsPath) { + m.accountUpdateCredentialsPATCHHandler(c) + } } } diff --git a/internal/apimodule/account/accountcreate.go b/internal/apimodule/account/accountcreate.go @@ -27,10 +27,10 @@ import ( "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/model" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" "github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/util" - "github.com/superseriousbusiness/gotosocial/pkg/mastotypes" "github.com/superseriousbusiness/oauth2/v4" ) @@ -83,7 +83,7 @@ func (m *accountModule) accountCreatePOSTHandler(c *gin.Context) { // accountCreate does the dirty work of making an account and user in the database. // It then returns a token to the caller, for use with the new account, as per the // spec here: https://docs.joinmastodon.org/methods/accounts/ -func (m *accountModule) accountCreate(form *mastotypes.AccountCreateRequest, signUpIP net.IP, token oauth2.TokenInfo, app *model.Application) (*mastotypes.Token, error) { +func (m *accountModule) accountCreate(form *mastotypes.AccountCreateRequest, signUpIP net.IP, token oauth2.TokenInfo, app *gtsmodel.Application) (*mastotypes.Token, error) { l := m.log.WithField("func", "accountCreate") // don't store a reason if we don't require one diff --git a/internal/apimodule/account/accountcreate_test.go b/internal/apimodule/account/accountcreate_test.go @@ -41,11 +41,13 @@ import ( "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/model" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/mastotypes" + mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" + "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/storage" - "github.com/superseriousbusiness/gotosocial/pkg/mastotypes" "github.com/superseriousbusiness/oauth2/v4" "github.com/superseriousbusiness/oauth2/v4/models" oauthmodels "github.com/superseriousbusiness/oauth2/v4/models" @@ -56,12 +58,13 @@ type AccountCreateTestSuite struct { suite.Suite config *config.Config log *logrus.Logger - testAccountLocal *model.Account - testApplication *model.Application + testAccountLocal *gtsmodel.Account + testApplication *gtsmodel.Application testToken oauth2.TokenInfo mockOauthServer *oauth.MockServer mockStorage *storage.MockStorage mediaHandler media.MediaHandler + mastoConverter mastotypes.Converter db db.DB accountModule *accountModule newUserFormHappyPath url.Values @@ -78,13 +81,13 @@ func (suite *AccountCreateTestSuite) SetupSuite() { log.SetLevel(logrus.TraceLevel) suite.log = log - suite.testAccountLocal = &model.Account{ + suite.testAccountLocal = &gtsmodel.Account{ ID: uuid.NewString(), Username: "test_user", } // can use this test application throughout - suite.testApplication = &model.Application{ + suite.testApplication = &gtsmodel.Application{ ID: "weeweeeeeeeeeeeeee", Name: "a test application", Website: "https://some-application-website.com", @@ -148,7 +151,7 @@ func (suite *AccountCreateTestSuite) SetupSuite() { userID := args.Get(2).(string) l.Infof("received userID %+v", userID) }).Return(&models.Token{ - Code: "we're authorized now!", + Access: "we're authorized now!", }, nil) suite.mockStorage = &storage.MockStorage{} @@ -158,8 +161,10 @@ func (suite *AccountCreateTestSuite) SetupSuite() { // set a media handler because some handlers (eg update credentials) need to upload media (new header/avatar) suite.mediaHandler = media.New(suite.config, suite.db, suite.mockStorage, log) + suite.mastoConverter = mastotypes.New(suite.config, suite.db) + // and finally here's the thing we're actually testing! - suite.accountModule = New(suite.config, suite.db, suite.mockOauthServer, suite.mediaHandler, suite.log).(*accountModule) + suite.accountModule = New(suite.config, suite.db, suite.mockOauthServer, suite.mediaHandler, suite.mastoConverter, suite.log).(*accountModule) } func (suite *AccountCreateTestSuite) TearDownSuite() { @@ -172,14 +177,14 @@ func (suite *AccountCreateTestSuite) TearDownSuite() { func (suite *AccountCreateTestSuite) SetupTest() { // create all the tables we might need in thie suite models := []interface{}{ - &model.User{}, - &model.Account{}, - &model.Follow{}, - &model.FollowRequest{}, - &model.Status{}, - &model.Application{}, - &model.EmailDomainBlock{}, - &model.MediaAttachment{}, + &gtsmodel.User{}, + &gtsmodel.Account{}, + &gtsmodel.Follow{}, + &gtsmodel.FollowRequest{}, + &gtsmodel.Status{}, + &gtsmodel.Application{}, + &gtsmodel.EmailDomainBlock{}, + &gtsmodel.MediaAttachment{}, } for _, m := range models { if err := suite.db.CreateTable(m); err != nil { @@ -210,14 +215,14 @@ func (suite *AccountCreateTestSuite) TearDownTest() { // remove all the tables we might have used so it's clear for the next test models := []interface{}{ - &model.User{}, - &model.Account{}, - &model.Follow{}, - &model.FollowRequest{}, - &model.Status{}, - &model.Application{}, - &model.EmailDomainBlock{}, - &model.MediaAttachment{}, + &gtsmodel.User{}, + &gtsmodel.Account{}, + &gtsmodel.Follow{}, + &gtsmodel.FollowRequest{}, + &gtsmodel.Status{}, + &gtsmodel.Application{}, + &gtsmodel.EmailDomainBlock{}, + &gtsmodel.MediaAttachment{}, } for _, m := range models { if err := suite.db.DropTable(m); err != nil { @@ -259,7 +264,7 @@ func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerSuccessful() { defer result.Body.Close() b, err := ioutil.ReadAll(result.Body) assert.NoError(suite.T(), err) - t := &mastotypes.Token{} + t := &mastomodel.Token{} err = json.Unmarshal(b, t) assert.NoError(suite.T(), err) assert.Equal(suite.T(), "we're authorized now!", t.AccessToken) @@ -267,7 +272,7 @@ func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerSuccessful() { // check new account // 1. we should be able to get the new account from the db - acct := &model.Account{} + acct := &gtsmodel.Account{} err = suite.db.GetWhere("username", "test_user", acct) assert.NoError(suite.T(), err) assert.NotNil(suite.T(), acct) @@ -288,7 +293,7 @@ func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerSuccessful() { // check new user // 1. we should be able to get the new user from the db - usr := &model.User{} + usr := &gtsmodel.User{} err = suite.db.GetWhere("unconfirmed_email", suite.newUserFormHappyPath.Get("email"), usr) assert.Nil(suite.T(), err) assert.NotNil(suite.T(), usr) diff --git a/internal/apimodule/account/accountget.go b/internal/apimodule/account/accountget.go @@ -23,7 +23,7 @@ import ( "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/model" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" ) // accountGetHandler serves the account information held by the server in response to a GET @@ -37,7 +37,7 @@ func (m *accountModule) accountGETHandler(c *gin.Context) { return } - targetAccount := &model.Account{} + targetAccount := &gtsmodel.Account{} if err := m.db.GetByID(targetAcctID, targetAccount); err != nil { if _, ok := err.(db.ErrNoEntries); ok { c.JSON(http.StatusNotFound, gin.H{"error": "Record not found"}) @@ -47,7 +47,7 @@ func (m *accountModule) accountGETHandler(c *gin.Context) { return } - acctInfo, err := m.db.AccountToMastoPublic(targetAccount) + acctInfo, err := m.mastoConverter.AccountToMastoPublic(targetAccount) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return diff --git a/internal/apimodule/account/accountupdate.go b/internal/apimodule/account/accountupdate.go @@ -27,10 +27,11 @@ import ( "net/http" "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/db/model" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" + "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/util" - "github.com/superseriousbusiness/gotosocial/pkg/mastotypes" ) // accountUpdateCredentialsPATCHHandler allows a user to modify their account/profile settings. @@ -67,7 +68,7 @@ func (m *accountModule) accountUpdateCredentialsPATCHHandler(c *gin.Context) { } if form.Discoverable != nil { - if err := m.db.UpdateOneByID(authed.Account.ID, "discoverable", *form.Discoverable, &model.Account{}); err != nil { + if err := m.db.UpdateOneByID(authed.Account.ID, "discoverable", *form.Discoverable, &gtsmodel.Account{}); err != nil { l.Debugf("error updating discoverable: %s", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -75,7 +76,7 @@ func (m *accountModule) accountUpdateCredentialsPATCHHandler(c *gin.Context) { } if form.Bot != nil { - if err := m.db.UpdateOneByID(authed.Account.ID, "bot", *form.Bot, &model.Account{}); err != nil { + if err := m.db.UpdateOneByID(authed.Account.ID, "bot", *form.Bot, &gtsmodel.Account{}); err != nil { l.Debugf("error updating bot: %s", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -87,7 +88,7 @@ func (m *accountModule) accountUpdateCredentialsPATCHHandler(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if err := m.db.UpdateOneByID(authed.Account.ID, "display_name", *form.DisplayName, &model.Account{}); err != nil { + if err := m.db.UpdateOneByID(authed.Account.ID, "display_name", *form.DisplayName, &gtsmodel.Account{}); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -98,7 +99,7 @@ func (m *accountModule) accountUpdateCredentialsPATCHHandler(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if err := m.db.UpdateOneByID(authed.Account.ID, "note", *form.Note, &model.Account{}); err != nil { + if err := m.db.UpdateOneByID(authed.Account.ID, "note", *form.Note, &gtsmodel.Account{}); err != nil { l.Debugf("error updating note: %s", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -126,7 +127,7 @@ func (m *accountModule) accountUpdateCredentialsPATCHHandler(c *gin.Context) { } if form.Locked != nil { - if err := m.db.UpdateOneByID(authed.Account.ID, "locked", *form.Locked, &model.Account{}); err != nil { + if err := m.db.UpdateOneByID(authed.Account.ID, "locked", *form.Locked, &gtsmodel.Account{}); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -138,14 +139,14 @@ func (m *accountModule) accountUpdateCredentialsPATCHHandler(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if err := m.db.UpdateOneByID(authed.Account.ID, "language", *form.Source.Language, &model.Account{}); err != nil { + if err := m.db.UpdateOneByID(authed.Account.ID, "language", *form.Source.Language, &gtsmodel.Account{}); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } } if form.Source.Sensitive != nil { - if err := m.db.UpdateOneByID(authed.Account.ID, "locked", *form.Locked, &model.Account{}); err != nil { + if err := m.db.UpdateOneByID(authed.Account.ID, "locked", *form.Locked, &gtsmodel.Account{}); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -156,7 +157,7 @@ func (m *accountModule) accountUpdateCredentialsPATCHHandler(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if err := m.db.UpdateOneByID(authed.Account.ID, "privacy", *form.Source.Privacy, &model.Account{}); err != nil { + if err := m.db.UpdateOneByID(authed.Account.ID, "privacy", *form.Source.Privacy, &gtsmodel.Account{}); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -168,14 +169,14 @@ func (m *accountModule) accountUpdateCredentialsPATCHHandler(c *gin.Context) { // } // fetch the account with all updated values set - updatedAccount := &model.Account{} + updatedAccount := &gtsmodel.Account{} if err := m.db.GetByID(authed.Account.ID, updatedAccount); err != nil { l.Debugf("could not fetch updated account %s: %s", authed.Account.ID, err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - acctSensitive, err := m.db.AccountToMastoSensitive(updatedAccount) + acctSensitive, err := m.mastoConverter.AccountToMastoSensitive(updatedAccount) if err != nil { l.Tracef("could not convert account into mastosensitive account: %s", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) @@ -195,7 +196,7 @@ func (m *accountModule) accountUpdateCredentialsPATCHHandler(c *gin.Context) { // UpdateAccountAvatar does the dirty work of checking the avatar part of an account update form, // parsing and checking the image, and doing the necessary updates in the database for this to become // the account's new avatar image. -func (m *accountModule) UpdateAccountAvatar(avatar *multipart.FileHeader, accountID string) (*model.MediaAttachment, error) { +func (m *accountModule) UpdateAccountAvatar(avatar *multipart.FileHeader, accountID string) (*gtsmodel.MediaAttachment, error) { var err error if int(avatar.Size) > m.config.MediaConfig.MaxImageSize { err = fmt.Errorf("avatar with size %d exceeded max image size of %d bytes", avatar.Size, m.config.MediaConfig.MaxImageSize) @@ -217,7 +218,7 @@ func (m *accountModule) UpdateAccountAvatar(avatar *multipart.FileHeader, accoun } // do the setting - avatarInfo, err := m.mediaHandler.SetHeaderOrAvatarForAccountID(buf.Bytes(), accountID, "avatar") + avatarInfo, err := m.mediaHandler.ProcessHeaderOrAvatar(buf.Bytes(), accountID, media.MediaAvatar) if err != nil { return nil, fmt.Errorf("error processing avatar: %s", err) } @@ -228,7 +229,7 @@ func (m *accountModule) UpdateAccountAvatar(avatar *multipart.FileHeader, accoun // UpdateAccountHeader does the dirty work of checking the header part of an account update form, // parsing and checking the image, and doing the necessary updates in the database for this to become // the account's new header image. -func (m *accountModule) UpdateAccountHeader(header *multipart.FileHeader, accountID string) (*model.MediaAttachment, error) { +func (m *accountModule) UpdateAccountHeader(header *multipart.FileHeader, accountID string) (*gtsmodel.MediaAttachment, error) { var err error if int(header.Size) > m.config.MediaConfig.MaxImageSize { err = fmt.Errorf("header with size %d exceeded max image size of %d bytes", header.Size, m.config.MediaConfig.MaxImageSize) @@ -250,7 +251,7 @@ func (m *accountModule) UpdateAccountHeader(header *multipart.FileHeader, accoun } // do the setting - headerInfo, err := m.mediaHandler.SetHeaderOrAvatarForAccountID(buf.Bytes(), accountID, "header") + headerInfo, err := m.mediaHandler.ProcessHeaderOrAvatar(buf.Bytes(), accountID, media.MediaHeader) if err != nil { return nil, fmt.Errorf("error processing header: %s", err) } diff --git a/internal/apimodule/account/accountupdate_test.go b/internal/apimodule/account/accountupdate_test.go @@ -39,7 +39,8 @@ import ( "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/model" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/mastotypes" "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/storage" @@ -52,12 +53,13 @@ type AccountUpdateTestSuite struct { suite.Suite config *config.Config log *logrus.Logger - testAccountLocal *model.Account - testApplication *model.Application + testAccountLocal *gtsmodel.Account + testApplication *gtsmodel.Application testToken oauth2.TokenInfo mockOauthServer *oauth.MockServer mockStorage *storage.MockStorage mediaHandler media.MediaHandler + mastoConverter mastotypes.Converter db db.DB accountModule *accountModule newUserFormHappyPath url.Values @@ -74,13 +76,13 @@ func (suite *AccountUpdateTestSuite) SetupSuite() { log.SetLevel(logrus.TraceLevel) suite.log = log - suite.testAccountLocal = &model.Account{ + suite.testAccountLocal = &gtsmodel.Account{ ID: uuid.NewString(), Username: "test_user", } // can use this test application throughout - suite.testApplication = &model.Application{ + suite.testApplication = &gtsmodel.Application{ ID: "weeweeeeeeeeeeeeee", Name: "a test application", Website: "https://some-application-website.com", @@ -154,8 +156,10 @@ func (suite *AccountUpdateTestSuite) SetupSuite() { // set a media handler because some handlers (eg update credentials) need to upload media (new header/avatar) suite.mediaHandler = media.New(suite.config, suite.db, suite.mockStorage, log) + suite.mastoConverter = mastotypes.New(suite.config, suite.db) + // and finally here's the thing we're actually testing! - suite.accountModule = New(suite.config, suite.db, suite.mockOauthServer, suite.mediaHandler, suite.log).(*accountModule) + suite.accountModule = New(suite.config, suite.db, suite.mockOauthServer, suite.mediaHandler, suite.mastoConverter, suite.log).(*accountModule) } func (suite *AccountUpdateTestSuite) TearDownSuite() { @@ -168,14 +172,14 @@ func (suite *AccountUpdateTestSuite) TearDownSuite() { func (suite *AccountUpdateTestSuite) SetupTest() { // create all the tables we might need in thie suite models := []interface{}{ - &model.User{}, - &model.Account{}, - &model.Follow{}, - &model.FollowRequest{}, - &model.Status{}, - &model.Application{}, - &model.EmailDomainBlock{}, - &model.MediaAttachment{}, + &gtsmodel.User{}, + &gtsmodel.Account{}, + &gtsmodel.Follow{}, + &gtsmodel.FollowRequest{}, + &gtsmodel.Status{}, + &gtsmodel.Application{}, + &gtsmodel.EmailDomainBlock{}, + &gtsmodel.MediaAttachment{}, } for _, m := range models { if err := suite.db.CreateTable(m); err != nil { @@ -206,14 +210,14 @@ func (suite *AccountUpdateTestSuite) TearDownTest() { // remove all the tables we might have used so it's clear for the next test models := []interface{}{ - &model.User{}, - &model.Account{}, - &model.Follow{}, - &model.FollowRequest{}, - &model.Status{}, - &model.Application{}, - &model.EmailDomainBlock{}, - &model.MediaAttachment{}, + &gtsmodel.User{}, + &gtsmodel.Account{}, + &gtsmodel.Follow{}, + &gtsmodel.FollowRequest{}, + &gtsmodel.Status{}, + &gtsmodel.Application{}, + &gtsmodel.EmailDomainBlock{}, + &gtsmodel.MediaAttachment{}, } for _, m := range models { if err := suite.db.DropTable(m); err != nil { diff --git a/internal/apimodule/account/accountverify.go b/internal/apimodule/account/accountverify.go @@ -38,7 +38,7 @@ func (m *accountModule) accountVerifyGETHandler(c *gin.Context) { } l.Tracef("retrieved account %+v, converting to mastosensitive...", authed.Account.ID) - acctSensitive, err := m.db.AccountToMastoSensitive(authed.Account) + acctSensitive, err := m.mastoConverter.AccountToMastoSensitive(authed.Account) if err != nil { l.Tracef("could not convert account into mastosensitive account: %s", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) diff --git a/internal/apimodule/admin/admin.go b/internal/apimodule/admin/admin.go @@ -0,0 +1,84 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package admin + +import ( + "fmt" + "net/http" + + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/apimodule" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/mastotypes" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/router" +) + +const ( + basePath = "/api/v1/admin" + emojiPath = basePath + "/custom_emojis" +) + +type adminModule struct { + config *config.Config + db db.DB + mediaHandler media.MediaHandler + mastoConverter mastotypes.Converter + log *logrus.Logger +} + +// New returns a new account module +func New(config *config.Config, db db.DB, mediaHandler media.MediaHandler, mastoConverter mastotypes.Converter, log *logrus.Logger) apimodule.ClientAPIModule { + return &adminModule{ + config: config, + db: db, + mediaHandler: mediaHandler, + mastoConverter: mastoConverter, + log: log, + } +} + +// Route attaches all routes from this module to the given router +func (m *adminModule) Route(r router.Router) error { + r.AttachHandler(http.MethodPost, emojiPath, m.emojiCreatePOSTHandler) + return nil +} + +func (m *adminModule) CreateTables(db db.DB) error { + models := []interface{}{ + &gtsmodel.User{}, + &gtsmodel.Account{}, + &gtsmodel.Follow{}, + &gtsmodel.FollowRequest{}, + &gtsmodel.Status{}, + &gtsmodel.Application{}, + &gtsmodel.EmailDomainBlock{}, + &gtsmodel.MediaAttachment{}, + &gtsmodel.Emoji{}, + } + + for _, m := range models { + if err := db.CreateTable(m); err != nil { + return fmt.Errorf("error creating table: %s", err) + } + } + return nil +} diff --git a/internal/apimodule/admin/emojicreate.go b/internal/apimodule/admin/emojicreate.go @@ -0,0 +1,130 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package admin + +import ( + "bytes" + "errors" + "fmt" + "io" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +func (m *adminModule) emojiCreatePOSTHandler(c *gin.Context) { + l := m.log.WithFields(logrus.Fields{ + "func": "emojiCreatePOSTHandler", + "request_uri": c.Request.RequestURI, + "user_agent": c.Request.UserAgent(), + "origin_ip": c.ClientIP(), + }) + + // make sure we're authed with an admin account + authed, err := oauth.MustAuth(c, true, true, true, true) // posting a status is serious business so we want *everything* + if err != nil { + l.Debugf("couldn't auth: %s", err) + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + if !authed.User.Admin { + l.Debugf("user %s not an admin", authed.User.ID) + c.JSON(http.StatusForbidden, gin.H{"error": "not an admin"}) + return + } + + // extract the media create form from the request context + l.Tracef("parsing request form: %+v", c.Request.Form) + form := &mastotypes.EmojiCreateRequest{} + if err := c.ShouldBind(form); err != nil { + l.Debugf("error parsing form %+v: %s", c.Request.Form, err) + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not parse form: %s", err)}) + return + } + + // Give the fields on the request form a first pass to make sure the request is superficially valid. + l.Tracef("validating form %+v", form) + if err := validateCreateEmoji(form); err != nil { + l.Debugf("error validating form: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // open the emoji and extract the bytes from it + f, err := form.Image.Open() + if err != nil { + l.Debugf("error opening emoji: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not open provided emoji: %s", err)}) + return + } + buf := new(bytes.Buffer) + size, err := io.Copy(buf, f) + if err != nil { + l.Debugf("error reading emoji: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not read provided emoji: %s", err)}) + return + } + if size == 0 { + l.Debug("could not read provided emoji: size 0 bytes") + c.JSON(http.StatusBadRequest, gin.H{"error": "could not read provided emoji: size 0 bytes"}) + return + } + + // allow the mediaHandler to work its magic of processing the emoji bytes, and putting them in whatever storage backend we're using + emoji, err := m.mediaHandler.ProcessLocalEmoji(buf.Bytes(), form.Shortcode) + if err != nil { + l.Debugf("error reading emoji: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not process emoji: %s", err)}) + return + } + + mastoEmoji, err := m.mastoConverter.EmojiToMasto(emoji) + if err != nil { + l.Debugf("error converting emoji to mastotype: %s", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("could not convert emoji: %s", err)}) + return + } + + if err := m.db.Put(emoji); err != nil { + l.Debugf("database error while processing emoji: %s", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("database error while processing emoji: %s", err)}) + return + } + + c.JSON(http.StatusOK, mastoEmoji) +} + +func validateCreateEmoji(form *mastotypes.EmojiCreateRequest) error { + // check there actually is an image attached and it's not size 0 + if form.Image == nil || form.Image.Size == 0 { + return errors.New("no emoji given") + } + + // a very superficial check to see if the media size limit is exceeded + if form.Image.Size > media.EmojiMaxBytes { + return fmt.Errorf("file size limit exceeded: limit is %d bytes but emoji was %d bytes", media.EmojiMaxBytes, form.Image.Size) + } + + return util.ValidateEmojiShortcode(form.Shortcode) +} diff --git a/internal/apimodule/apimodule.go b/internal/apimodule/apimodule.go @@ -25,8 +25,8 @@ import ( ) // ClientAPIModule represents a chunk of code (usually contained in a single package) that adds a set -// of functionalities and side effects to a router, by mapping routes and handlers onto it--in other words, a REST API ;) -// A ClientAPIMpdule corresponds roughly to one main path of the gotosocial REST api, for example /api/v1/accounts/ or /oauth/ +// of functionalities and/or side effects to a router, by mapping routes and/or middlewares onto it--in other words, a REST API ;) +// A ClientAPIMpdule with routes corresponds roughly to one main path of the gotosocial REST api, for example /api/v1/accounts/ or /oauth/ type ClientAPIModule interface { Route(s router.Router) error CreateTables(db db.DB) error diff --git a/internal/apimodule/app/app.go b/internal/apimodule/app/app.go @@ -25,7 +25,8 @@ import ( "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/apimodule" "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/model" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/mastotypes" "github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/router" ) @@ -33,17 +34,19 @@ import ( const appsPath = "/api/v1/apps" type appModule struct { - server oauth.Server - db db.DB - log *logrus.Logger + server oauth.Server + db db.DB + mastoConverter mastotypes.Converter + log *logrus.Logger } // New returns a new auth module -func New(srv oauth.Server, db db.DB, log *logrus.Logger) apimodule.ClientAPIModule { +func New(srv oauth.Server, db db.DB, mastoConverter mastotypes.Converter, log *logrus.Logger) apimodule.ClientAPIModule { return &appModule{ - server: srv, - db: db, - log: log, + server: srv, + db: db, + mastoConverter: mastoConverter, + log: log, } } @@ -57,9 +60,9 @@ func (m *appModule) CreateTables(db db.DB) error { models := []interface{}{ &oauth.Client{}, &oauth.Token{}, - &model.User{}, - &model.Account{}, - &model.Application{}, + &gtsmodel.User{}, + &gtsmodel.Account{}, + &gtsmodel.Application{}, } for _, m := range models { diff --git a/internal/apimodule/app/appcreate.go b/internal/apimodule/app/appcreate.go @@ -24,9 +24,9 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" - "github.com/superseriousbusiness/gotosocial/internal/db/model" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/pkg/mastotypes" ) // appsPOSTHandler should be served at https://example.org/api/v1/apps @@ -78,7 +78,7 @@ func (m *appModule) appsPOSTHandler(c *gin.Context) { vapidKey := uuid.NewString() // generate the application to put in the database - app := &model.Application{ + app := &gtsmodel.Application{ Name: form.ClientName, Website: form.Website, RedirectURI: form.RedirectURIs, @@ -108,6 +108,12 @@ func (m *appModule) appsPOSTHandler(c *gin.Context) { return } + mastoApp, err := m.mastoConverter.AppToMastoSensitive(app) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + // done, return the new app information per the spec here: https://docs.joinmastodon.org/methods/apps/ - c.JSON(http.StatusOK, app.ToMasto()) + c.JSON(http.StatusOK, mastoApp) } diff --git a/internal/apimodule/auth/auth.go b/internal/apimodule/auth/auth.go @@ -31,7 +31,7 @@ import ( "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/apimodule" "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/model" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/router" ) @@ -75,9 +75,9 @@ func (m *authModule) CreateTables(db db.DB) error { models := []interface{}{ &oauth.Client{}, &oauth.Token{}, - &model.User{}, - &model.Account{}, - &model.Application{}, + &gtsmodel.User{}, + &gtsmodel.Account{}, + &gtsmodel.Application{}, } for _, m := range models { diff --git a/internal/apimodule/auth/auth_test.go b/internal/apimodule/auth/auth_test.go @@ -22,16 +22,14 @@ import ( "context" "fmt" "testing" - "time" "github.com/google/uuid" "github.com/sirupsen/logrus" "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/model" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/oauth" - "github.com/superseriousbusiness/gotosocial/internal/router" "golang.org/x/crypto/bcrypt" ) @@ -39,9 +37,9 @@ type AuthTestSuite struct { suite.Suite oauthServer oauth.Server db db.DB - testAccount *model.Account - testApplication *model.Application - testUser *model.User + testAccount *gtsmodel.Account + testApplication *gtsmodel.Application + testUser *gtsmodel.User testClient *oauth.Client config *config.Config } @@ -75,11 +73,11 @@ func (suite *AuthTestSuite) SetupSuite() { acctID := uuid.NewString() - suite.testAccount = &model.Account{ + suite.testAccount = &gtsmodel.Account{ ID: acctID, Username: "test_user", } - suite.testUser = &model.User{ + suite.testUser = &gtsmodel.User{ EncryptedPassword: string(encryptedPassword), Email: "user@example.org", AccountID: acctID, @@ -89,7 +87,7 @@ func (suite *AuthTestSuite) SetupSuite() { Secret: "some-secret", Domain: fmt.Sprintf("%s://%s", c.Protocol, c.Host), } - suite.testApplication = &model.Application{ + suite.testApplication = &gtsmodel.Application{ Name: "a test application", Website: "https://some-application-website.com", RedirectURI: "http://localhost:8080", @@ -115,9 +113,9 @@ func (suite *AuthTestSuite) SetupTest() { models := []interface{}{ &oauth.Client{}, &oauth.Token{}, - &model.User{}, - &model.Account{}, - &model.Application{}, + &gtsmodel.User{}, + &gtsmodel.Account{}, + &gtsmodel.Application{}, } for _, m := range models { @@ -148,9 +146,9 @@ func (suite *AuthTestSuite) TearDownTest() { models := []interface{}{ &oauth.Client{}, &oauth.Token{}, - &model.User{}, - &model.Account{}, - &model.Application{}, + &gtsmodel.User{}, + &gtsmodel.Account{}, + &gtsmodel.Application{}, } for _, m := range models { if err := suite.db.DropTable(m); err != nil { @@ -163,27 +161,6 @@ func (suite *AuthTestSuite) TearDownTest() { suite.db = nil } -func (suite *AuthTestSuite) TestAPIInitialize() { - log := logrus.New() - log.SetLevel(logrus.TraceLevel) - - r, err := router.New(suite.config, log) - if err != nil { - suite.FailNow(fmt.Sprintf("error mapping routes onto router: %s", err)) - } - - api := New(suite.oauthServer, suite.db, log) - if err := api.Route(r); err != nil { - suite.FailNow(fmt.Sprintf("error mapping routes onto router: %s", err)) - } - - r.Start() - time.Sleep(60 * time.Second) - if err := r.Stop(context.Background()); err != nil { - suite.FailNow(fmt.Sprintf("error stopping router: %s", err)) - } -} - func TestAuthTestSuite(t *testing.T) { suite.Run(t, new(AuthTestSuite)) } diff --git a/internal/apimodule/auth/authorize.go b/internal/apimodule/auth/authorize.go @@ -27,8 +27,8 @@ import ( "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" - "github.com/superseriousbusiness/gotosocial/internal/db/model" - "github.com/superseriousbusiness/gotosocial/pkg/mastotypes" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" ) // authorizeGETHandler should be served as GET at https://example.org/oauth/authorize @@ -57,7 +57,7 @@ func (m *authModule) authorizeGETHandler(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "no client_id found in session"}) return } - app := &model.Application{ + app := &gtsmodel.Application{ ClientID: clientID, } if err := m.db.GetWhere("client_id", app.ClientID, app); err != nil { @@ -66,7 +66,7 @@ func (m *authModule) authorizeGETHandler(c *gin.Context) { } // we can also use the userid of the user to fetch their username from the db to greet them nicely <3 - user := &model.User{ + user := &gtsmodel.User{ ID: userID, } if err := m.db.GetByID(user.ID, user); err != nil { @@ -74,7 +74,7 @@ func (m *authModule) authorizeGETHandler(c *gin.Context) { return } - acct := &model.Account{ + acct := &gtsmodel.Account{ ID: user.AccountID, } diff --git a/internal/apimodule/auth/middleware.go b/internal/apimodule/auth/middleware.go @@ -20,7 +20,7 @@ package auth import ( "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/db/model" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -46,7 +46,7 @@ func (m *authModule) oauthTokenMiddleware(c *gin.Context) { l.Tracef("authenticated user %s with bearer token, scope is %s", uid, ti.GetScope()) // fetch user's and account for this user id - user := &model.User{} + user := &gtsmodel.User{} if err := m.db.GetByID(uid, user); err != nil || user == nil { l.Warnf("no user found for validated uid %s", uid) return @@ -54,7 +54,7 @@ func (m *authModule) oauthTokenMiddleware(c *gin.Context) { c.Set(oauth.SessionAuthorizedUser, user) l.Tracef("set gin context %s to %+v", oauth.SessionAuthorizedUser, user) - acct := &model.Account{} + acct := &gtsmodel.Account{} if err := m.db.GetByID(user.AccountID, acct); err != nil || acct == nil { l.Warnf("no account found for validated user %s", uid) return @@ -66,7 +66,7 @@ func (m *authModule) oauthTokenMiddleware(c *gin.Context) { // check for application token if cid := ti.GetClientID(); cid != "" { l.Tracef("authenticated client %s with bearer token, scope is %s", cid, ti.GetScope()) - app := &model.Application{} + app := &gtsmodel.Application{} if err := m.db.GetWhere("client_id", cid, app); err != nil { l.Tracef("no app found for client %s", cid) } diff --git a/internal/apimodule/auth/signin.go b/internal/apimodule/auth/signin.go @@ -24,7 +24,7 @@ import ( "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" - "github.com/superseriousbusiness/gotosocial/internal/db/model" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" "golang.org/x/crypto/bcrypt" ) @@ -84,7 +84,7 @@ func (m *authModule) validatePassword(email string, password string) (userid str } // first we select the user from the database based on email address, bail if no user found for that email - gtsUser := &model.User{} + gtsUser := &gtsmodel.User{} if err := m.db.GetWhere("email", email, gtsUser); err != nil { l.Debugf("user %s was not retrievable from db during oauth authorization attempt: %s", email, err) diff --git a/internal/apimodule/fileserver/fileserver.go b/internal/apimodule/fileserver/fileserver.go @@ -1,20 +1,48 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + package fileserver import ( "fmt" + "net/http" "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/apimodule" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/model" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/router" "github.com/superseriousbusiness/gotosocial/internal/storage" ) -// fileServer implements the RESTAPIModule interface. +const ( + AccountIDKey = "account_id" + MediaTypeKey = "media_type" + MediaSizeKey = "media_size" + FileNameKey = "file_name" + + FilesPath = "files" +) + +// 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 { +type FileServer struct { config *config.Config db db.DB storage storage.Storage @@ -24,34 +52,24 @@ type fileServer struct { // New returns a new fileServer module func New(config *config.Config, db db.DB, storage storage.Storage, log *logrus.Logger) apimodule.ClientAPIModule { - - storageBase := config.StorageConfig.BasePath // TODO: do this properly - - return &fileServer{ + return &FileServer{ config: config, db: db, storage: storage, log: log, - storageBase: storageBase, + storageBase: config.StorageConfig.ServeBasePath, } } // Route satisfies the RESTAPIModule interface -func (m *fileServer) Route(s router.Router) error { - // s.AttachHandler(http.MethodPost, appsPath, m.appsPOSTHandler) +func (m *FileServer) Route(s router.Router) error { + s.AttachHandler(http.MethodGet, fmt.Sprintf("%s/:%s/:%s/:%s/:%s", m.storageBase, AccountIDKey, MediaTypeKey, MediaSizeKey, FileNameKey), m.ServeFile) return nil } -func (m *fileServer) CreateTables(db db.DB) error { +func (m *FileServer) CreateTables(db db.DB) error { models := []interface{}{ - &model.User{}, - &model.Account{}, - &model.Follow{}, - &model.FollowRequest{}, - &model.Status{}, - &model.Application{}, - &model.EmailDomainBlock{}, - &model.MediaAttachment{}, + &gtsmodel.MediaAttachment{}, } for _, m := range models { diff --git a/internal/apimodule/fileserver/servefile.go b/internal/apimodule/fileserver/servefile.go @@ -0,0 +1,243 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package fileserver + +import ( + "bytes" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/media" +) + +// ServeFile is for serving attachments, headers, and avatars to the requester from instance storage. +// +// Note: to mitigate scraping attempts, no information should be given out on a bad request except "404 page not found". +// Don't give away account ids or media ids or anything like that; callers shouldn't be able to infer anything. +func (m *FileServer) ServeFile(c *gin.Context) { + l := m.log.WithFields(logrus.Fields{ + "func": "ServeFile", + "request_uri": c.Request.RequestURI, + "user_agent": c.Request.UserAgent(), + "origin_ip": c.ClientIP(), + }) + l.Trace("received request") + + // We use request params to check what to pull out of the database/storage so check everything. A request URL should be formatted as follows: + // "https://example.org/fileserver/[ACCOUNT_ID]/[MEDIA_TYPE]/[MEDIA_SIZE]/[FILE_NAME]" + // "FILE_NAME" consists of two parts, the attachment's database id, a period, and the file extension. + accountID := c.Param(AccountIDKey) + if accountID == "" { + l.Debug("missing accountID from request") + c.String(http.StatusNotFound, "404 page not found") + return + } + + mediaType := c.Param(MediaTypeKey) + if mediaType == "" { + l.Debug("missing mediaType from request") + c.String(http.StatusNotFound, "404 page not found") + return + } + + mediaSize := c.Param(MediaSizeKey) + if mediaSize == "" { + l.Debug("missing mediaSize from request") + c.String(http.StatusNotFound, "404 page not found") + return + } + + fileName := c.Param(FileNameKey) + if fileName == "" { + l.Debug("missing fileName from request") + c.String(http.StatusNotFound, "404 page not found") + return + } + + // Only serve media types that are defined in our internal media module + switch mediaType { + case media.MediaHeader, media.MediaAvatar, media.MediaAttachment: + m.serveAttachment(c, accountID, mediaType, mediaSize, fileName) + return + case media.MediaEmoji: + m.serveEmoji(c, accountID, mediaType, mediaSize, fileName) + return + } + l.Debugf("mediatype %s not recognized", mediaType) + c.String(http.StatusNotFound, "404 page not found") +} + +func (m *FileServer) serveAttachment(c *gin.Context, accountID string, mediaType string, mediaSize string, fileName string) { + l := m.log.WithFields(logrus.Fields{ + "func": "serveAttachment", + "request_uri": c.Request.RequestURI, + "user_agent": c.Request.UserAgent(), + "origin_ip": c.ClientIP(), + }) + + // This corresponds to original-sized image as it was uploaded, small (which is the thumbnail), or static + switch mediaSize { + case media.MediaOriginal, media.MediaSmall, media.MediaStatic: + default: + l.Debugf("mediasize %s not recognized", mediaSize) + c.String(http.StatusNotFound, "404 page not found") + return + } + + // derive the media id and the file extension from the last part of the request + spl := strings.Split(fileName, ".") + if len(spl) != 2 { + l.Debugf("filename %s not parseable", fileName) + c.String(http.StatusNotFound, "404 page not found") + return + } + wantedMediaID := spl[0] + fileExtension := spl[1] + if wantedMediaID == "" || fileExtension == "" { + l.Debugf("filename %s not parseable", fileName) + c.String(http.StatusNotFound, "404 page not found") + return + } + + // now we know the attachment ID that the caller is asking for we can use it to pull the attachment out of the db + attachment := &gtsmodel.MediaAttachment{} + if err := m.db.GetByID(wantedMediaID, attachment); err != nil { + l.Debugf("attachment with id %s not retrievable: %s", wantedMediaID, err) + c.String(http.StatusNotFound, "404 page not found") + return + } + + // make sure the given account id owns the requested attachment + if accountID != attachment.AccountID { + l.Debugf("account %s does not own attachment with id %s", accountID, wantedMediaID) + c.String(http.StatusNotFound, "404 page not found") + return + } + + // now we can start preparing the response depending on whether we're serving a thumbnail or a larger attachment + var storagePath string + var contentType string + var contentLength int + switch mediaSize { + case media.MediaOriginal: + storagePath = attachment.File.Path + contentType = attachment.File.ContentType + contentLength = attachment.File.FileSize + case media.MediaSmall: + storagePath = attachment.Thumbnail.Path + contentType = attachment.Thumbnail.ContentType + contentLength = attachment.Thumbnail.FileSize + } + + // use the path listed on the attachment we pulled out of the database to retrieve the object from storage + attachmentBytes, err := m.storage.RetrieveFileFrom(storagePath) + if err != nil { + l.Debugf("error retrieving from storage: %s", err) + c.String(http.StatusNotFound, "404 page not found") + return + } + + l.Errorf("about to serve content length: %d attachment bytes is: %d", int64(contentLength), int64(len(attachmentBytes))) + + // finally we can return with all the information we derived above + c.DataFromReader(http.StatusOK, int64(contentLength), contentType, bytes.NewReader(attachmentBytes), map[string]string{}) +} + +func (m *FileServer) serveEmoji(c *gin.Context, accountID string, mediaType string, mediaSize string, fileName string) { + l := m.log.WithFields(logrus.Fields{ + "func": "serveEmoji", + "request_uri": c.Request.RequestURI, + "user_agent": c.Request.UserAgent(), + "origin_ip": c.ClientIP(), + }) + + // This corresponds to original-sized emoji as it was uploaded, or static + switch mediaSize { + case media.MediaOriginal, media.MediaStatic: + default: + l.Debugf("mediasize %s not recognized", mediaSize) + c.String(http.StatusNotFound, "404 page not found") + return + } + + // derive the media id and the file extension from the last part of the request + spl := strings.Split(fileName, ".") + if len(spl) != 2 { + l.Debugf("filename %s not parseable", fileName) + c.String(http.StatusNotFound, "404 page not found") + return + } + wantedEmojiID := spl[0] + fileExtension := spl[1] + if wantedEmojiID == "" || fileExtension == "" { + l.Debugf("filename %s not parseable", fileName) + c.String(http.StatusNotFound, "404 page not found") + return + } + + // now we know the attachment ID that the caller is asking for we can use it to pull the attachment out of the db + emoji := &gtsmodel.Emoji{} + if err := m.db.GetByID(wantedEmojiID, emoji); err != nil { + l.Debugf("emoji with id %s not retrievable: %s", wantedEmojiID, err) + c.String(http.StatusNotFound, "404 page not found") + return + } + + // make sure the instance account id owns the requested emoji + instanceAccount := &gtsmodel.Account{} + if err := m.db.GetWhere("username", m.config.Host, instanceAccount); err != nil { + l.Debugf("error fetching instance account: %s", err) + c.String(http.StatusNotFound, "404 page not found") + return + } + if accountID != instanceAccount.ID { + l.Debugf("account %s does not own emoji with id %s", accountID, wantedEmojiID) + c.String(http.StatusNotFound, "404 page not found") + return + } + + // now we can start preparing the response depending on whether we're serving a thumbnail or a larger attachment + var storagePath string + var contentType string + var contentLength int + switch mediaSize { + case media.MediaOriginal: + storagePath = emoji.ImagePath + contentType = emoji.ImageContentType + contentLength = emoji.ImageFileSize + case media.MediaStatic: + storagePath = emoji.ImageStaticPath + contentType = "image/png" + contentLength = emoji.ImageStaticFileSize + } + + // use the path listed on the emoji we pulled out of the database to retrieve the object from storage + emojiBytes, err := m.storage.RetrieveFileFrom(storagePath) + if err != nil { + l.Debugf("error retrieving emoji from storage: %s", err) + c.String(http.StatusNotFound, "404 page not found") + return + } + + // finally we can return with all the information we derived above + c.DataFromReader(http.StatusOK, int64(contentLength), contentType, bytes.NewReader(emojiBytes), map[string]string{}) +} diff --git a/internal/apimodule/fileserver/test/servefile_test.go b/internal/apimodule/fileserver/test/servefile_test.go @@ -0,0 +1,157 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package test + +import ( + "context" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/apimodule/fileserver" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/mastotypes" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/storage" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type ServeFileTestSuite struct { + // standard suite interfaces + suite.Suite + config *config.Config + db db.DB + log *logrus.Logger + storage storage.Storage + mastoConverter mastotypes.Converter + mediaHandler media.MediaHandler + oauthServer oauth.Server + + // standard suite models + testTokens map[string]*oauth.Token + testClients map[string]*oauth.Client + testApplications map[string]*gtsmodel.Application + testUsers map[string]*gtsmodel.User + testAccounts map[string]*gtsmodel.Account + testAttachments map[string]*gtsmodel.MediaAttachment + + // item being tested + fileServer *fileserver.FileServer +} + +/* + TEST INFRASTRUCTURE +*/ + +func (suite *ServeFileTestSuite) SetupSuite() { + // setup standard items + suite.config = testrig.NewTestConfig() + suite.db = testrig.NewTestDB() + suite.log = testrig.NewTestLog() + suite.storage = testrig.NewTestStorage() + suite.mastoConverter = testrig.NewTestMastoConverter(suite.db) + suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage) + suite.oauthServer = testrig.NewTestOauthServer(suite.db) + + // setup module being tested + suite.fileServer = fileserver.New(suite.config, suite.db, suite.storage, suite.log).(*fileserver.FileServer) +} + +func (suite *ServeFileTestSuite) TearDownSuite() { + if err := suite.db.Stop(context.Background()); err != nil { + logrus.Panicf("error closing db connection: %s", err) + } +} + +func (suite *ServeFileTestSuite) SetupTest() { + testrig.StandardDBSetup(suite.db) + testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") + suite.testTokens = testrig.NewTestTokens() + suite.testClients = testrig.NewTestClients() + suite.testApplications = testrig.NewTestApplications() + suite.testUsers = testrig.NewTestUsers() + suite.testAccounts = testrig.NewTestAccounts() + suite.testAttachments = testrig.NewTestAttachments() +} + +func (suite *ServeFileTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) +} + +/* + ACTUAL TESTS +*/ + +func (suite *ServeFileTestSuite) TestServeOriginalFileSuccessful() { + targetAttachment, ok := suite.testAttachments["admin_account_status_1_attachment_1"] + assert.True(suite.T(), ok) + assert.NotNil(suite.T(), targetAttachment) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodGet, targetAttachment.URL, nil) + + // normally the router would populate these params from the path values, + // but because we're calling the ServeFile function directly, we need to set them manually. + ctx.Params = gin.Params{ + gin.Param{ + Key: fileserver.AccountIDKey, + Value: targetAttachment.AccountID, + }, + gin.Param{ + Key: fileserver.MediaTypeKey, + Value: media.MediaAttachment, + }, + gin.Param{ + Key: fileserver.MediaSizeKey, + Value: media.MediaOriginal, + }, + gin.Param{ + Key: fileserver.FileNameKey, + Value: fmt.Sprintf("%s.jpeg", targetAttachment.ID), + }, + } + + // call the function we're testing and check status code + suite.fileServer.ServeFile(ctx) + suite.EqualValues(http.StatusOK, recorder.Code) + + b, err := ioutil.ReadAll(recorder.Body) + assert.NoError(suite.T(), err) + assert.NotNil(suite.T(), b) + + fileInStorage, err := suite.storage.RetrieveFileFrom(targetAttachment.File.Path) + assert.NoError(suite.T(), err) + assert.NotNil(suite.T(), fileInStorage) + assert.Equal(suite.T(), b, fileInStorage) +} + +func TestServeFileTestSuite(t *testing.T) { + suite.Run(t, new(ServeFileTestSuite)) +} diff --git a/internal/apimodule/media/media.go b/internal/apimodule/media/media.go @@ -0,0 +1,73 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package media + +import ( + "fmt" + "net/http" + + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/apimodule" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/mastotypes" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/router" +) + +const BasePath = "/api/v1/media" + +type MediaModule struct { + mediaHandler media.MediaHandler + config *config.Config + db db.DB + mastoConverter mastotypes.Converter + log *logrus.Logger +} + +// New returns a new auth module +func New(db db.DB, mediaHandler media.MediaHandler, mastoConverter mastotypes.Converter, config *config.Config, log *logrus.Logger) apimodule.ClientAPIModule { + return &MediaModule{ + mediaHandler: mediaHandler, + config: config, + db: db, + mastoConverter: mastoConverter, + log: log, + } +} + +// Route satisfies the RESTAPIModule interface +func (m *MediaModule) Route(s router.Router) error { + s.AttachHandler(http.MethodPost, BasePath, m.MediaCreatePOSTHandler) + return nil +} + +func (m *MediaModule) CreateTables(db db.DB) error { + models := []interface{}{ + &gtsmodel.MediaAttachment{}, + } + + for _, m := range models { + if err := db.CreateTable(m); err != nil { + return fmt.Errorf("error creating table: %s", err) + } + } + return nil +} diff --git a/internal/apimodule/media/mediacreate.go b/internal/apimodule/media/mediacreate.go @@ -0,0 +1,192 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package media + +import ( + "bytes" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/config" + mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +func (m *MediaModule) MediaCreatePOSTHandler(c *gin.Context) { + l := m.log.WithField("func", "statusCreatePOSTHandler") + authed, err := oauth.MustAuth(c, true, true, true, true) // posting new media is serious business so we want *everything* + if err != nil { + l.Debugf("couldn't auth: %s", err) + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + + // First check this user/account is permitted to create media + // There's no point continuing otherwise. + if authed.User.Disabled || !authed.User.Approved || !authed.Account.SuspendedAt.IsZero() { + l.Debugf("couldn't auth: %s", err) + c.JSON(http.StatusForbidden, gin.H{"error": "account is disabled, not yet approved, or suspended"}) + return + } + + // extract the media create form from the request context + l.Tracef("parsing request form: %s", c.Request.Form) + form := &mastotypes.AttachmentRequest{} + if err := c.ShouldBind(form); err != nil || form == nil { + l.Debugf("could not parse form from request: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"}) + return + } + + // Give the fields on the request form a first pass to make sure the request is superficially valid. + l.Tracef("validating form %+v", form) + if err := validateCreateMedia(form, m.config.MediaConfig); err != nil { + l.Debugf("error validating form: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // open the attachment and extract the bytes from it + f, err := form.File.Open() + if err != nil { + l.Debugf("error opening attachment: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not open provided attachment: %s", err)}) + return + } + buf := new(bytes.Buffer) + size, err := io.Copy(buf, f) + if err != nil { + l.Debugf("error reading attachment: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not read provided attachment: %s", err)}) + return + } + if size == 0 { + l.Debug("could not read provided attachment: size 0 bytes") + c.JSON(http.StatusBadRequest, gin.H{"error": "could not read provided attachment: size 0 bytes"}) + return + } + + // allow the mediaHandler to work its magic of processing the attachment bytes, and putting them in whatever storage backend we're using + attachment, err := m.mediaHandler.ProcessLocalAttachment(buf.Bytes(), authed.Account.ID) + if err != nil { + l.Debugf("error reading attachment: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not process attachment: %s", err)}) + return + } + + // now we need to add extra fields that the attachment processor doesn't know (from the form) + // TODO: handle this inside mediaHandler.ProcessAttachment (just pass more params to it) + + // first description + attachment.Description = form.Description + + // now parse the focus parameter + // TODO: tidy this up into a separate function and just return an error so all the c.JSON and return calls are obviated + var focusx, focusy float32 + if form.Focus != "" { + spl := strings.Split(form.Focus, ",") + if len(spl) != 2 { + l.Debugf("improperly formatted focus %s", form.Focus) + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)}) + return + } + xStr := spl[0] + yStr := spl[1] + if xStr == "" || yStr == "" { + l.Debugf("improperly formatted focus %s", form.Focus) + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)}) + return + } + fx, err := strconv.ParseFloat(xStr, 32) + if err != nil { + l.Debugf("improperly formatted focus %s: %s", form.Focus, err) + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)}) + return + } + if fx > 1 || fx < -1 { + l.Debugf("improperly formatted focus %s", form.Focus) + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)}) + return + } + focusx = float32(fx) + fy, err := strconv.ParseFloat(yStr, 32) + if err != nil { + l.Debugf("improperly formatted focus %s: %s", form.Focus, err) + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)}) + return + } + if fy > 1 || fy < -1 { + l.Debugf("improperly formatted focus %s", form.Focus) + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)}) + return + } + focusy = float32(fy) + } + attachment.FileMeta.Focus.X = focusx + attachment.FileMeta.Focus.Y = focusy + + // prepare the frontend representation now -- if there are any errors here at least we can bail without + // having already put something in the database and then having to clean it up again (eugh) + mastoAttachment, err := m.mastoConverter.AttachmentToMasto(attachment) + if err != nil { + l.Debugf("error parsing media attachment to frontend type: %s", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("error parsing media attachment to frontend type: %s", err)}) + return + } + + // now we can confidently put the attachment in the database + if err := m.db.Put(attachment); err != nil { + l.Debugf("error storing media attachment in db: %s", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("error storing media attachment in db: %s", err)}) + return + } + + // and return its frontend representation + c.JSON(http.StatusAccepted, mastoAttachment) +} + +func validateCreateMedia(form *mastotypes.AttachmentRequest, config *config.MediaConfig) error { + // check there actually is a file attached and it's not size 0 + if form.File == nil || form.File.Size == 0 { + return errors.New("no attachment given") + } + + // a very superficial check to see if no size limits are exceeded + // we still don't actually know which media types we're dealing with but the other handlers will go into more detail there + maxSize := config.MaxVideoSize + if config.MaxImageSize > maxSize { + maxSize = config.MaxImageSize + } + if form.File.Size > int64(maxSize) { + return fmt.Errorf("file size limit exceeded: limit is %d bytes but attachment was %d bytes", maxSize, form.File.Size) + } + + if len(form.Description) < config.MinDescriptionChars || len(form.Description) > config.MaxDescriptionChars { + return fmt.Errorf("image description length must be between %d and %d characters (inclusive), but provided image description was %d chars", config.MinDescriptionChars, config.MaxDescriptionChars, len(form.Description)) + } + + // TODO: validate focus here + + return nil +} diff --git a/internal/apimodule/media/test/mediacreate_test.go b/internal/apimodule/media/test/mediacreate_test.go @@ -0,0 +1,194 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + mediamodule "github.com/superseriousbusiness/gotosocial/internal/apimodule/media" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/mastotypes" + mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/storage" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type MediaCreateTestSuite struct { + // standard suite interfaces + suite.Suite + config *config.Config + db db.DB + log *logrus.Logger + storage storage.Storage + mastoConverter mastotypes.Converter + mediaHandler media.MediaHandler + oauthServer oauth.Server + + // standard suite models + testTokens map[string]*oauth.Token + testClients map[string]*oauth.Client + testApplications map[string]*gtsmodel.Application + testUsers map[string]*gtsmodel.User + testAccounts map[string]*gtsmodel.Account + testAttachments map[string]*gtsmodel.MediaAttachment + + // item being tested + mediaModule *mediamodule.MediaModule +} + +/* + TEST INFRASTRUCTURE +*/ + +func (suite *MediaCreateTestSuite) SetupSuite() { + // setup standard items + suite.config = testrig.NewTestConfig() + suite.db = testrig.NewTestDB() + suite.log = testrig.NewTestLog() + suite.storage = testrig.NewTestStorage() + suite.mastoConverter = testrig.NewTestMastoConverter(suite.db) + suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage) + suite.oauthServer = testrig.NewTestOauthServer(suite.db) + + // setup module being tested + suite.mediaModule = mediamodule.New(suite.db, suite.mediaHandler, suite.mastoConverter, suite.config, suite.log).(*mediamodule.MediaModule) +} + +func (suite *MediaCreateTestSuite) TearDownSuite() { + if err := suite.db.Stop(context.Background()); err != nil { + logrus.Panicf("error closing db connection: %s", err) + } +} + +func (suite *MediaCreateTestSuite) SetupTest() { + testrig.StandardDBSetup(suite.db) + testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") + suite.testTokens = testrig.NewTestTokens() + suite.testClients = testrig.NewTestClients() + suite.testApplications = testrig.NewTestApplications() + suite.testUsers = testrig.NewTestUsers() + suite.testAccounts = testrig.NewTestAccounts() + suite.testAttachments = testrig.NewTestAttachments() +} + +func (suite *MediaCreateTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) +} + +/* + ACTUAL TESTS +*/ + +func (suite *MediaCreateTestSuite) TestStatusCreatePOSTImageHandlerSuccessful() { + + // set up the context for the request + t := suite.testTokens["local_account_1"] + oauthToken := oauth.PGTokenToOauthToken(t) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedToken, oauthToken) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + + // see what's in storage *before* the request + storageKeysBeforeRequest, err := suite.storage.ListKeys() + if err != nil { + panic(err) + } + + // create the request + buf, w, err := testrig.CreateMultipartFormData("file", "../../../../testrig/media/test-jpeg.jpg", map[string]string{ + "description": "this is a test image -- a cool background from somewhere", + "focus": "-0.5,0.5", + }) + if err != nil { + panic(err) + } + ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", mediamodule.BasePath), bytes.NewReader(buf.Bytes())) // the endpoint we're hitting + ctx.Request.Header.Set("Content-Type", w.FormDataContentType()) + + // do the actual request + suite.mediaModule.MediaCreatePOSTHandler(ctx) + + // check what's in storage *after* the request + storageKeysAfterRequest, err := suite.storage.ListKeys() + if err != nil { + panic(err) + } + + // check response + suite.EqualValues(http.StatusAccepted, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + assert.NoError(suite.T(), err) + fmt.Println(string(b)) + + attachmentReply := &mastomodel.Attachment{} + err = json.Unmarshal(b, attachmentReply) + assert.NoError(suite.T(), err) + + assert.Equal(suite.T(), "this is a test image -- a cool background from somewhere", attachmentReply.Description) + assert.Equal(suite.T(), "image", attachmentReply.Type) + assert.EqualValues(suite.T(), mastomodel.MediaMeta{ + Original: mastomodel.MediaDimensions{ + Width: 1920, + Height: 1080, + Size: "1920x1080", + Aspect: 1.7777778, + }, + Small: mastomodel.MediaDimensions{ + Width: 256, + Height: 144, + Size: "256x144", + Aspect: 1.7777778, + }, + Focus: mastomodel.MediaFocus{ + X: -0.5, + Y: 0.5, + }, + }, attachmentReply.Meta) + assert.Equal(suite.T(), "LjCZnlvyRkRn_NvzRjWF?urqV@f9", attachmentReply.Blurhash) + assert.NotEmpty(suite.T(), attachmentReply.ID) + assert.NotEmpty(suite.T(), attachmentReply.URL) + assert.NotEmpty(suite.T(), attachmentReply.PreviewURL) + assert.Equal(suite.T(), len(storageKeysBeforeRequest)+2, len(storageKeysAfterRequest)) // 2 images should be added to storage: the original and the thumbnail +} + +func TestMediaCreateTestSuite(t *testing.T) { + suite.Run(t, new(MediaCreateTestSuite)) +} diff --git a/internal/apimodule/mock_ClientAPIModule.go b/internal/apimodule/mock_ClientAPIModule.go @@ -4,6 +4,8 @@ package apimodule import ( mock "github.com/stretchr/testify/mock" + db "github.com/superseriousbusiness/gotosocial/internal/db" + router "github.com/superseriousbusiness/gotosocial/internal/router" ) @@ -12,6 +14,20 @@ type MockClientAPIModule struct { mock.Mock } +// CreateTables provides a mock function with given fields: _a0 +func (_m *MockClientAPIModule) CreateTables(_a0 db.DB) error { + ret := _m.Called(_a0) + + var r0 error + if rf, ok := ret.Get(0).(func(db.DB) error); ok { + r0 = rf(_a0) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // Route provides a mock function with given fields: s func (_m *MockClientAPIModule) Route(s router.Router) error { ret := _m.Called(s) diff --git a/internal/apimodule/security/flocblock.go b/internal/apimodule/security/flocblock.go @@ -0,0 +1,27 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package security + +import "github.com/gin-gonic/gin" + +// flocBlock prevents google chrome cohort tracking by writing the Permissions-Policy header after all other parts of the request have been completed. +// See: https://plausible.io/blog/google-floc +func (m *module) flocBlock(c *gin.Context) { + c.Header("Permissions-Policy", "interest-cohort=()") +} diff --git a/internal/apimodule/security/security.go b/internal/apimodule/security/security.go @@ -0,0 +1,50 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package security + +import ( + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/apimodule" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/router" +) + +// module implements the apiclient interface +type module struct { + config *config.Config + log *logrus.Logger +} + +// New returns a new security module +func New(config *config.Config, log *logrus.Logger) apimodule.ClientAPIModule { + return &module{ + config: config, + log: log, + } +} + +func (m *module) Route(s router.Router) error { + s.AttachMiddleware(m.flocBlock) + return nil +} + +func (m *module) CreateTables(db db.DB) error { + return nil +} diff --git a/internal/apimodule/status/status.go b/internal/apimodule/status/status.go @@ -0,0 +1,138 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package status + +import ( + "fmt" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/apimodule" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/distributor" + "github.com/superseriousbusiness/gotosocial/internal/mastotypes" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/router" +) + +const ( + IDKey = "id" + BasePath = "/api/v1/statuses" + BasePathWithID = BasePath + "/:" + IDKey + + ContextPath = BasePathWithID + "/context" + + FavouritedPath = BasePathWithID + "/favourited_by" + FavouritePath = BasePathWithID + "/favourite" + UnfavouritePath = BasePathWithID + "/unfavourite" + + RebloggedPath = BasePathWithID + "/reblogged_by" + ReblogPath = BasePathWithID + "/reblog" + UnreblogPath = BasePathWithID + "/unreblog" + + BookmarkPath = BasePathWithID + "/bookmark" + UnbookmarkPath = BasePathWithID + "/unbookmark" + + MutePath = BasePathWithID + "/mute" + UnmutePath = BasePathWithID + "/unmute" + + PinPath = BasePathWithID + "/pin" + UnpinPath = BasePathWithID + "/unpin" +) + +type StatusModule struct { + config *config.Config + db db.DB + mediaHandler media.MediaHandler + mastoConverter mastotypes.Converter + distributor distributor.Distributor + log *logrus.Logger +} + +// New returns a new account module +func New(config *config.Config, db db.DB, mediaHandler media.MediaHandler, mastoConverter mastotypes.Converter, distributor distributor.Distributor, log *logrus.Logger) apimodule.ClientAPIModule { + return &StatusModule{ + config: config, + db: db, + mediaHandler: mediaHandler, + mastoConverter: mastoConverter, + distributor: distributor, + log: log, + } +} + +// Route attaches all routes from this module to the given router +func (m *StatusModule) Route(r router.Router) error { + r.AttachHandler(http.MethodPost, BasePath, m.StatusCreatePOSTHandler) + r.AttachHandler(http.MethodDelete, BasePathWithID, m.StatusDELETEHandler) + + r.AttachHandler(http.MethodPost, FavouritePath, m.StatusFavePOSTHandler) + r.AttachHandler(http.MethodPost, UnfavouritePath, m.StatusFavePOSTHandler) + + r.AttachHandler(http.MethodGet, BasePathWithID, m.muxHandler) + return nil +} + +func (m *StatusModule) CreateTables(db db.DB) error { + models := []interface{}{ + &gtsmodel.User{}, + &gtsmodel.Account{}, + &gtsmodel.Block{}, + &gtsmodel.Follow{}, + &gtsmodel.FollowRequest{}, + &gtsmodel.Status{}, + &gtsmodel.StatusFave{}, + &gtsmodel.StatusBookmark{}, + &gtsmodel.StatusMute{}, + &gtsmodel.StatusPin{}, + &gtsmodel.Application{}, + &gtsmodel.EmailDomainBlock{}, + &gtsmodel.MediaAttachment{}, + &gtsmodel.Emoji{}, + &gtsmodel.Tag{}, + &gtsmodel.Mention{}, + } + + for _, m := range models { + if err := db.CreateTable(m); err != nil { + return fmt.Errorf("error creating table: %s", err) + } + } + return nil +} + +func (m *StatusModule) muxHandler(c *gin.Context) { + m.log.Debug("entering mux handler") + ru := c.Request.RequestURI + + switch c.Request.Method { + case http.MethodGet: + if strings.HasPrefix(ru, ContextPath) { + // TODO + } else if strings.HasPrefix(ru, FavouritedPath) { + m.StatusFavedByGETHandler(c) + } else { + m.StatusGETHandler(c) + } + } +} diff --git a/internal/apimodule/status/statuscreate.go b/internal/apimodule/status/statuscreate.go @@ -0,0 +1,463 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package status + +import ( + "errors" + "fmt" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/distributor" + mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +type advancedStatusCreateForm struct { + mastotypes.StatusCreateRequest + advancedVisibilityFlagsForm +} + +type advancedVisibilityFlagsForm struct { + // The gotosocial visibility model + VisibilityAdvanced *gtsmodel.Visibility `form:"visibility_advanced"` + // This status will be federated beyond the local timeline(s) + Federated *bool `form:"federated"` + // This status can be boosted/reblogged + Boostable *bool `form:"boostable"` + // This status can be replied to + Replyable *bool `form:"replyable"` + // This status can be liked/faved + Likeable *bool `form:"likeable"` +} + +func (m *StatusModule) StatusCreatePOSTHandler(c *gin.Context) { + l := m.log.WithField("func", "statusCreatePOSTHandler") + authed, err := oauth.MustAuth(c, true, true, true, true) // posting a status is serious business so we want *everything* + if err != nil { + l.Debugf("couldn't auth: %s", err) + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + + // First check this user/account is permitted to post new statuses. + // There's no point continuing otherwise. + if authed.User.Disabled || !authed.User.Approved || !authed.Account.SuspendedAt.IsZero() { + l.Debugf("couldn't auth: %s", err) + c.JSON(http.StatusForbidden, gin.H{"error": "account is disabled, not yet approved, or suspended"}) + return + } + + // extract the status create form from the request context + l.Tracef("parsing request form: %s", c.Request.Form) + form := &advancedStatusCreateForm{} + if err := c.ShouldBind(form); err != nil || form == nil { + l.Debugf("could not parse form from request: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"}) + return + } + + // Give the fields on the request form a first pass to make sure the request is superficially valid. + l.Tracef("validating form %+v", form) + if err := validateCreateStatus(form, m.config.StatusesConfig); err != nil { + l.Debugf("error validating form: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // At this point we know the account is permitted to post, and we know the request form + // is valid (at least according to the API specifications and the instance configuration). + // So now we can start digging a bit deeper into the form and building up the new status from it. + + // first we create a new status and add some basic info to it + uris := util.GenerateURIs(authed.Account.Username, m.config.Protocol, m.config.Host) + thisStatusID := uuid.NewString() + thisStatusURI := fmt.Sprintf("%s/%s", uris.StatusesURI, thisStatusID) + thisStatusURL := fmt.Sprintf("%s/%s", uris.StatusesURL, thisStatusID) + newStatus := &gtsmodel.Status{ + ID: thisStatusID, + URI: thisStatusURI, + URL: thisStatusURL, + Content: util.HTMLFormat(form.Status), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Local: true, + AccountID: authed.Account.ID, + ContentWarning: form.SpoilerText, + ActivityStreamsType: gtsmodel.ActivityStreamsNote, + Sensitive: form.Sensitive, + Language: form.Language, + CreatedWithApplicationID: authed.Application.ID, + Text: form.Status, + } + + // check if replyToID is ok + if err := m.parseReplyToID(form, authed.Account.ID, newStatus); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // check if mediaIDs are ok + if err := m.parseMediaIDs(form, authed.Account.ID, newStatus); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // check if visibility settings are ok + if err := parseVisibility(form, authed.Account.Privacy, newStatus); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // handle language settings + if err := parseLanguage(form, authed.Account.Language, newStatus); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // handle mentions + if err := m.parseMentions(form, authed.Account.ID, newStatus); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := m.parseTags(form, authed.Account.ID, newStatus); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := m.parseEmojis(form, authed.Account.ID, newStatus); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + /* + FROM THIS POINT ONWARDS WE ARE HAPPY WITH THE STATUS -- it is valid and we will try to create it + */ + + // put the new status in the database, generating an ID for it in the process + if err := m.db.Put(newStatus); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // change the status ID of the media attachments to the new status + for _, a := range newStatus.GTSMediaAttachments { + a.StatusID = newStatus.ID + a.UpdatedAt = time.Now() + if err := m.db.UpdateByID(a.ID, a); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } + + // pass to the distributor to take care of side effects asynchronously -- federation, mentions, updating metadata, etc, etc + m.distributor.FromClientAPI() <- distributor.FromClientAPI{ + APObjectType: gtsmodel.ActivityStreamsNote, + APActivityType: gtsmodel.ActivityStreamsCreate, + Activity: newStatus, + } + + // return the frontend representation of the new status to the submitter + mastoStatus, err := m.mastoConverter.StatusToMasto(newStatus, authed.Account, authed.Account, nil, newStatus.GTSReplyToAccount, nil) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, mastoStatus) +} + +func validateCreateStatus(form *advancedStatusCreateForm, config *config.StatusesConfig) error { + // validate that, structurally, we have a valid status/post + if form.Status == "" && form.MediaIDs == nil && form.Poll == nil { + return errors.New("no status, media, or poll provided") + } + + if form.MediaIDs != nil && form.Poll != nil { + return errors.New("can't post media + poll in same status") + } + + // validate status + if form.Status != "" { + if len(form.Status) > config.MaxChars { + return fmt.Errorf("status too long, %d characters provided but limit is %d", len(form.Status), config.MaxChars) + } + } + + // validate media attachments + if len(form.MediaIDs) > config.MaxMediaFiles { + return fmt.Errorf("too many media files attached to status, %d attached but limit is %d", len(form.MediaIDs), config.MaxMediaFiles) + } + + // validate poll + if form.Poll != nil { + if form.Poll.Options == nil { + return errors.New("poll with no options") + } + if len(form.Poll.Options) > config.PollMaxOptions { + return fmt.Errorf("too many poll options provided, %d provided but limit is %d", len(form.Poll.Options), config.PollMaxOptions) + } + for _, p := range form.Poll.Options { + if len(p) > config.PollOptionMaxChars { + return fmt.Errorf("poll option too long, %d characters provided but limit is %d", len(p), config.PollOptionMaxChars) + } + } + } + + // validate spoiler text/cw + if form.SpoilerText != "" { + if len(form.SpoilerText) > config.CWMaxChars { + return fmt.Errorf("content-warning/spoilertext too long, %d characters provided but limit is %d", len(form.SpoilerText), config.CWMaxChars) + } + } + + // validate post language + if form.Language != "" { + if err := util.ValidateLanguage(form.Language); err != nil { + return err + } + } + + return nil +} + +func parseVisibility(form *advancedStatusCreateForm, accountDefaultVis gtsmodel.Visibility, status *gtsmodel.Status) error { + // by default all flags are set to true + gtsAdvancedVis := &gtsmodel.VisibilityAdvanced{ + Federated: true, + Boostable: true, + Replyable: true, + Likeable: true, + } + + var gtsBasicVis gtsmodel.Visibility + // Advanced takes priority if it's set. + // If it's not set, take whatever masto visibility is set. + // If *that's* not set either, then just take the account default. + // If that's also not set, take the default for the whole instance. + if form.VisibilityAdvanced != nil { + gtsBasicVis = *form.VisibilityAdvanced + } else if form.Visibility != "" { + gtsBasicVis = util.ParseGTSVisFromMastoVis(form.Visibility) + } else if accountDefaultVis != "" { + gtsBasicVis = accountDefaultVis + } else { + gtsBasicVis = gtsmodel.VisibilityDefault + } + + switch gtsBasicVis { + case gtsmodel.VisibilityPublic: + // for public, there's no need to change any of the advanced flags from true regardless of what the user filled out + break + case gtsmodel.VisibilityUnlocked: + // for unlocked the user can set any combination of flags they like so look at them all to see if they're set and then apply them + if form.Federated != nil { + gtsAdvancedVis.Federated = *form.Federated + } + + if form.Boostable != nil { + gtsAdvancedVis.Boostable = *form.Boostable + } + + if form.Replyable != nil { + gtsAdvancedVis.Replyable = *form.Replyable + } + + if form.Likeable != nil { + gtsAdvancedVis.Likeable = *form.Likeable + } + + case gtsmodel.VisibilityFollowersOnly, gtsmodel.VisibilityMutualsOnly: + // for followers or mutuals only, boostable will *always* be false, but the other fields can be set so check and apply them + gtsAdvancedVis.Boostable = false + + if form.Federated != nil { + gtsAdvancedVis.Federated = *form.Federated + } + + if form.Replyable != nil { + gtsAdvancedVis.Replyable = *form.Replyable + } + + if form.Likeable != nil { + gtsAdvancedVis.Likeable = *form.Likeable + } + + case gtsmodel.VisibilityDirect: + // direct is pretty easy: there's only one possible setting so return it + gtsAdvancedVis.Federated = true + gtsAdvancedVis.Boostable = false + gtsAdvancedVis.Federated = true + gtsAdvancedVis.Likeable = true + } + + status.Visibility = gtsBasicVis + status.VisibilityAdvanced = gtsAdvancedVis + return nil +} + +func (m *StatusModule) parseReplyToID(form *advancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) error { + if form.InReplyToID == "" { + return nil + } + + // If this status is a reply to another status, we need to do a bit of work to establish whether or not this status can be posted: + // + // 1. Does the replied status exist in the database? + // 2. Is the replied status marked as replyable? + // 3. Does a block exist between either the current account or the account that posted the status it's replying to? + // + // If this is all OK, then we fetch the repliedStatus and the repliedAccount for later processing. + repliedStatus := &gtsmodel.Status{} + repliedAccount := &gtsmodel.Account{} + // check replied status exists + is replyable + if err := m.db.GetByID(form.InReplyToID, repliedStatus); err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + return fmt.Errorf("status with id %s not replyable because it doesn't exist", form.InReplyToID) + } else { + return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err) + } + } + + if !repliedStatus.VisibilityAdvanced.Replyable { + return fmt.Errorf("status with id %s is marked as not replyable", form.InReplyToID) + } + + // check replied account is known to us + if err := m.db.GetByID(repliedStatus.AccountID, repliedAccount); err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + return fmt.Errorf("status with id %s not replyable because account id %s is not known", form.InReplyToID, repliedStatus.AccountID) + } else { + return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err) + } + } + // check if a block exists + if blocked, err := m.db.Blocked(thisAccountID, repliedAccount.ID); err != nil { + if _, ok := err.(db.ErrNoEntries); !ok { + return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err) + } + } else if blocked { + return fmt.Errorf("status with id %s not replyable", form.InReplyToID) + } + status.InReplyToID = repliedStatus.ID + status.InReplyToAccountID = repliedAccount.ID + + return nil +} + +func (m *StatusModule) parseMediaIDs(form *advancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) error { + if form.MediaIDs == nil { + return nil + } + + gtsMediaAttachments := []*gtsmodel.MediaAttachment{} + attachments := []string{} + for _, mediaID := range form.MediaIDs { + // check these attachments exist + a := &gtsmodel.MediaAttachment{} + if err := m.db.GetByID(mediaID, a); err != nil { + return fmt.Errorf("invalid media type or media not found for media id %s", mediaID) + } + // check they belong to the requesting account id + if a.AccountID != thisAccountID { + return fmt.Errorf("media with id %s does not belong to account %s", mediaID, thisAccountID) + } + // check they're not already used in a status + if a.StatusID != "" || a.ScheduledStatusID != "" { + return fmt.Errorf("media with id %s is already attached to a status", mediaID) + } + gtsMediaAttachments = append(gtsMediaAttachments, a) + attachments = append(attachments, a.ID) + } + status.GTSMediaAttachments = gtsMediaAttachments + status.Attachments = attachments + return nil +} + +func parseLanguage(form *advancedStatusCreateForm, accountDefaultLanguage string, status *gtsmodel.Status) error { + if form.Language != "" { + status.Language = form.Language + } else { + status.Language = accountDefaultLanguage + } + if status.Language == "" { + return errors.New("no language given either in status create form or account default") + } + return nil +} + +func (m *StatusModule) parseMentions(form *advancedStatusCreateForm, accountID string, status *gtsmodel.Status) error { + menchies := []string{} + gtsMenchies, err := m.db.MentionStringsToMentions(util.DeriveMentions(form.Status), accountID, status.ID) + if err != nil { + return fmt.Errorf("error generating mentions from status: %s", err) + } + for _, menchie := range gtsMenchies { + if err := m.db.Put(menchie); err != nil { + return fmt.Errorf("error putting mentions in db: %s", err) + } + menchies = append(menchies, menchie.TargetAccountID) + } + // add full populated gts menchies to the status for passing them around conveniently + status.GTSMentions = gtsMenchies + // add just the ids of the mentioned accounts to the status for putting in the db + status.Mentions = menchies + return nil +} + +func (m *StatusModule) parseTags(form *advancedStatusCreateForm, accountID string, status *gtsmodel.Status) error { + tags := []string{} + gtsTags, err := m.db.TagStringsToTags(util.DeriveHashtags(form.Status), accountID, status.ID) + if err != nil { + return fmt.Errorf("error generating hashtags from status: %s", err) + } + for _, tag := range gtsTags { + if err := m.db.Upsert(tag, "name"); err != nil { + return fmt.Errorf("error putting tags in db: %s", err) + } + tags = append(tags, tag.ID) + } + // add full populated gts tags to the status for passing them around conveniently + status.GTSTags = gtsTags + // add just the ids of the used tags to the status for putting in the db + status.Tags = tags + return nil +} + +func (m *StatusModule) parseEmojis(form *advancedStatusCreateForm, accountID string, status *gtsmodel.Status) error { + emojis := []string{} + gtsEmojis, err := m.db.EmojiStringsToEmojis(util.DeriveEmojis(form.Status), accountID, status.ID) + if err != nil { + return fmt.Errorf("error generating emojis from status: %s", err) + } + for _, e := range gtsEmojis { + emojis = append(emojis, e.ID) + } + // add full populated gts emojis to the status for passing them around conveniently + status.GTSEmojis = gtsEmojis + // add just the ids of the used emojis to the status for putting in the db + status.Emojis = emojis + return nil +} diff --git a/internal/apimodule/status/statusdelete.go b/internal/apimodule/status/statusdelete.go @@ -0,0 +1,106 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package status + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/distributor" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +func (m *StatusModule) StatusDELETEHandler(c *gin.Context) { + l := m.log.WithFields(logrus.Fields{ + "func": "StatusDELETEHandler", + "request_uri": c.Request.RequestURI, + "user_agent": c.Request.UserAgent(), + "origin_ip": c.ClientIP(), + }) + l.Debugf("entering function") + + authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else + if err != nil { + l.Debug("not authed so can't delete status") + c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"}) + return + } + + targetStatusID := c.Param(IDKey) + if targetStatusID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"}) + return + } + + l.Tracef("going to search for target status %s", targetStatusID) + targetStatus := &gtsmodel.Status{} + if err := m.db.GetByID(targetStatusID, targetStatus); err != nil { + l.Errorf("error fetching status %s: %s", targetStatusID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + if targetStatus.AccountID != authed.Account.ID { + l.Debug("status doesn't belong to requesting account") + c.JSON(http.StatusForbidden, gin.H{"error": "not allowed"}) + return + } + + l.Trace("going to get relevant accounts") + relevantAccounts, err := m.db.PullRelevantAccountsFromStatus(targetStatus) + if err != nil { + l.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + var boostOfStatus *gtsmodel.Status + if targetStatus.BoostOfID != "" { + boostOfStatus = &gtsmodel.Status{} + if err := m.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { + l.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + } + + mastoStatus, err := m.mastoConverter.StatusToMasto(targetStatus, authed.Account, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) + if err != nil { + l.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + if err := m.db.DeleteByID(targetStatus.ID, targetStatus); err != nil { + l.Errorf("error deleting status from the database: %s", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + m.distributor.FromClientAPI() <- distributor.FromClientAPI{ + APObjectType: gtsmodel.ActivityStreamsNote, + APActivityType: gtsmodel.ActivityStreamsDelete, + Activity: targetStatus, + } + + c.JSON(http.StatusOK, mastoStatus) +} diff --git a/internal/apimodule/status/statusfave.go b/internal/apimodule/status/statusfave.go @@ -0,0 +1,136 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package status + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/distributor" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +func (m *StatusModule) StatusFavePOSTHandler(c *gin.Context) { + l := m.log.WithFields(logrus.Fields{ + "func": "StatusFavePOSTHandler", + "request_uri": c.Request.RequestURI, + "user_agent": c.Request.UserAgent(), + "origin_ip": c.ClientIP(), + }) + l.Debugf("entering function") + + authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else + if err != nil { + l.Debug("not authed so can't fave status") + c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"}) + return + } + + targetStatusID := c.Param(IDKey) + if targetStatusID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"}) + return + } + + l.Tracef("going to search for target status %s", targetStatusID) + targetStatus := &gtsmodel.Status{} + if err := m.db.GetByID(targetStatusID, targetStatus); err != nil { + l.Errorf("error fetching status %s: %s", targetStatusID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + l.Tracef("going to search for target account %s", targetStatus.AccountID) + targetAccount := &gtsmodel.Account{} + if err := m.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { + l.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + l.Trace("going to get relevant accounts") + relevantAccounts, err := m.db.PullRelevantAccountsFromStatus(targetStatus) + if err != nil { + l.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + l.Trace("going to see if status is visible") + visible, err := m.db.StatusVisible(targetStatus, targetAccount, authed.Account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that + if err != nil { + l.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + if !visible { + l.Trace("status is not visible") + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + // is the status faveable? + if !targetStatus.VisibilityAdvanced.Likeable { + l.Debug("status is not faveable") + c.JSON(http.StatusForbidden, gin.H{"error": fmt.Sprintf("status %s not faveable", targetStatusID)}) + return + } + + // it's visible! it's faveable! so let's fave the FUCK out of it + fave, err := m.db.FaveStatus(targetStatus, authed.Account.ID) + if err != nil { + l.Debugf("error faveing status: %s", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var boostOfStatus *gtsmodel.Status + if targetStatus.BoostOfID != "" { + boostOfStatus = &gtsmodel.Status{} + if err := m.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { + l.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + } + + mastoStatus, err := m.mastoConverter.StatusToMasto(targetStatus, targetAccount, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) + if err != nil { + l.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + // if the targeted status was already faved, faved will be nil + // only put the fave in the distributor if something actually changed + if fave != nil { + fave.FavedStatus = targetStatus // attach the status pointer to the fave for easy retrieval in the distributor + m.distributor.FromClientAPI() <- distributor.FromClientAPI{ + APObjectType: gtsmodel.ActivityStreamsNote, // status is a note + APActivityType: gtsmodel.ActivityStreamsLike, // we're creating a like/fave on the note + Activity: fave, // pass the fave along for processing + } + } + + c.JSON(http.StatusOK, mastoStatus) +} diff --git a/internal/apimodule/status/statusfavedby.go b/internal/apimodule/status/statusfavedby.go @@ -0,0 +1,128 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package status + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +func (m *StatusModule) StatusFavedByGETHandler(c *gin.Context) { + l := m.log.WithFields(logrus.Fields{ + "func": "statusGETHandler", + "request_uri": c.Request.RequestURI, + "user_agent": c.Request.UserAgent(), + "origin_ip": c.ClientIP(), + }) + l.Debugf("entering function") + + var requestingAccount *gtsmodel.Account + authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else + if err != nil { + l.Debug("not authed but will continue to serve anyway if public status") + requestingAccount = nil + } else { + requestingAccount = authed.Account + } + + targetStatusID := c.Param(IDKey) + if targetStatusID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"}) + return + } + + l.Tracef("going to search for target status %s", targetStatusID) + targetStatus := &gtsmodel.Status{} + if err := m.db.GetByID(targetStatusID, targetStatus); err != nil { + l.Errorf("error fetching status %s: %s", targetStatusID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + l.Tracef("going to search for target account %s", targetStatus.AccountID) + targetAccount := &gtsmodel.Account{} + if err := m.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { + l.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + l.Trace("going to get relevant accounts") + relevantAccounts, err := m.db.PullRelevantAccountsFromStatus(targetStatus) + if err != nil { + l.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + l.Trace("going to see if status is visible") + visible, err := m.db.StatusVisible(targetStatus, targetAccount, requestingAccount, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that + if err != nil { + l.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + if !visible { + l.Trace("status is not visible") + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + // get ALL accounts that faved a status -- doesn't take account of blocks and mutes and stuff + favingAccounts, err := m.db.WhoFavedStatus(targetStatus) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // filter the list so the user doesn't see accounts they blocked or which blocked them + filteredAccounts := []*gtsmodel.Account{} + for _, acc := range favingAccounts { + blocked, err := m.db.Blocked(authed.Account.ID, acc.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if !blocked { + filteredAccounts = append(filteredAccounts, acc) + } + } + + // TODO: filter other things here? suspended? muted? silenced? + + // now we can return the masto representation of those accounts + mastoAccounts := []*mastotypes.Account{} + for _, acc := range filteredAccounts { + mastoAccount, err := m.mastoConverter.AccountToMastoPublic(acc) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + mastoAccounts = append(mastoAccounts, mastoAccount) + } + + c.JSON(http.StatusOK, mastoAccounts) +} diff --git a/internal/apimodule/status/statusget.go b/internal/apimodule/status/statusget.go @@ -0,0 +1,111 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package status + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +func (m *StatusModule) StatusGETHandler(c *gin.Context) { + l := m.log.WithFields(logrus.Fields{ + "func": "statusGETHandler", + "request_uri": c.Request.RequestURI, + "user_agent": c.Request.UserAgent(), + "origin_ip": c.ClientIP(), + }) + l.Debugf("entering function") + + var requestingAccount *gtsmodel.Account + authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else + if err != nil { + l.Debug("not authed but will continue to serve anyway if public status") + requestingAccount = nil + } else { + requestingAccount = authed.Account + } + + targetStatusID := c.Param(IDKey) + if targetStatusID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"}) + return + } + + l.Tracef("going to search for target status %s", targetStatusID) + targetStatus := &gtsmodel.Status{} + if err := m.db.GetByID(targetStatusID, targetStatus); err != nil { + l.Errorf("error fetching status %s: %s", targetStatusID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + l.Tracef("going to search for target account %s", targetStatus.AccountID) + targetAccount := &gtsmodel.Account{} + if err := m.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { + l.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + l.Trace("going to get relevant accounts") + relevantAccounts, err := m.db.PullRelevantAccountsFromStatus(targetStatus) + if err != nil { + l.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + l.Trace("going to see if status is visible") + visible, err := m.db.StatusVisible(targetStatus, targetAccount, requestingAccount, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that + if err != nil { + l.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + if !visible { + l.Trace("status is not visible") + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + var boostOfStatus *gtsmodel.Status + if targetStatus.BoostOfID != "" { + boostOfStatus = &gtsmodel.Status{} + if err := m.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { + l.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + } + + mastoStatus, err := m.mastoConverter.StatusToMasto(targetStatus, targetAccount, requestingAccount, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) + if err != nil { + l.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + c.JSON(http.StatusOK, mastoStatus) +} diff --git a/internal/apimodule/status/statusunfave.go b/internal/apimodule/status/statusunfave.go @@ -0,0 +1,136 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package status + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/distributor" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +func (m *StatusModule) StatusUnfavePOSTHandler(c *gin.Context) { + l := m.log.WithFields(logrus.Fields{ + "func": "StatusUnfavePOSTHandler", + "request_uri": c.Request.RequestURI, + "user_agent": c.Request.UserAgent(), + "origin_ip": c.ClientIP(), + }) + l.Debugf("entering function") + + authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else + if err != nil { + l.Debug("not authed so can't unfave status") + c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"}) + return + } + + targetStatusID := c.Param(IDKey) + if targetStatusID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"}) + return + } + + l.Tracef("going to search for target status %s", targetStatusID) + targetStatus := &gtsmodel.Status{} + if err := m.db.GetByID(targetStatusID, targetStatus); err != nil { + l.Errorf("error fetching status %s: %s", targetStatusID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + l.Tracef("going to search for target account %s", targetStatus.AccountID) + targetAccount := &gtsmodel.Account{} + if err := m.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { + l.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + l.Trace("going to get relevant accounts") + relevantAccounts, err := m.db.PullRelevantAccountsFromStatus(targetStatus) + if err != nil { + l.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + l.Trace("going to see if status is visible") + visible, err := m.db.StatusVisible(targetStatus, targetAccount, authed.Account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that + if err != nil { + l.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + if !visible { + l.Trace("status is not visible") + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + // is the status faveable? + if !targetStatus.VisibilityAdvanced.Likeable { + l.Debug("status is not faveable") + c.JSON(http.StatusForbidden, gin.H{"error": fmt.Sprintf("status %s not faveable so therefore not unfave-able", targetStatusID)}) + return + } + + // it's visible! it's faveable! so let's unfave the FUCK out of it + fave, err := m.db.UnfaveStatus(targetStatus, authed.Account.ID) + if err != nil { + l.Debugf("error unfaveing status: %s", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var boostOfStatus *gtsmodel.Status + if targetStatus.BoostOfID != "" { + boostOfStatus = &gtsmodel.Status{} + if err := m.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { + l.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + } + + mastoStatus, err := m.mastoConverter.StatusToMasto(targetStatus, targetAccount, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) + if err != nil { + l.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + // fave might be nil if this status wasn't faved in the first place + // we only want to pass the message to the distributor if something actually changed + if fave != nil { + fave.FavedStatus = targetStatus // attach the status pointer to the fave for easy retrieval in the distributor + m.distributor.FromClientAPI() <- distributor.FromClientAPI{ + APObjectType: gtsmodel.ActivityStreamsNote, // status is a note + APActivityType: gtsmodel.ActivityStreamsUndo, // undo the fave + Activity: fave, // pass the undone fave along + } + } + + c.JSON(http.StatusOK, mastoStatus) +} diff --git a/internal/apimodule/status/test/statuscreate_test.go b/internal/apimodule/status/test/statuscreate_test.go @@ -0,0 +1,346 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package status + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/apimodule/status" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/distributor" + "github.com/superseriousbusiness/gotosocial/internal/mastotypes" + mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/storage" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type StatusCreateTestSuite struct { + // standard suite interfaces + suite.Suite + config *config.Config + db db.DB + log *logrus.Logger + storage storage.Storage + mastoConverter mastotypes.Converter + mediaHandler media.MediaHandler + oauthServer oauth.Server + distributor distributor.Distributor + + // standard suite models + testTokens map[string]*oauth.Token + testClients map[string]*oauth.Client + testApplications map[string]*gtsmodel.Application + testUsers map[string]*gtsmodel.User + testAccounts map[string]*gtsmodel.Account + testAttachments map[string]*gtsmodel.MediaAttachment + + // module being tested + statusModule *status.StatusModule +} + +/* + TEST INFRASTRUCTURE +*/ + +// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout +func (suite *StatusCreateTestSuite) SetupSuite() { + // setup standard items + suite.config = testrig.NewTestConfig() + suite.db = testrig.NewTestDB() + suite.log = testrig.NewTestLog() + suite.storage = testrig.NewTestStorage() + suite.mastoConverter = testrig.NewTestMastoConverter(suite.db) + suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage) + suite.oauthServer = testrig.NewTestOauthServer(suite.db) + suite.distributor = testrig.NewTestDistributor() + + // setup module being tested + suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.StatusModule) +} + +func (suite *StatusCreateTestSuite) TearDownSuite() { + testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) +} + +func (suite *StatusCreateTestSuite) SetupTest() { + testrig.StandardDBSetup(suite.db) + testrig.StandardStorageSetup(suite.storage, "../../../testrig/media") + suite.testTokens = testrig.NewTestTokens() + suite.testClients = testrig.NewTestClients() + suite.testApplications = testrig.NewTestApplications() + suite.testUsers = testrig.NewTestUsers() + suite.testAccounts = testrig.NewTestAccounts() + suite.testAttachments = testrig.NewTestAttachments() +} + +// TearDownTest drops tables to make sure there's no data in the db +func (suite *StatusCreateTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) +} + +/* + ACTUAL TESTS +*/ + +/* + TESTING: StatusCreatePOSTHandler +*/ + +// Post a new status with some custom visibility settings +func (suite *StatusCreateTestSuite) TestPostNewStatus() { + + t := suite.testTokens["local_account_1"] + oauthToken := oauth.PGTokenToOauthToken(t) + + // setup + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedToken, oauthToken) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting + ctx.Request.Form = url.Values{ + "status": {"this is a brand new status! #helloworld"}, + "spoiler_text": {"hello hello"}, + "sensitive": {"true"}, + "visibility_advanced": {"mutuals_only"}, + "likeable": {"false"}, + "replyable": {"false"}, + "federated": {"false"}, + } + suite.statusModule.StatusCreatePOSTHandler(ctx) + + // check response + + // 1. we should have OK from our call to the function + suite.EqualValues(http.StatusOK, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + assert.NoError(suite.T(), err) + + statusReply := &mastomodel.Status{} + err = json.Unmarshal(b, statusReply) + assert.NoError(suite.T(), err) + + assert.Equal(suite.T(), "hello hello", statusReply.SpoilerText) + assert.Equal(suite.T(), "this is a brand new status! #helloworld", statusReply.Content) + assert.True(suite.T(), statusReply.Sensitive) + assert.Equal(suite.T(), mastomodel.VisibilityPrivate, statusReply.Visibility) + assert.Len(suite.T(), statusReply.Tags, 1) + assert.Equal(suite.T(), mastomodel.Tag{ + Name: "helloworld", + URL: "http://localhost:8080/tags/helloworld", + }, statusReply.Tags[0]) + + gtsTag := &gtsmodel.Tag{} + err = suite.db.GetWhere("name", "helloworld", gtsTag) + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), statusReply.Account.ID, gtsTag.FirstSeenFromAccountID) +} + +func (suite *StatusCreateTestSuite) TestPostNewStatusWithEmoji() { + + t := suite.testTokens["local_account_1"] + oauthToken := oauth.PGTokenToOauthToken(t) + + // setup + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedToken, oauthToken) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting + ctx.Request.Form = url.Values{ + "status": {"here is a rainbow emoji a few times! :rainbow: :rainbow: :rainbow: \n here's an emoji that isn't in the db: :test_emoji: "}, + } + suite.statusModule.StatusCreatePOSTHandler(ctx) + + suite.EqualValues(http.StatusOK, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + assert.NoError(suite.T(), err) + + statusReply := &mastomodel.Status{} + err = json.Unmarshal(b, statusReply) + assert.NoError(suite.T(), err) + + assert.Equal(suite.T(), "", statusReply.SpoilerText) + assert.Equal(suite.T(), "here is a rainbow emoji a few times! :rainbow: :rainbow: :rainbow: \n here's an emoji that isn't in the db: :test_emoji: ", statusReply.Content) + + assert.Len(suite.T(), statusReply.Emojis, 1) + mastoEmoji := statusReply.Emojis[0] + gtsEmoji := testrig.NewTestEmojis()["rainbow"] + + assert.Equal(suite.T(), gtsEmoji.Shortcode, mastoEmoji.Shortcode) + assert.Equal(suite.T(), gtsEmoji.ImageURL, mastoEmoji.URL) + assert.Equal(suite.T(), gtsEmoji.ImageStaticURL, mastoEmoji.StaticURL) +} + +// Try to reply to a status that doesn't exist +func (suite *StatusCreateTestSuite) TestReplyToNonexistentStatus() { + t := suite.testTokens["local_account_1"] + oauthToken := oauth.PGTokenToOauthToken(t) + + // setup + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedToken, oauthToken) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting + ctx.Request.Form = url.Values{ + "status": {"this is a reply to a status that doesn't exist"}, + "spoiler_text": {"don't open cuz it won't work"}, + "in_reply_to_id": {"3759e7ef-8ee1-4c0c-86f6-8b70b9ad3d50"}, + } + suite.statusModule.StatusCreatePOSTHandler(ctx) + + // check response + + suite.EqualValues(http.StatusBadRequest, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), `{"error":"status with id 3759e7ef-8ee1-4c0c-86f6-8b70b9ad3d50 not replyable because it doesn't exist"}`, string(b)) +} + +// Post a reply to the status of a local user that allows replies. +func (suite *StatusCreateTestSuite) TestReplyToLocalStatus() { + t := suite.testTokens["local_account_1"] + oauthToken := oauth.PGTokenToOauthToken(t) + + // setup + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedToken, oauthToken) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting + ctx.Request.Form = url.Values{ + "status": {fmt.Sprintf("hello @%s this reply should work!", testrig.NewTestAccounts()["local_account_2"].Username)}, + "in_reply_to_id": {testrig.NewTestStatuses()["local_account_2_status_1"].ID}, + } + suite.statusModule.StatusCreatePOSTHandler(ctx) + + // check response + suite.EqualValues(http.StatusOK, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + assert.NoError(suite.T(), err) + + statusReply := &mastomodel.Status{} + err = json.Unmarshal(b, statusReply) + assert.NoError(suite.T(), err) + + assert.Equal(suite.T(), "", statusReply.SpoilerText) + assert.Equal(suite.T(), fmt.Sprintf("hello @%s this reply should work!", testrig.NewTestAccounts()["local_account_2"].Username), statusReply.Content) + assert.False(suite.T(), statusReply.Sensitive) + assert.Equal(suite.T(), mastomodel.VisibilityPublic, statusReply.Visibility) + assert.Equal(suite.T(), testrig.NewTestStatuses()["local_account_2_status_1"].ID, statusReply.InReplyToID) + assert.Equal(suite.T(), testrig.NewTestAccounts()["local_account_2"].ID, statusReply.InReplyToAccountID) + assert.Len(suite.T(), statusReply.Mentions, 1) +} + +// Take a media file which is currently not associated with a status, and attach it to a new status. +func (suite *StatusCreateTestSuite) TestAttachNewMediaSuccess() { + t := suite.testTokens["local_account_1"] + oauthToken := oauth.PGTokenToOauthToken(t) + + // setup + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedToken, oauthToken) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting + ctx.Request.Form = url.Values{ + "status": {"here's an image attachment"}, + "media_ids": {"7a3b9f77-ab30-461e-bdd8-e64bd1db3008"}, + } + suite.statusModule.StatusCreatePOSTHandler(ctx) + + // check response + suite.EqualValues(http.StatusOK, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + assert.NoError(suite.T(), err) + + fmt.Println(string(b)) + + statusReply := &mastomodel.Status{} + err = json.Unmarshal(b, statusReply) + assert.NoError(suite.T(), err) + + assert.Equal(suite.T(), "", statusReply.SpoilerText) + assert.Equal(suite.T(), "here's an image attachment", statusReply.Content) + assert.False(suite.T(), statusReply.Sensitive) + assert.Equal(suite.T(), mastomodel.VisibilityPublic, statusReply.Visibility) + + // there should be one media attachment + assert.Len(suite.T(), statusReply.MediaAttachments, 1) + + // get the updated media attachment from the database + gtsAttachment := &gtsmodel.MediaAttachment{} + err = suite.db.GetByID(statusReply.MediaAttachments[0].ID, gtsAttachment) + assert.NoError(suite.T(), err) + + // convert it to a masto attachment + gtsAttachmentAsMasto, err := suite.mastoConverter.AttachmentToMasto(gtsAttachment) + assert.NoError(suite.T(), err) + + // compare it with what we have now + assert.EqualValues(suite.T(), statusReply.MediaAttachments[0], gtsAttachmentAsMasto) + + // the status id of the attachment should now be set to the id of the status we just created + assert.Equal(suite.T(), statusReply.ID, gtsAttachment.StatusID) +} + +func TestStatusCreateTestSuite(t *testing.T) { + suite.Run(t, new(StatusCreateTestSuite)) +} diff --git a/internal/apimodule/status/test/statusfave_test.go b/internal/apimodule/status/test/statusfave_test.go @@ -0,0 +1,207 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package status + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/apimodule/status" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/distributor" + "github.com/superseriousbusiness/gotosocial/internal/mastotypes" + mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/storage" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type StatusFaveTestSuite struct { + // standard suite interfaces + suite.Suite + config *config.Config + db db.DB + log *logrus.Logger + storage storage.Storage + mastoConverter mastotypes.Converter + mediaHandler media.MediaHandler + oauthServer oauth.Server + distributor distributor.Distributor + + // standard suite models + testTokens map[string]*oauth.Token + testClients map[string]*oauth.Client + testApplications map[string]*gtsmodel.Application + testUsers map[string]*gtsmodel.User + testAccounts map[string]*gtsmodel.Account + testAttachments map[string]*gtsmodel.MediaAttachment + testStatuses map[string]*gtsmodel.Status + + // module being tested + statusModule *status.StatusModule +} + +/* + TEST INFRASTRUCTURE +*/ + +// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout +func (suite *StatusFaveTestSuite) SetupSuite() { + // setup standard items + suite.config = testrig.NewTestConfig() + suite.db = testrig.NewTestDB() + suite.log = testrig.NewTestLog() + suite.storage = testrig.NewTestStorage() + suite.mastoConverter = testrig.NewTestMastoConverter(suite.db) + suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage) + suite.oauthServer = testrig.NewTestOauthServer(suite.db) + suite.distributor = testrig.NewTestDistributor() + + // setup module being tested + suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.StatusModule) +} + +func (suite *StatusFaveTestSuite) TearDownSuite() { + testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) +} + +func (suite *StatusFaveTestSuite) SetupTest() { + testrig.StandardDBSetup(suite.db) + testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") + suite.testTokens = testrig.NewTestTokens() + suite.testClients = testrig.NewTestClients() + suite.testApplications = testrig.NewTestApplications() + suite.testUsers = testrig.NewTestUsers() + suite.testAccounts = testrig.NewTestAccounts() + suite.testAttachments = testrig.NewTestAttachments() + suite.testStatuses = testrig.NewTestStatuses() +} + +// TearDownTest drops tables to make sure there's no data in the db +func (suite *StatusFaveTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) +} + +/* + ACTUAL TESTS +*/ + +// fave a status +func (suite *StatusFaveTestSuite) TestPostFave() { + + t := suite.testTokens["local_account_1"] + oauthToken := oauth.PGTokenToOauthToken(t) + + targetStatus := suite.testStatuses["admin_account_status_2"] + + // setup + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedToken, oauthToken) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.FavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting + + // normally the router would populate these params from the path values, + // but because we're calling the function directly, we need to set them manually. + ctx.Params = gin.Params{ + gin.Param{ + Key: status.IDKey, + Value: targetStatus.ID, + }, + } + + suite.statusModule.StatusFavePOSTHandler(ctx) + + // check response + suite.EqualValues(http.StatusOK, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + assert.NoError(suite.T(), err) + + statusReply := &mastomodel.Status{} + err = json.Unmarshal(b, statusReply) + assert.NoError(suite.T(), err) + + assert.Equal(suite.T(), targetStatus.ContentWarning, statusReply.SpoilerText) + assert.Equal(suite.T(), targetStatus.Content, statusReply.Content) + assert.True(suite.T(), statusReply.Sensitive) + assert.Equal(suite.T(), mastomodel.VisibilityPublic, statusReply.Visibility) + assert.True(suite.T(), statusReply.Favourited) + assert.Equal(suite.T(), 1, statusReply.FavouritesCount) +} + +// try to fave a status that's not faveable +func (suite *StatusFaveTestSuite) TestPostUnfaveable() { + + t := suite.testTokens["local_account_1"] + oauthToken := oauth.PGTokenToOauthToken(t) + + targetStatus := suite.testStatuses["local_account_2_status_3"] // this one is unlikeable and unreplyable + + // setup + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedToken, oauthToken) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.FavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting + + // normally the router would populate these params from the path values, + // but because we're calling the function directly, we need to set them manually. + ctx.Params = gin.Params{ + gin.Param{ + Key: status.IDKey, + Value: targetStatus.ID, + }, + } + + suite.statusModule.StatusFavePOSTHandler(ctx) + + // check response + suite.EqualValues(http.StatusForbidden, recorder.Code) // we 403 unlikeable statuses + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), fmt.Sprintf(`{"error":"status %s not faveable"}`, targetStatus.ID), string(b)) +} + +func TestStatusFaveTestSuite(t *testing.T) { + suite.Run(t, new(StatusFaveTestSuite)) +} diff --git a/internal/apimodule/status/test/statusfavedby_test.go b/internal/apimodule/status/test/statusfavedby_test.go @@ -0,0 +1,159 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package status + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/apimodule/status" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/distributor" + "github.com/superseriousbusiness/gotosocial/internal/mastotypes" + mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/storage" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type StatusFavedByTestSuite struct { + // standard suite interfaces + suite.Suite + config *config.Config + db db.DB + log *logrus.Logger + storage storage.Storage + mastoConverter mastotypes.Converter + mediaHandler media.MediaHandler + oauthServer oauth.Server + distributor distributor.Distributor + + // standard suite models + testTokens map[string]*oauth.Token + testClients map[string]*oauth.Client + testApplications map[string]*gtsmodel.Application + testUsers map[string]*gtsmodel.User + testAccounts map[string]*gtsmodel.Account + testAttachments map[string]*gtsmodel.MediaAttachment + testStatuses map[string]*gtsmodel.Status + + // module being tested + statusModule *status.StatusModule +} + +// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout +func (suite *StatusFavedByTestSuite) SetupSuite() { + // setup standard items + suite.config = testrig.NewTestConfig() + suite.db = testrig.NewTestDB() + suite.log = testrig.NewTestLog() + suite.storage = testrig.NewTestStorage() + suite.mastoConverter = testrig.NewTestMastoConverter(suite.db) + suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage) + suite.oauthServer = testrig.NewTestOauthServer(suite.db) + suite.distributor = testrig.NewTestDistributor() + + // setup module being tested + suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.StatusModule) +} + +func (suite *StatusFavedByTestSuite) TearDownSuite() { + testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) +} + +func (suite *StatusFavedByTestSuite) SetupTest() { + testrig.StandardDBSetup(suite.db) + testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") + suite.testTokens = testrig.NewTestTokens() + suite.testClients = testrig.NewTestClients() + suite.testApplications = testrig.NewTestApplications() + suite.testUsers = testrig.NewTestUsers() + suite.testAccounts = testrig.NewTestAccounts() + suite.testAttachments = testrig.NewTestAttachments() + suite.testStatuses = testrig.NewTestStatuses() +} + +// TearDownTest drops tables to make sure there's no data in the db +func (suite *StatusFavedByTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) +} + +/* + ACTUAL TESTS +*/ + +func (suite *StatusFavedByTestSuite) TestGetFavedBy() { + t := suite.testTokens["local_account_2"] + oauthToken := oauth.PGTokenToOauthToken(t) + + targetStatus := suite.testStatuses["admin_account_status_1"] // this status is faved by local_account_1 + + // setup + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_2"]) + ctx.Set(oauth.SessionAuthorizedToken, oauthToken) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_2"]) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_2"]) + ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.FavouritedPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting + + // normally the router would populate these params from the path values, + // but because we're calling the function directly, we need to set them manually. + ctx.Params = gin.Params{ + gin.Param{ + Key: status.IDKey, + Value: targetStatus.ID, + }, + } + + suite.statusModule.StatusFavedByGETHandler(ctx) + + // check response + suite.EqualValues(http.StatusOK, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + assert.NoError(suite.T(), err) + + accts := []mastomodel.Account{} + err = json.Unmarshal(b, &accts) + assert.NoError(suite.T(), err) + + assert.Len(suite.T(), accts, 1) + assert.Equal(suite.T(), "the_mighty_zork", accts[0].Username) +} + +func TestStatusFavedByTestSuite(t *testing.T) { + suite.Run(t, new(StatusFavedByTestSuite)) +} diff --git a/internal/apimodule/status/test/statusget_test.go b/internal/apimodule/status/test/statusget_test.go @@ -0,0 +1,168 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package status + +import ( + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/apimodule/status" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/distributor" + "github.com/superseriousbusiness/gotosocial/internal/mastotypes" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/storage" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type StatusGetTestSuite struct { + // standard suite interfaces + suite.Suite + config *config.Config + db db.DB + log *logrus.Logger + storage storage.Storage + mastoConverter mastotypes.Converter + mediaHandler media.MediaHandler + oauthServer oauth.Server + distributor distributor.Distributor + + // standard suite models + testTokens map[string]*oauth.Token + testClients map[string]*oauth.Client + testApplications map[string]*gtsmodel.Application + testUsers map[string]*gtsmodel.User + testAccounts map[string]*gtsmodel.Account + testAttachments map[string]*gtsmodel.MediaAttachment + + // module being tested + statusModule *status.StatusModule +} + +/* + TEST INFRASTRUCTURE +*/ + +// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout +func (suite *StatusGetTestSuite) SetupSuite() { + // setup standard items + suite.config = testrig.NewTestConfig() + suite.db = testrig.NewTestDB() + suite.log = testrig.NewTestLog() + suite.storage = testrig.NewTestStorage() + suite.mastoConverter = testrig.NewTestMastoConverter(suite.db) + suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage) + suite.oauthServer = testrig.NewTestOauthServer(suite.db) + suite.distributor = testrig.NewTestDistributor() + + // setup module being tested + suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.StatusModule) +} + +func (suite *StatusGetTestSuite) TearDownSuite() { + testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) +} + +func (suite *StatusGetTestSuite) SetupTest() { + testrig.StandardDBSetup(suite.db) + testrig.StandardStorageSetup(suite.storage, "../../../testrig/media") + suite.testTokens = testrig.NewTestTokens() + suite.testClients = testrig.NewTestClients() + suite.testApplications = testrig.NewTestApplications() + suite.testUsers = testrig.NewTestUsers() + suite.testAccounts = testrig.NewTestAccounts() + suite.testAttachments = testrig.NewTestAttachments() +} + +// TearDownTest drops tables to make sure there's no data in the db +func (suite *StatusGetTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) +} + +/* + ACTUAL TESTS +*/ + +/* + TESTING: StatusGetPOSTHandler +*/ + +// Post a new status with some custom visibility settings +func (suite *StatusGetTestSuite) TestPostNewStatus() { + + // t := suite.testTokens["local_account_1"] + // oauthToken := oauth.PGTokenToOauthToken(t) + + // // setup + // recorder := httptest.NewRecorder() + // ctx, _ := gin.CreateTestContext(recorder) + // ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + // ctx.Set(oauth.SessionAuthorizedToken, oauthToken) + // ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + // ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + // ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", basePath), nil) // the endpoint we're hitting + // ctx.Request.Form = url.Values{ + // "status": {"this is a brand new status! #helloworld"}, + // "spoiler_text": {"hello hello"}, + // "sensitive": {"true"}, + // "visibility_advanced": {"mutuals_only"}, + // "likeable": {"false"}, + // "replyable": {"false"}, + // "federated": {"false"}, + // } + // suite.statusModule.statusGETHandler(ctx) + + // // check response + + // // 1. we should have OK from our call to the function + // suite.EqualValues(http.StatusOK, recorder.Code) + + // result := recorder.Result() + // defer result.Body.Close() + // b, err := ioutil.ReadAll(result.Body) + // assert.NoError(suite.T(), err) + + // statusReply := &mastomodel.Status{} + // err = json.Unmarshal(b, statusReply) + // assert.NoError(suite.T(), err) + + // assert.Equal(suite.T(), "hello hello", statusReply.SpoilerText) + // assert.Equal(suite.T(), "this is a brand new status! #helloworld", statusReply.Content) + // assert.True(suite.T(), statusReply.Sensitive) + // assert.Equal(suite.T(), mastomodel.VisibilityPrivate, statusReply.Visibility) + // assert.Len(suite.T(), statusReply.Tags, 1) + // assert.Equal(suite.T(), mastomodel.Tag{ + // Name: "helloworld", + // URL: "http://localhost:8080/tags/helloworld", + // }, statusReply.Tags[0]) + + // gtsTag := &gtsmodel.Tag{} + // err = suite.db.GetWhere("name", "helloworld", gtsTag) + // assert.NoError(suite.T(), err) + // assert.Equal(suite.T(), statusReply.Account.ID, gtsTag.FirstSeenFromAccountID) +} + +func TestStatusGetTestSuite(t *testing.T) { + suite.Run(t, new(StatusGetTestSuite)) +} diff --git a/internal/apimodule/status/test/statusunfave_test.go b/internal/apimodule/status/test/statusunfave_test.go @@ -0,0 +1,219 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package status + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/apimodule/status" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/distributor" + "github.com/superseriousbusiness/gotosocial/internal/mastotypes" + mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/storage" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type StatusUnfaveTestSuite struct { + // standard suite interfaces + suite.Suite + config *config.Config + db db.DB + log *logrus.Logger + storage storage.Storage + mastoConverter mastotypes.Converter + mediaHandler media.MediaHandler + oauthServer oauth.Server + distributor distributor.Distributor + + // standard suite models + testTokens map[string]*oauth.Token + testClients map[string]*oauth.Client + testApplications map[string]*gtsmodel.Application + testUsers map[string]*gtsmodel.User + testAccounts map[string]*gtsmodel.Account + testAttachments map[string]*gtsmodel.MediaAttachment + testStatuses map[string]*gtsmodel.Status + + // module being tested + statusModule *status.StatusModule +} + +/* + TEST INFRASTRUCTURE +*/ + +// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout +func (suite *StatusUnfaveTestSuite) SetupSuite() { + // setup standard items + suite.config = testrig.NewTestConfig() + suite.db = testrig.NewTestDB() + suite.log = testrig.NewTestLog() + suite.storage = testrig.NewTestStorage() + suite.mastoConverter = testrig.NewTestMastoConverter(suite.db) + suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage) + suite.oauthServer = testrig.NewTestOauthServer(suite.db) + suite.distributor = testrig.NewTestDistributor() + + // setup module being tested + suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.StatusModule) +} + +func (suite *StatusUnfaveTestSuite) TearDownSuite() { + testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) +} + +func (suite *StatusUnfaveTestSuite) SetupTest() { + testrig.StandardDBSetup(suite.db) + testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") + suite.testTokens = testrig.NewTestTokens() + suite.testClients = testrig.NewTestClients() + suite.testApplications = testrig.NewTestApplications() + suite.testUsers = testrig.NewTestUsers() + suite.testAccounts = testrig.NewTestAccounts() + suite.testAttachments = testrig.NewTestAttachments() + suite.testStatuses = testrig.NewTestStatuses() +} + +// TearDownTest drops tables to make sure there's no data in the db +func (suite *StatusUnfaveTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) +} + +/* + ACTUAL TESTS +*/ + +// unfave a status +func (suite *StatusUnfaveTestSuite) TestPostUnfave() { + + t := suite.testTokens["local_account_1"] + oauthToken := oauth.PGTokenToOauthToken(t) + + // this is the status we wanna unfave: in the testrig it's already faved by this account + targetStatus := suite.testStatuses["admin_account_status_1"] + + // setup + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedToken, oauthToken) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.UnfavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting + + // normally the router would populate these params from the path values, + // but because we're calling the function directly, we need to set them manually. + ctx.Params = gin.Params{ + gin.Param{ + Key: status.IDKey, + Value: targetStatus.ID, + }, + } + + suite.statusModule.StatusUnfavePOSTHandler(ctx) + + // check response + suite.EqualValues(http.StatusOK, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + assert.NoError(suite.T(), err) + + statusReply := &mastomodel.Status{} + err = json.Unmarshal(b, statusReply) + assert.NoError(suite.T(), err) + + assert.Equal(suite.T(), targetStatus.ContentWarning, statusReply.SpoilerText) + assert.Equal(suite.T(), targetStatus.Content, statusReply.Content) + assert.False(suite.T(), statusReply.Sensitive) + assert.Equal(suite.T(), mastomodel.VisibilityPublic, statusReply.Visibility) + assert.False(suite.T(), statusReply.Favourited) + assert.Equal(suite.T(), 0, statusReply.FavouritesCount) +} + +// try to unfave a status that's already not faved +func (suite *StatusUnfaveTestSuite) TestPostAlreadyNotFaved() { + + t := suite.testTokens["local_account_1"] + oauthToken := oauth.PGTokenToOauthToken(t) + + // this is the status we wanna unfave: in the testrig it's not faved by this account + targetStatus := suite.testStatuses["admin_account_status_2"] + + // setup + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedToken, oauthToken) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.UnfavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting + + // normally the router would populate these params from the path values, + // but because we're calling the function directly, we need to set them manually. + ctx.Params = gin.Params{ + gin.Param{ + Key: status.IDKey, + Value: targetStatus.ID, + }, + } + + suite.statusModule.StatusUnfavePOSTHandler(ctx) + + // check response + suite.EqualValues(http.StatusOK, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + assert.NoError(suite.T(), err) + + statusReply := &mastomodel.Status{} + err = json.Unmarshal(b, statusReply) + assert.NoError(suite.T(), err) + + assert.Equal(suite.T(), targetStatus.ContentWarning, statusReply.SpoilerText) + assert.Equal(suite.T(), targetStatus.Content, statusReply.Content) + assert.True(suite.T(), statusReply.Sensitive) + assert.Equal(suite.T(), mastomodel.VisibilityPublic, statusReply.Visibility) + assert.False(suite.T(), statusReply.Favourited) + assert.Equal(suite.T(), 0, statusReply.FavouritesCount) +} + +func TestStatusUnfaveTestSuite(t *testing.T) { + suite.Run(t, new(StatusUnfaveTestSuite)) +} diff --git a/internal/config/config.go b/internal/config/config.go @@ -36,6 +36,7 @@ type Config struct { AccountsConfig *AccountsConfig `yaml:"accounts"` MediaConfig *MediaConfig `yaml:"media"` StorageConfig *StorageConfig `yaml:"storage"` + StatusesConfig *StatusesConfig `yaml:"statuses"` } // FromFile returns a new config from a file, or an error if something goes amiss. @@ -50,7 +51,7 @@ func FromFile(path string) (*Config, error) { return Empty(), nil } -// Empty just returns an empty config +// Empty just returns a new empty config func Empty() *Config { return &Config{ DBConfig: &DBConfig{}, @@ -58,6 +59,7 @@ func Empty() *Config { AccountsConfig: &AccountsConfig{}, MediaConfig: &MediaConfig{}, StorageConfig: &StorageConfig{}, + StatusesConfig: &StatusesConfig{}, } } @@ -140,8 +142,8 @@ func (c *Config) ParseCLIFlags(f KeyedFlags) { c.AccountsConfig.OpenRegistration = f.Bool(fn.AccountsOpenRegistration) } - if f.IsSet(fn.AccountsRequireApproval) { - c.AccountsConfig.RequireApproval = f.Bool(fn.AccountsRequireApproval) + if f.IsSet(fn.AccountsApprovalRequired) { + c.AccountsConfig.RequireApproval = f.Bool(fn.AccountsApprovalRequired) } // media flags @@ -153,6 +155,14 @@ func (c *Config) ParseCLIFlags(f KeyedFlags) { c.MediaConfig.MaxVideoSize = f.Int(fn.MediaMaxVideoSize) } + if c.MediaConfig.MinDescriptionChars == 0 || f.IsSet(fn.MediaMinDescriptionChars) { + c.MediaConfig.MinDescriptionChars = f.Int(fn.MediaMinDescriptionChars) + } + + if c.MediaConfig.MaxDescriptionChars == 0 || f.IsSet(fn.MediaMaxDescriptionChars) { + c.MediaConfig.MaxDescriptionChars = f.Int(fn.MediaMaxDescriptionChars) + } + // storage flags if c.StorageConfig.Backend == "" || f.IsSet(fn.StorageBackend) { c.StorageConfig.Backend = f.String(fn.StorageBackend) @@ -173,6 +183,23 @@ func (c *Config) ParseCLIFlags(f KeyedFlags) { if c.StorageConfig.ServeBasePath == "" || f.IsSet(fn.StorageServeBasePath) { c.StorageConfig.ServeBasePath = f.String(fn.StorageServeBasePath) } + + // statuses flags + if c.StatusesConfig.MaxChars == 0 || f.IsSet(fn.StatusesMaxChars) { + c.StatusesConfig.MaxChars = f.Int(fn.StatusesMaxChars) + } + if c.StatusesConfig.CWMaxChars == 0 || f.IsSet(fn.StatusesCWMaxChars) { + c.StatusesConfig.CWMaxChars = f.Int(fn.StatusesCWMaxChars) + } + if c.StatusesConfig.PollMaxOptions == 0 || f.IsSet(fn.StatusesPollMaxOptions) { + c.StatusesConfig.PollMaxOptions = f.Int(fn.StatusesPollMaxOptions) + } + if c.StatusesConfig.PollOptionMaxChars == 0 || f.IsSet(fn.StatusesPollOptionMaxChars) { + c.StatusesConfig.PollOptionMaxChars = f.Int(fn.StatusesPollOptionMaxChars) + } + if c.StatusesConfig.MaxMediaFiles == 0 || f.IsSet(fn.StatusesMaxMediaFiles) { + c.StatusesConfig.MaxMediaFiles = f.Int(fn.StatusesMaxMediaFiles) + } } // KeyedFlags is a wrapper for any type that can store keyed flags and give them back. @@ -203,16 +230,63 @@ type Flags struct { TemplateBaseDir string AccountsOpenRegistration string - AccountsRequireApproval string + AccountsApprovalRequired string + AccountsReasonRequired string - MediaMaxImageSize string - MediaMaxVideoSize string + MediaMaxImageSize string + MediaMaxVideoSize string + MediaMinDescriptionChars string + MediaMaxDescriptionChars string StorageBackend string StorageBasePath string StorageServeProtocol string StorageServeHost string StorageServeBasePath string + + StatusesMaxChars string + StatusesCWMaxChars string + StatusesPollMaxOptions string + StatusesPollOptionMaxChars string + StatusesMaxMediaFiles string +} + +type Defaults struct { + LogLevel string + ApplicationName string + ConfigPath string + Host string + Protocol string + + DbType string + DbAddress string + DbPort int + DbUser string + DbPassword string + DbDatabase string + + TemplateBaseDir string + + AccountsOpenRegistration bool + AccountsRequireApproval bool + AccountsReasonRequired bool + + MediaMaxImageSize int + MediaMaxVideoSize int + MediaMinDescriptionChars int + MediaMaxDescriptionChars int + + StorageBackend string + StorageBasePath string + StorageServeProtocol string + StorageServeHost string + StorageServeBasePath string + + StatusesMaxChars int + StatusesCWMaxChars int + StatusesPollMaxOptions int + StatusesPollOptionMaxChars int + StatusesMaxMediaFiles int } // GetFlagNames returns a struct containing the names of the various flags used for @@ -235,16 +309,25 @@ func GetFlagNames() Flags { TemplateBaseDir: "template-basedir", AccountsOpenRegistration: "accounts-open-registration", - AccountsRequireApproval: "accounts-require-approval", + AccountsApprovalRequired: "accounts-approval-required", + AccountsReasonRequired: "accounts-reason-required", - MediaMaxImageSize: "media-max-image-size", - MediaMaxVideoSize: "media-max-video-size", + MediaMaxImageSize: "media-max-image-size", + MediaMaxVideoSize: "media-max-video-size", + MediaMinDescriptionChars: "media-min-description-chars", + MediaMaxDescriptionChars: "media-max-description-chars", StorageBackend: "storage-backend", StorageBasePath: "storage-base-path", StorageServeProtocol: "storage-serve-protocol", StorageServeHost: "storage-serve-host", StorageServeBasePath: "storage-serve-base-path", + + StatusesMaxChars: "statuses-max-chars", + StatusesCWMaxChars: "statuses-cw-max-chars", + StatusesPollMaxOptions: "statuses-poll-max-options", + StatusesPollOptionMaxChars: "statuses-poll-option-max-chars", + StatusesMaxMediaFiles: "statuses-max-media-files", } } @@ -268,15 +351,24 @@ func GetEnvNames() Flags { TemplateBaseDir: "GTS_TEMPLATE_BASEDIR", AccountsOpenRegistration: "GTS_ACCOUNTS_OPEN_REGISTRATION", - AccountsRequireApproval: "GTS_ACCOUNTS_REQUIRE_APPROVAL", + AccountsApprovalRequired: "GTS_ACCOUNTS_APPROVAL_REQUIRED", + AccountsReasonRequired: "GTS_ACCOUNTS_REASON_REQUIRED", - MediaMaxImageSize: "GTS_MEDIA_MAX_IMAGE_SIZE", - MediaMaxVideoSize: "GTS_MEDIA_MAX_VIDEO_SIZE", + MediaMaxImageSize: "GTS_MEDIA_MAX_IMAGE_SIZE", + MediaMaxVideoSize: "GTS_MEDIA_MAX_VIDEO_SIZE", + MediaMinDescriptionChars: "GTS_MEDIA_MIN_DESCRIPTION_CHARS", + MediaMaxDescriptionChars: "GTS_MEDIA_MAX_DESCRIPTION_CHARS", StorageBackend: "GTS_STORAGE_BACKEND", StorageBasePath: "GTS_STORAGE_BASE_PATH", StorageServeProtocol: "GTS_STORAGE_SERVE_PROTOCOL", StorageServeHost: "GTS_STORAGE_SERVE_HOST", StorageServeBasePath: "GTS_STORAGE_SERVE_BASE_PATH", + + StatusesMaxChars: "GTS_STATUSES_MAX_CHARS", + StatusesCWMaxChars: "GTS_STATUSES_CW_MAX_CHARS", + StatusesPollMaxOptions: "GTS_STATUSES_POLL_MAX_OPTIONS", + StatusesPollOptionMaxChars: "GTS_STATUSES_POLL_OPTION_MAX_CHARS", + StatusesMaxMediaFiles: "GTS_STATUSES_MAX_MEDIA_FILES", } } diff --git a/internal/config/default.go b/internal/config/default.go @@ -0,0 +1,177 @@ +package config + +// TestDefault returns a default config for testing +func TestDefault() *Config { + defaults := GetTestDefaults() + return &Config{ + LogLevel: defaults.LogLevel, + ApplicationName: defaults.ApplicationName, + Host: defaults.Host, + Protocol: defaults.Protocol, + DBConfig: &DBConfig{ + Type: defaults.DbType, + Address: defaults.DbAddress, + Port: defaults.DbPort, + User: defaults.DbUser, + Password: defaults.DbPassword, + Database: defaults.DbDatabase, + ApplicationName: defaults.ApplicationName, + }, + TemplateConfig: &TemplateConfig{ + BaseDir: defaults.TemplateBaseDir, + }, + AccountsConfig: &AccountsConfig{ + OpenRegistration: defaults.AccountsOpenRegistration, + RequireApproval: defaults.AccountsRequireApproval, + ReasonRequired: defaults.AccountsReasonRequired, + }, + MediaConfig: &MediaConfig{ + MaxImageSize: defaults.MediaMaxImageSize, + MaxVideoSize: defaults.MediaMaxVideoSize, + MinDescriptionChars: defaults.MediaMinDescriptionChars, + MaxDescriptionChars: defaults.MediaMaxDescriptionChars, + }, + StorageConfig: &StorageConfig{ + Backend: defaults.StorageBackend, + BasePath: defaults.StorageBasePath, + ServeProtocol: defaults.StorageServeProtocol, + ServeHost: defaults.StorageServeHost, + ServeBasePath: defaults.StorageServeBasePath, + }, + StatusesConfig: &StatusesConfig{ + MaxChars: defaults.StatusesMaxChars, + CWMaxChars: defaults.StatusesCWMaxChars, + PollMaxOptions: defaults.StatusesPollMaxOptions, + PollOptionMaxChars: defaults.StatusesPollOptionMaxChars, + MaxMediaFiles: defaults.StatusesMaxMediaFiles, + }, + } +} + +// Default returns a config with all default values set +func Default() *Config { + defaults := GetDefaults() + return &Config{ + LogLevel: defaults.LogLevel, + ApplicationName: defaults.ApplicationName, + Host: defaults.Host, + Protocol: defaults.Protocol, + DBConfig: &DBConfig{ + Type: defaults.DbType, + Address: defaults.DbAddress, + Port: defaults.DbPort, + User: defaults.DbUser, + Password: defaults.DbPassword, + Database: defaults.DbDatabase, + ApplicationName: defaults.ApplicationName, + }, + TemplateConfig: &TemplateConfig{ + BaseDir: defaults.TemplateBaseDir, + }, + AccountsConfig: &AccountsConfig{ + OpenRegistration: defaults.AccountsOpenRegistration, + RequireApproval: defaults.AccountsRequireApproval, + ReasonRequired: defaults.AccountsReasonRequired, + }, + MediaConfig: &MediaConfig{ + MaxImageSize: defaults.MediaMaxImageSize, + MaxVideoSize: defaults.MediaMaxVideoSize, + MinDescriptionChars: defaults.MediaMinDescriptionChars, + MaxDescriptionChars: defaults.MediaMaxDescriptionChars, + }, + StorageConfig: &StorageConfig{ + Backend: defaults.StorageBackend, + BasePath: defaults.StorageBasePath, + ServeProtocol: defaults.StorageServeProtocol, + ServeHost: defaults.StorageServeHost, + ServeBasePath: defaults.StorageServeBasePath, + }, + StatusesConfig: &StatusesConfig{ + MaxChars: defaults.StatusesMaxChars, + CWMaxChars: defaults.StatusesCWMaxChars, + PollMaxOptions: defaults.StatusesPollMaxOptions, + PollOptionMaxChars: defaults.StatusesPollOptionMaxChars, + MaxMediaFiles: defaults.StatusesMaxMediaFiles, + }, + } +} + +func GetDefaults() Defaults { + return Defaults{ + LogLevel: "info", + ApplicationName: "gotosocial", + ConfigPath: "", + Host: "", + Protocol: "https", + + DbType: "postgres", + DbAddress: "localhost", + DbPort: 5432, + DbUser: "postgres", + DbPassword: "postgres", + DbDatabase: "postgres", + + TemplateBaseDir: "./web/template/", + + AccountsOpenRegistration: true, + AccountsRequireApproval: true, + AccountsReasonRequired: true, + + MediaMaxImageSize: 2097152, //2mb + MediaMaxVideoSize: 10485760, //10mb + MediaMinDescriptionChars: 0, + MediaMaxDescriptionChars: 500, + + StorageBackend: "local", + StorageBasePath: "/gotosocial/storage", + StorageServeProtocol: "https", + StorageServeHost: "localhost", + StorageServeBasePath: "/fileserver", + + StatusesMaxChars: 5000, + StatusesCWMaxChars: 100, + StatusesPollMaxOptions: 6, + StatusesPollOptionMaxChars: 50, + StatusesMaxMediaFiles: 6, + } +} + +func GetTestDefaults() Defaults { + return Defaults{ + LogLevel: "trace", + ApplicationName: "gotosocial", + ConfigPath: "", + Host: "localhost:8080", + Protocol: "http", + + DbType: "postgres", + DbAddress: "localhost", + DbPort: 5432, + DbUser: "postgres", + DbPassword: "postgres", + DbDatabase: "postgres", + + TemplateBaseDir: "./web/template/", + + AccountsOpenRegistration: true, + AccountsRequireApproval: true, + AccountsReasonRequired: true, + + MediaMaxImageSize: 1048576, //1mb + MediaMaxVideoSize: 5242880, //5mb + MediaMinDescriptionChars: 0, + MediaMaxDescriptionChars: 500, + + StorageBackend: "local", + StorageBasePath: "/gotosocial/storage", + StorageServeProtocol: "http", + StorageServeHost: "localhost:8080", + StorageServeBasePath: "/fileserver", + + StatusesMaxChars: 5000, + StatusesCWMaxChars: 100, + StatusesPollMaxOptions: 6, + StatusesPollOptionMaxChars: 50, + StatusesMaxMediaFiles: 6, + } +} diff --git a/internal/config/media.go b/internal/config/media.go @@ -24,4 +24,8 @@ type MediaConfig struct { MaxImageSize int `yaml:"maxImageSize"` // Max size of uploaded video in bytes MaxVideoSize int `yaml:"maxVideoSize"` + // Minimum amount of chars required in an image description + MinDescriptionChars int `yaml:"minDescriptionChars"` + // Max amount of chars allowed in an image description + MaxDescriptionChars int `yaml:"maxDescriptionChars"` } diff --git a/internal/config/statuses.go b/internal/config/statuses.go @@ -0,0 +1,33 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package config + +// StatusesConfig pertains to posting/deleting/interacting with statuses +type StatusesConfig struct { + // Maximum amount of characters allowed in a status, excluding CW + MaxChars int `yaml:"max_chars"` + // Maximum amount of characters allowed in a content-warning/spoiler field + CWMaxChars int `yaml:"cw_max_chars"` + // Maximum number of options allowed in a poll + PollMaxOptions int `yaml:"poll_max_options"` + // Maximum characters allowed per poll option + PollOptionMaxChars int `yaml:"poll_option_max_chars"` + // Maximum amount of media files allowed to be attached to one status + MaxMediaFiles int `yaml:"max_media_files"` +} diff --git a/internal/db/db.go b/internal/db/db.go @@ -27,8 +27,7 @@ import ( "github.com/go-fed/activity/pub" "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db/model" - "github.com/superseriousbusiness/gotosocial/pkg/mastotypes" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" ) const dbTypePostgres string = "POSTGRES" @@ -79,6 +78,11 @@ type DB interface { // In case of no entries, a 'no entries' error will be returned GetWhere(key string, value interface{}, i interface{}) error + // // GetWhereMany gets one entry where key = value for *ALL* parameters passed as "where". + // // That is, if you pass 2 'where' entries, with 1 being Key username and Value test, and the second + // // being Key domain and Value example.org, only entries will be returned where BOTH conditions are true. + // GetWhereMany(i interface{}, where ...model.Where) error + // GetAll will try to get all entries of type i. // The given interface i will be set to the result of the query, whatever it is. Use a pointer or a slice. // In case of no entries, a 'no entries' error will be returned @@ -88,6 +92,11 @@ type DB interface { // The given interface i will be set to the result of the query, whatever it is. Use a pointer or a slice. Put(i interface{}) error + // Upsert stores or updates i based on the given conflict column, as in https://www.postgresqltutorial.com/postgresql-upsert/ + // It is up to the implementation to figure out how to store it, and using what key. + // The given interface i will be set to the result of the query, whatever it is. Use a pointer or a slice. + Upsert(i interface{}, conflictColumn string) error + // UpdateByID updates i with id id. // The given interface i will be set to the result of the query, whatever it is. Use a pointer or a slice. UpdateByID(id string, i interface{}) error @@ -107,41 +116,46 @@ type DB interface { HANDY SHORTCUTS */ + // CreateInstanceAccount creates an account in the database with the same username as the instance host value. + // Ie., if the instance is hosted at 'example.org' the instance user will have a username of 'example.org'. + // This is needed for things like serving files that belong to the instance and not an individual user/account. + CreateInstanceAccount() error + // GetAccountByUserID is a shortcut for the common action of fetching an account corresponding to a user ID. // The given account pointer will be set to the result of the query, whatever it is. // In case of no entries, a 'no entries' error will be returned - GetAccountByUserID(userID string, account *model.Account) error + GetAccountByUserID(userID string, account *gtsmodel.Account) error // GetFollowRequestsForAccountID is a shortcut for the common action of fetching a list of follow requests targeting the given account ID. // The given slice 'followRequests' will be set to the result of the query, whatever it is. // In case of no entries, a 'no entries' error will be returned - GetFollowRequestsForAccountID(accountID string, followRequests *[]model.FollowRequest) error + GetFollowRequestsForAccountID(accountID string, followRequests *[]gtsmodel.FollowRequest) error // GetFollowingByAccountID is a shortcut for the common action of fetching a list of accounts that accountID is following. // The given slice 'following' will be set to the result of the query, whatever it is. // In case of no entries, a 'no entries' error will be returned - GetFollowingByAccountID(accountID string, following *[]model.Follow) error + GetFollowingByAccountID(accountID string, following *[]gtsmodel.Follow) error // GetFollowersByAccountID is a shortcut for the common action of fetching a list of accounts that accountID is followed by. // The given slice 'followers' will be set to the result of the query, whatever it is. // In case of no entries, a 'no entries' error will be returned - GetFollowersByAccountID(accountID string, followers *[]model.Follow) error + GetFollowersByAccountID(accountID string, followers *[]gtsmodel.Follow) error // GetStatusesByAccountID is a shortcut for the common action of fetching a list of statuses produced by accountID. // The given slice 'statuses' will be set to the result of the query, whatever it is. // In case of no entries, a 'no entries' error will be returned - GetStatusesByAccountID(accountID string, statuses *[]model.Status) error + GetStatusesByAccountID(accountID string, statuses *[]gtsmodel.Status) error // GetStatusesByTimeDescending is a shortcut for getting the most recent statuses. accountID is optional, if not provided // then all statuses will be returned. If limit is set to 0, the size of the returned slice will not be limited. This can // be very memory intensive so you probably shouldn't do this! // In case of no entries, a 'no entries' error will be returned - GetStatusesByTimeDescending(accountID string, statuses *[]model.Status, limit int) error + GetStatusesByTimeDescending(accountID string, statuses *[]gtsmodel.Status, limit int) error // GetLastStatusForAccountID simply gets the most recent status by the given account. // The given slice 'status' pointer will be set to the result of the query, whatever it is. // In case of no entries, a 'no entries' error will be returned - GetLastStatusForAccountID(accountID string, status *model.Status) error + GetLastStatusForAccountID(accountID string, status *gtsmodel.Status) error // IsUsernameAvailable checks whether a given username is available on our domain. // Returns an error if the username is already taken, or something went wrong in the db. @@ -156,32 +170,112 @@ type DB interface { // NewSignup creates a new user in the database with the given parameters, with an *unconfirmed* email address. // By the time this function is called, it should be assumed that all the parameters have passed validation! - NewSignup(username string, reason string, requireApproval bool, email string, password string, signUpIP net.IP, locale string, appID string) (*model.User, error) + NewSignup(username string, reason string, requireApproval bool, email string, password string, signUpIP net.IP, locale string, appID string) (*gtsmodel.User, error) // SetHeaderOrAvatarForAccountID sets the header or avatar for the given accountID to the given media attachment. - SetHeaderOrAvatarForAccountID(mediaAttachment *model.MediaAttachment, accountID string) error + SetHeaderOrAvatarForAccountID(mediaAttachment *gtsmodel.MediaAttachment, accountID string) error // GetHeaderAvatarForAccountID gets the current avatar for the given account ID. // The passed mediaAttachment pointer will be populated with the value of the avatar, if it exists. - GetAvatarForAccountID(avatar *model.MediaAttachment, accountID string) error + GetAvatarForAccountID(avatar *gtsmodel.MediaAttachment, accountID string) error // GetHeaderForAccountID gets the current header for the given account ID. // The passed mediaAttachment pointer will be populated with the value of the header, if it exists. - GetHeaderForAccountID(header *model.MediaAttachment, accountID string) error + GetHeaderForAccountID(header *gtsmodel.MediaAttachment, accountID string) error + + // Blocked checks whether a block exists in eiher direction between two accounts. + // That is, it returns true if account1 blocks account2, OR if account2 blocks account1. + Blocked(account1 string, account2 string) (bool, error) + + // StatusVisible returns true if targetStatus is visible to requestingAccount, based on the + // privacy settings of the status, and any blocks/mutes that might exist between the two accounts + // or account domains. + // + // StatusVisible will also check through the given slice of 'otherRelevantAccounts', which should include: + // + // 1. Accounts mentioned in the targetStatus + // + // 2. Accounts replied to by the target status + // + // 3. Accounts boosted by the target status + // + // Will return an error if something goes wrong while pulling stuff out of the database. + StatusVisible(targetStatus *gtsmodel.Status, targetAccount *gtsmodel.Account, requestingAccount *gtsmodel.Account, relevantAccounts *gtsmodel.RelevantAccounts) (bool, error) + + // Follows returns true if sourceAccount follows target account, or an error if something goes wrong while finding out. + Follows(sourceAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) (bool, error) + + // Mutuals returns true if account1 and account2 both follow each other, or an error if something goes wrong while finding out. + Mutuals(account1 *gtsmodel.Account, account2 *gtsmodel.Account) (bool, error) + + // PullRelevantAccountsFromStatus returns all accounts mentioned in a status, replied to by a status, or boosted by a status + PullRelevantAccountsFromStatus(status *gtsmodel.Status) (*gtsmodel.RelevantAccounts, error) + + // GetReplyCountForStatus returns the amount of replies recorded for a status, or an error if something goes wrong + GetReplyCountForStatus(status *gtsmodel.Status) (int, error) + + // GetReblogCountForStatus returns the amount of reblogs/boosts recorded for a status, or an error if something goes wrong + GetReblogCountForStatus(status *gtsmodel.Status) (int, error) + + // GetFaveCountForStatus returns the amount of faves/likes recorded for a status, or an error if something goes wrong + GetFaveCountForStatus(status *gtsmodel.Status) (int, error) + + // StatusFavedBy checks if a given status has been faved by a given account ID + StatusFavedBy(status *gtsmodel.Status, accountID string) (bool, error) + + // StatusRebloggedBy checks if a given status has been reblogged/boosted by a given account ID + StatusRebloggedBy(status *gtsmodel.Status, accountID string) (bool, error) + + // StatusMutedBy checks if a given status has been muted by a given account ID + StatusMutedBy(status *gtsmodel.Status, accountID string) (bool, error) + + // StatusBookmarkedBy checks if a given status has been bookmarked by a given account ID + StatusBookmarkedBy(status *gtsmodel.Status, accountID string) (bool, error) + + // StatusPinnedBy checks if a given status has been pinned by a given account ID + StatusPinnedBy(status *gtsmodel.Status, accountID string) (bool, error) + + // FaveStatus faves the given status, using accountID as the faver. + // The returned fave will be nil if the status was already faved. + FaveStatus(status *gtsmodel.Status, accountID string) (*gtsmodel.StatusFave, error) + + // UnfaveStatus unfaves the given status, using accountID as the unfaver (sure, that's a word). + // The returned fave will be nil if the status was already not faved. + UnfaveStatus(status *gtsmodel.Status, accountID string) (*gtsmodel.StatusFave, error) + + // WhoFavedStatus returns a slice of accounts who faved the given status. + // This slice will be unfiltered, not taking account of blocks and whatnot, so filter it before serving it back to a user. + WhoFavedStatus(status *gtsmodel.Status) ([]*gtsmodel.Account, error) /* USEFUL CONVERSION FUNCTIONS */ - // AccountToMastoSensitive takes a db model account as a param, and returns a populated mastotype account, or an error - // if something goes wrong. The returned account should be ready to serialize on an API level, and may have sensitive fields, - // so serve it only to an authorized user who should have permission to see it. - AccountToMastoSensitive(account *model.Account) (*mastotypes.Account, error) - - // AccountToMastoPublic takes a db model account as a param, and returns a populated mastotype account, or an error - // if something goes wrong. The returned account should be ready to serialize on an API level, and may NOT have sensitive fields. - // In other words, this is the public record that the server has of an account. - AccountToMastoPublic(account *model.Account) (*mastotypes.Account, error) + // MentionStringsToMentions takes a slice of deduplicated, lowercase account names in the form "@test@whatever.example.org" for a remote account, + // or @test for a local account, which have been mentioned in a status. + // It takes the id of the account that wrote the status, and the id of the status itself, and then + // checks in the database for the mentioned accounts, and returns a slice of mentions generated based on the given parameters. + // + // Note: this func doesn't/shouldn't do any manipulation of the accounts in the DB, it's just for checking + // if they exist in the db and conveniently returning them if they do. + MentionStringsToMentions(targetAccounts []string, originAccountID string, statusID string) ([]*gtsmodel.Mention, error) + + // 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 + // 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 + // if they exist in the db already, and conveniently returning them, or creating new tag structs. + TagStringsToTags(tags []string, originAccountID string, statusID string) ([]*gtsmodel.Tag, error) + + // EmojiStringsToEmojis takes a slice of deduplicated, lowercase emojis in the form ":emojiname:", 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.Emoji corresponding to the given emojis. + // + // Note: this func doesn't/shouldn't do any manipulation of the emoji in the DB, it's just for checking + // if they exist in the db and conveniently returning them if they do. + EmojiStringsToEmojis(emojis []string, originAccountID string, statusID string) ([]*gtsmodel.Emoji, error) } // New returns a new database service that satisfies the DB interface and, by extension, diff --git a/internal/db/model/README.md b/internal/db/gtsmodel/README.md diff --git a/internal/db/gtsmodel/account.go b/internal/db/gtsmodel/account.go @@ -0,0 +1,142 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +// Package gtsmodel contains types used *internally* by GoToSocial and added/removed/selected from the database. +// These types should never be serialized and/or sent out via public APIs, as they contain sensitive information. +// The annotation used on these structs is for handling them via the go-pg ORM (hence why they're in this db subdir). +// See here for more info on go-pg model annotations: https://pg.uptrace.dev/models/ +package gtsmodel + +import ( + "crypto/rsa" + "time" +) + +// Account represents either a local or a remote fediverse account, gotosocial or otherwise (mastodon, pleroma, etc) +type Account struct { + /* + BASIC INFO + */ + + // id of this account in the local database; the end-user will never need to know this, it's strictly internal + ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` + // Username of the account, should just be a string of [a-z0-9_]. Can be added to domain to create the full username in the form ``[username]@[domain]`` eg., ``user_96@example.org`` + Username string `pg:",notnull,unique:userdomain"` // username and domain should be unique *with* each other + // Domain of the account, will be null if this is a local account, otherwise something like ``example.org`` or ``mastodon.social``. Should be unique with username. + Domain string `pg:",unique:userdomain"` // username and domain should be unique *with* each other + + /* + ACCOUNT METADATA + */ + + // ID of the avatar as a media attachment + AvatarMediaAttachmentID string + // ID of the header as a media attachment + HeaderMediaAttachmentID string + // DisplayName for this account. Can be empty, then just the Username will be used for display purposes. + DisplayName string + // a key/value map of fields that this account has added to their profile + Fields []Field + // A note that this account has on their profile (ie., the account's bio/description of themselves) + Note string + // Is this a memorial account, ie., has the user passed away? + Memorial bool + // This account has moved this account id in the database + MovedToAccountID string + // When was this account created? + CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // When was this account last updated? + UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // Does this account identify itself as a bot? + Bot bool + // What reason was given for signing up when this account was created? + Reason string + + /* + USER AND PRIVACY PREFERENCES + */ + + // Does this account need an approval for new followers? + Locked bool + // Should this account be shown in the instance's profile directory? + Discoverable bool + // Default post privacy for this account + Privacy Visibility + // Set posts from this account to sensitive by default? + Sensitive bool + // What language does this account post in? + Language string + + /* + ACTIVITYPUB THINGS + */ + + // What is the activitypub URI for this account discovered by webfinger? + URI string `pg:",unique"` + // At which URL can we see the user account in a web browser? + URL string `pg:",unique"` + // Last time this account was located using the webfinger API. + LastWebfingeredAt time.Time `pg:"type:timestamp"` + // Address of this account's activitypub inbox, for sending activity to + InboxURL string `pg:",unique"` + // Address of this account's activitypub outbox + OutboxURL string `pg:",unique"` + // Don't support shared inbox right now so this is just a stub for a future implementation + SharedInboxURL string `pg:",unique"` + // URL for getting the followers list of this account + FollowersURL string `pg:",unique"` + // URL for getting the featured collection list of this account + FeaturedCollectionURL string `pg:",unique"` + // What type of activitypub actor is this account? + ActorType ActivityStreamsActor + // This account is associated with x account id + AlsoKnownAs string + + /* + CRYPTO FIELDS + */ + + // Privatekey for validating activitypub requests, will obviously only be defined for local accounts + PrivateKey *rsa.PrivateKey + // Publickey for encoding activitypub requests, will be defined for both local and remote accounts + PublicKey *rsa.PublicKey + + /* + ADMIN FIELDS + */ + + // When was this account set to have all its media shown as sensitive? + SensitizedAt time.Time `pg:"type:timestamp"` + // When was this account silenced (eg., statuses only visible to followers, not public)? + SilencedAt time.Time `pg:"type:timestamp"` + // When was this account suspended (eg., don't allow it to log in/post, don't accept media/posts from this account) + SuspendedAt time.Time `pg:"type:timestamp"` + // Should we hide this account's collections? + HideCollections bool + // id of the user that suspended this account through an admin action + SuspensionOrigin string +} + +// Field represents a key value field on an account, for things like pronouns, website, etc. +// VerifiedAt is optional, to be used only if Value is a URL to a webpage that contains the +// username of the user. +type Field struct { + Name string + Value string + VerifiedAt time.Time `pg:"type:timestamp"` +} diff --git a/internal/db/gtsmodel/activitystreams.go b/internal/db/gtsmodel/activitystreams.go @@ -0,0 +1,127 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package gtsmodel + +// ActivityStreamsObject refers to https://www.w3.org/TR/activitystreams-vocabulary/#object-types +type ActivityStreamsObject string + +const ( + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-article + ActivityStreamsArticle ActivityStreamsObject = "Article" + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-audio + ActivityStreamsAudio ActivityStreamsObject = "Audio" + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-document + ActivityStreamsDocument ActivityStreamsObject = "Event" + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-event + ActivityStreamsEvent ActivityStreamsObject = "Event" + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-image + ActivityStreamsImage ActivityStreamsObject = "Image" + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-note + ActivityStreamsNote ActivityStreamsObject = "Note" + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-page + ActivityStreamsPage ActivityStreamsObject = "Page" + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-place + ActivityStreamsPlace ActivityStreamsObject = "Place" + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-profile + ActivityStreamsProfile ActivityStreamsObject = "Profile" + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-relationship + ActivityStreamsRelationship ActivityStreamsObject = "Relationship" + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tombstone + ActivityStreamsTombstone ActivityStreamsObject = "Tombstone" + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-video + ActivityStreamsVideo ActivityStreamsObject = "Video" +) + +// ActivityStreamsActor refers to https://www.w3.org/TR/activitystreams-vocabulary/#actor-types +type ActivityStreamsActor string + +const ( + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-application + ActivityStreamsApplication ActivityStreamsActor = "Application" + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-group + ActivityStreamsGroup ActivityStreamsActor = "Group" + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-organization + ActivityStreamsOrganization ActivityStreamsActor = "Organization" + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-person + ActivityStreamsPerson ActivityStreamsActor = "Person" + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-service + ActivityStreamsService ActivityStreamsActor = "Service" +) + +// ActivityStreamsActivity refers to https://www.w3.org/TR/activitystreams-vocabulary/#activity-types +type ActivityStreamsActivity string + +const ( + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-accept + ActivityStreamsAccept ActivityStreamsActivity = "Accept" + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-add + ActivityStreamsAdd ActivityStreamsActivity = "Add" + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-announce + ActivityStreamsAnnounce ActivityStreamsActivity = "Announce" + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-arrive + ActivityStreamsArrive ActivityStreamsActivity = "Arrive" + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-block + ActivityStreamsBlock ActivityStreamsActivity = "Block" + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-create + ActivityStreamsCreate ActivityStreamsActivity = "Create" + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-delete + ActivityStreamsDelete ActivityStreamsActivity = "Delete" + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-dislike + ActivityStreamsDislike ActivityStreamsActivity = "Dislike" + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-flag + ActivityStreamsFlag ActivityStreamsActivity = "Flag" + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-follow + ActivityStreamsFollow ActivityStreamsActivity = "Follow" + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-ignore + ActivityStreamsIgnore ActivityStreamsActivity = "Ignore" + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-invite + ActivityStreamsInvite ActivityStreamsActivity = "Invite" + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-join + ActivityStreamsJoin ActivityStreamsActivity = "Join" + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-leave + ActivityStreamsLeave ActivityStreamsActivity = "Leave" + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-like + ActivityStreamsLike ActivityStreamsActivity = "Like" + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-listen + ActivityStreamsListen ActivityStreamsActivity = "Listen" + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-move + ActivityStreamsMove ActivityStreamsActivity = "Move" + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-offer + ActivityStreamsOffer ActivityStreamsActivity = "Offer" + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-question + ActivityStreamsQuestion ActivityStreamsActivity = "Question" + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-reject + ActivityStreamsReject ActivityStreamsActivity = "Reject" + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-read + ActivityStreamsRead ActivityStreamsActivity = "Read" + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-remove + ActivityStreamsRemove ActivityStreamsActivity = "Remove" + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tentativereject + ActivityStreamsTentativeReject ActivityStreamsActivity = "TentativeReject" + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tentativeaccept + ActivityStreamsTentativeAccept ActivityStreamsActivity = "TentativeAccept" + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-travel + ActivityStreamsTravel ActivityStreamsActivity = "Travel" + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-undo + ActivityStreamsUndo ActivityStreamsActivity = "Undo" + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-update + ActivityStreamsUpdate ActivityStreamsActivity = "Update" + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-view + ActivityStreamsView ActivityStreamsActivity = "View" +) diff --git a/internal/db/gtsmodel/application.go b/internal/db/gtsmodel/application.go @@ -0,0 +1,40 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package gtsmodel + +// Application represents an application that can perform actions on behalf of a user. +// It is used to authorize tokens etc, and is associated with an oauth client id in the database. +type Application struct { + // id of this application in the db + ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull"` + // name of the application given when it was created (eg., 'tusky') + Name string + // website for the application given when it was created (eg., 'https://tusky.app') + Website string + // redirect uri requested by the application for oauth2 flow + RedirectURI string + // id of the associated oauth client entity in the db + ClientID string + // secret of the associated oauth client entity in the db + ClientSecret string + // scopes requested when this app was created + Scopes string + // a vapid key generated for this app when it was created + VapidKey string +} diff --git a/internal/db/gtsmodel/block.go b/internal/db/gtsmodel/block.go @@ -0,0 +1,19 @@ +package gtsmodel + +import "time" + +// Block refers to the blocking of one account by another. +type Block struct { + // id of this block in the database + ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull"` + // When was this block created + CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // When was this block updated + UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // Who created this block? + AccountID string `pg:",notnull"` + // Who is targeted by this block? + TargetAccountID string `pg:",notnull"` + // Activitypub URI for this block + URI string +} diff --git a/internal/db/gtsmodel/domainblock.go b/internal/db/gtsmodel/domainblock.go @@ -0,0 +1,47 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package gtsmodel + +import "time" + +// DomainBlock represents a federation block against a particular domain, of varying severity. +type DomainBlock struct { + // ID of this block in the database + ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` + // Domain to block. If ANY PART of the candidate domain contains this string, it will be blocked. + // For example: 'example.org' also blocks 'gts.example.org'. '.com' blocks *any* '.com' domains. + // TODO: implement wildcards here + Domain string `pg:",notnull"` + // When was this block created + CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // When was this block updated + UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // Account ID of the creator of this block + CreatedByAccountID string `pg:",notnull"` + // TODO: define this + Severity int + // Reject media from this domain? + RejectMedia bool + // Reject reports from this domain? + RejectReports bool + // Private comment on this block, viewable to admins + PrivateComment string + // Public comment on this block, viewable (optionally) by everyone + PublicComment string +} diff --git a/internal/db/gtsmodel/emaildomainblock.go b/internal/db/gtsmodel/emaildomainblock.go @@ -0,0 +1,35 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package gtsmodel + +import "time" + +// EmailDomainBlock represents a domain that the server should automatically reject sign-up requests from. +type EmailDomainBlock struct { + // ID of this block in the database + ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` + // Email domain to block. Eg. 'gmail.com' or 'hotmail.com' + Domain string `pg:",notnull"` + // When was this block created + CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // When was this block updated + UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // Account ID of the creator of this block + CreatedByAccountID string `pg:",notnull"` +} diff --git a/internal/db/gtsmodel/emoji.go b/internal/db/gtsmodel/emoji.go @@ -0,0 +1,74 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package gtsmodel + +import "time" + +type Emoji struct { + // database ID of this emoji + ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull"` + // String shortcode for this emoji -- the part that's between colons. This should be lowercase a-z_ + // eg., 'blob_hug' 'purple_heart' Must be unique with domain. + Shortcode string `pg:",notnull,unique:shortcodedomain"` + // Origin domain of this emoji, eg 'example.org', 'queer.party'. empty string for local emojis. + Domain string `pg:",notnull,default:'',use_zero,unique:shortcodedomain"` + // When was this emoji created. Must be unique with shortcode. + CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // When was this emoji updated + UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // Where can this emoji be retrieved remotely? Null for local emojis. + // For remote emojis, it'll be something like: + // https://hackers.town/system/custom_emojis/images/000/049/842/original/1b74481204feabfd.png + ImageRemoteURL string + // Where can a static / non-animated version of this emoji be retrieved remotely? Null for local emojis. + // For remote emojis, it'll be something like: + // https://hackers.town/system/custom_emojis/images/000/049/842/static/1b74481204feabfd.png + ImageStaticRemoteURL string + // Where can this emoji be retrieved from the local server? Null for remote emojis. + // Assuming our server is hosted at 'example.org', this will be something like: + // 'https://example.org/fileserver/6339820e-ef65-4166-a262-5a9f46adb1a7/emoji/original/bfa6c9c5-6c25-4ea4-98b4-d78b8126fb52.png' + ImageURL string + // Where can a static version of this emoji be retrieved from the local server? Null for remote emojis. + // Assuming our server is hosted at 'example.org', this will be something like: + // 'https://example.org/fileserver/6339820e-ef65-4166-a262-5a9f46adb1a7/emoji/small/bfa6c9c5-6c25-4ea4-98b4-d78b8126fb52.png' + ImageStaticURL string + // Path of the emoji image in the server storage system. Will be something like: + // '/gotosocial/storage/6339820e-ef65-4166-a262-5a9f46adb1a7/emoji/original/bfa6c9c5-6c25-4ea4-98b4-d78b8126fb52.png' + ImagePath string `pg:",notnull"` + // Path of a static version of the emoji image in the server storage system. Will be something like: + // '/gotosocial/storage/6339820e-ef65-4166-a262-5a9f46adb1a7/emoji/small/bfa6c9c5-6c25-4ea4-98b4-d78b8126fb52.png' + ImageStaticPath string `pg:",notnull"` + // MIME content type of the emoji image + // Probably "image/png" + ImageContentType string `pg:",notnull"` + // Size of the emoji image file in bytes, for serving purposes. + ImageFileSize int `pg:",notnull"` + // Size of the static version of the emoji image file in bytes, for serving purposes. + ImageStaticFileSize int `pg:",notnull"` + // When was the emoji image last updated? + ImageUpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // Has a moderation action disabled this emoji from being shown? + Disabled bool `pg:",notnull,default:false"` + // ActivityStreams uri of this emoji. Something like 'https://example.org/emojis/1234' + URI string `pg:",notnull,unique"` + // Is this emoji visible in the admin emoji picker? + VisibleInPicker bool `pg:",notnull,default:true"` + // In which emoji category is this emoji visible? + CategoryID string +} diff --git a/internal/db/gtsmodel/follow.go b/internal/db/gtsmodel/follow.go @@ -0,0 +1,41 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package gtsmodel + +import "time" + +// Follow represents one account following another, and the metadata around that follow. +type Follow struct { + // id of this follow in the database + ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` + // When was this follow created? + CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // When was this follow last updated? + UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // Who does this follow belong to? + AccountID string `pg:",unique:srctarget,notnull"` + // Who does AccountID follow? + TargetAccountID string `pg:",unique:srctarget,notnull"` + // Does this follow also want to see reblogs and not just posts? + ShowReblogs bool `pg:"default:true"` + // What is the activitypub URI of this follow? + URI string `pg:",unique"` + // does the following account want to be notified when the followed account posts? + Notify bool +} diff --git a/internal/db/gtsmodel/followrequest.go b/internal/db/gtsmodel/followrequest.go @@ -0,0 +1,41 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package gtsmodel + +import "time" + +// FollowRequest represents one account requesting to follow another, and the metadata around that request. +type FollowRequest struct { + // id of this follow request in the database + ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` + // When was this follow request created? + CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // When was this follow request last updated? + UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // Who does this follow request originate from? + AccountID string `pg:",unique:srctarget,notnull"` + // Who is the target of this follow request? + TargetAccountID string `pg:",unique:srctarget,notnull"` + // Does this follow also want to see reblogs and not just posts? + ShowReblogs bool `pg:"default:true"` + // What is the activitypub URI of this follow request? + URI string `pg:",unique"` + // does the following account want to be notified when the followed account posts? + Notify bool +} diff --git a/internal/db/gtsmodel/mediaattachment.go b/internal/db/gtsmodel/mediaattachment.go @@ -0,0 +1,148 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package gtsmodel + +import ( + "time" +) + +// MediaAttachment represents a user-uploaded media attachment: an image/video/audio/gif that is +// somewhere in storage and that can be retrieved and served by the router. +type MediaAttachment struct { + // ID of the attachment in the database + ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` + // ID of the status to which this is attached + StatusID string + // Where can the attachment be retrieved on *this* server + URL string + // Where can the attachment be retrieved on a remote server (empty for local media) + RemoteURL string + // When was the attachment created + CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // When was the attachment last updated + UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // Type of file (image/gif/audio/video) + Type FileType `pg:",notnull"` + // Metadata about the file + FileMeta FileMeta + // To which account does this attachment belong + AccountID string `pg:",notnull"` + // Description of the attachment (for screenreaders) + Description string + // To which scheduled status does this attachment belong + ScheduledStatusID string + // What is the generated blurhash of this attachment + Blurhash string + // What is the processing status of this attachment + Processing ProcessingStatus + // metadata for the whole file + File File + // small image thumbnail derived from a larger image, video, or audio file. + Thumbnail Thumbnail + // Is this attachment being used as an avatar? + Avatar bool + // Is this attachment being used as a header? + Header bool +} + +// File refers to the metadata for the whole file +type File struct { + // What is the path of the file in storage. + Path string + // What is the MIME content type of the file. + ContentType string + // What is the size of the file in bytes. + FileSize int + // When was the file last updated. + UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` +} + +// Thumbnail refers to a small image thumbnail derived from a larger image, video, or audio file. +type Thumbnail struct { + // What is the path of the file in storage + Path string + // What is the MIME content type of the file. + ContentType string + // What is the size of the file in bytes + FileSize int + // When was the file last updated + UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // What is the URL of the thumbnail on the local server + URL string + // What is the remote URL of the thumbnail (empty for local media) + RemoteURL string +} + +// ProcessingStatus refers to how far along in the processing stage the attachment is. +type ProcessingStatus int + +const ( + // ProcessingStatusReceived: the attachment has been received and is awaiting processing. No thumbnail available yet. + ProcessingStatusReceived ProcessingStatus = 0 + // ProcessingStatusProcessing: the attachment is currently being processed. Thumbnail is available but full media is not. + ProcessingStatusProcessing ProcessingStatus = 1 + // ProcessingStatusProcessed: the attachment has been fully processed and is ready to be served. + ProcessingStatusProcessed ProcessingStatus = 2 + // ProcessingStatusError: something went wrong processing the attachment and it won't be tried again--these can be deleted. + ProcessingStatusError ProcessingStatus = 666 +) + +// FileType refers to the file type of the media attaachment. +type FileType string + +const ( + // FileTypeImage is for jpegs and pngs + FileTypeImage FileType = "image" + // FileTypeGif is for native gifs and soundless videos that have been converted to gifs + FileTypeGif FileType = "gif" + // FileTypeAudio is for audio-only files (no video) + FileTypeAudio FileType = "audio" + // FileTypeVideo is for files with audio + visual + FileTypeVideo FileType = "video" + // FileTypeUnknown is for unknown file types (surprise surprise!) + FileTypeUnknown FileType = "unknown" +) + +// FileMeta describes metadata about the actual contents of the file. +type FileMeta struct { + Original Original + Small Small + Focus Focus +} + +// Small can be used for a thumbnail of any media type +type Small struct { + Width int + Height int + Size int + Aspect float64 +} + +// Original can be used for original metadata for any media type +type Original struct { + Width int + Height int + Size int + Aspect float64 +} + +type Focus struct { + X float32 + Y float32 +} diff --git a/internal/db/gtsmodel/mention.go b/internal/db/gtsmodel/mention.go @@ -0,0 +1,39 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package gtsmodel + +import "time" + +// Mention refers to the 'tagging' or 'mention' of a user within a status. +type Mention struct { + // ID of this mention in the database + ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` + // ID of the status this mention originates from + StatusID string `pg:",notnull"` + // When was this mention created? + CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // When was this mention last updated? + UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // Who created this mention? + OriginAccountID string `pg:",notnull"` + // Who does this mention target? + TargetAccountID string `pg:",notnull"` + // Prevent this mention from generating a notification? + Silent bool +} diff --git a/internal/db/gtsmodel/poll.go b/internal/db/gtsmodel/poll.go @@ -0,0 +1,19 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package gtsmodel diff --git a/internal/db/gtsmodel/status.go b/internal/db/gtsmodel/status.go @@ -0,0 +1,138 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package gtsmodel + +import "time" + +// Status represents a user-created 'post' or 'status' in the database, either remote or local +type Status struct { + // id of the status in the database + ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull"` + // uri at which this status is reachable + URI string `pg:",unique"` + // web url for viewing this status + URL string `pg:",unique"` + // the html-formatted content of this status + Content string + // Database IDs of any media attachments associated with this status + Attachments []string `pg:",array"` + // Database IDs of any tags used in this status + Tags []string `pg:",array"` + // Database IDs of any accounts mentioned in this status + Mentions []string `pg:",array"` + // Database IDs of any emojis used in this status + Emojis []string `pg:",array"` + // when was this status created? + CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // when was this status updated? + UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // is this status from a local account? + Local bool + // which account posted this status? + AccountID string + // id of the status this status is a reply to + InReplyToID string + // id of the account that this status replies to + InReplyToAccountID string + // id of the status this status is a boost of + BoostOfID string + // cw string for this status + ContentWarning string + // visibility entry for this status + Visibility Visibility `pg:",notnull"` + // mark the status as sensitive? + Sensitive bool + // what language is this status written in? + Language string + // Which application was used to create this status? + CreatedWithApplicationID string + // advanced visibility for this status + VisibilityAdvanced *VisibilityAdvanced + // What is the activitystreams type of this status? See: https://www.w3.org/TR/activitystreams-vocabulary/#object-types + // Will probably almost always be Note but who knows!. + ActivityStreamsType ActivityStreamsObject + // Original text of the status without formatting + Text string + + /* + NON-DATABASE FIELDS + + These are for convenience while passing the status around internally, + but these fields should *never* be put in the db. + */ + + // Mentions created in this status + GTSMentions []*Mention `pg:"-"` + // Hashtags used in this status + GTSTags []*Tag `pg:"-"` + // Emojis used in this status + GTSEmojis []*Emoji `pg:"-"` + // MediaAttachments used in this status + GTSMediaAttachments []*MediaAttachment `pg:"-"` + // Status being replied to + GTSReplyToStatus *Status `pg:"-"` + // Account being replied to + GTSReplyToAccount *Account `pg:"-"` +} + +// Visibility represents the visibility granularity of a status. +type Visibility string + +const ( + // This status will be visible to everyone on all timelines. + VisibilityPublic Visibility = "public" + // This status will be visible to everyone, but will only show on home timeline to followers, and in lists. + VisibilityUnlocked Visibility = "unlocked" + // This status is viewable to followers only. + VisibilityFollowersOnly Visibility = "followers_only" + // This status is visible to mutual followers only. + VisibilityMutualsOnly Visibility = "mutuals_only" + // This status is visible only to mentioned recipients + VisibilityDirect Visibility = "direct" + // Default visibility to use when no other setting can be found + VisibilityDefault Visibility = "public" +) + +// VisibilityAdvanced denotes a set of flags that can be set on a status for fine-tuning visibility and interactivity of the status. +type VisibilityAdvanced struct { + /* + ADVANCED SETTINGS -- These should all default to TRUE. + + If PUBLIC is selected, they will all be overwritten to TRUE regardless of what is selected. + If UNLOCKED is selected, any of them can be turned on or off in any combination. + If FOLLOWERS-ONLY or MUTUALS-ONLY are selected, boostable will always be FALSE. The others can be turned on or off as desired. + If DIRECT is selected, boostable will be FALSE, and all other flags will be TRUE. + */ + // This status will be federated beyond the local timeline(s) + Federated bool `pg:"default:true"` + // This status can be boosted/reblogged + Boostable bool `pg:"default:true"` + // This status can be replied to + Replyable bool `pg:"default:true"` + // This status can be liked/faved + Likeable bool `pg:"default:true"` +} + +// RelevantAccounts denotes accounts that are replied to, boosted by, or mentioned in a status. +type RelevantAccounts struct { + ReplyToAccount *Account + BoostedAccount *Account + BoostedReplyToAccount *Account + MentionedAccounts []*Account +} diff --git a/internal/db/gtsmodel/statusbookmark.go b/internal/db/gtsmodel/statusbookmark.go @@ -0,0 +1,35 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package gtsmodel + +import "time" + +// StatusBookmark refers to one account having a 'bookmark' of the status of another account +type StatusBookmark struct { + // id of this bookmark in the database + ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` + // when was this bookmark created + CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // id of the account that created ('did') the bookmarking + AccountID string `pg:",notnull"` + // id the account owning the bookmarked status + TargetAccountID string `pg:",notnull"` + // database id of the status that has been bookmarked + StatusID string `pg:",notnull"` +} diff --git a/internal/db/gtsmodel/statusfave.go b/internal/db/gtsmodel/statusfave.go @@ -0,0 +1,38 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package gtsmodel + +import "time" + +// StatusFave refers to a 'fave' or 'like' in the database, from one account, targeting the status of another account +type StatusFave struct { + // id of this fave in the database + ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` + // when was this fave created + CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // id of the account that created ('did') the fave + AccountID string `pg:",notnull"` + // id the account owning the faved status + TargetAccountID string `pg:",notnull"` + // database id of the status that has been 'faved' + StatusID string `pg:",notnull"` + + // FavedStatus is the status being interacted with. It won't be put or retrieved from the db, it's just for conveniently passing a pointer around. + FavedStatus *Status `pg:"-"` +} diff --git a/internal/db/gtsmodel/statusmute.go b/internal/db/gtsmodel/statusmute.go @@ -0,0 +1,35 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package gtsmodel + +import "time" + +// StatusMute refers to one account having muted the status of another account or its own +type StatusMute struct { + // id of this mute in the database + ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` + // when was this mute created + CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // id of the account that created ('did') the mute + AccountID string `pg:",notnull"` + // id the account owning the muted status (can be the same as accountID) + TargetAccountID string `pg:",notnull"` + // database id of the status that has been muted + StatusID string `pg:",notnull"` +} diff --git a/internal/db/gtsmodel/statuspin.go b/internal/db/gtsmodel/statuspin.go @@ -0,0 +1,33 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package gtsmodel + +import "time" + +// StatusPin refers to a status 'pinned' to the top of an account +type StatusPin struct { + // id of this pin in the database + ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` + // when was this pin created + CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // id of the account that created ('did') the pinning (this should always be the same as the author of the status) + AccountID string `pg:",notnull"` + // database id of the status that has been pinned + StatusID string `pg:",notnull"` +} diff --git a/internal/db/gtsmodel/tag.go b/internal/db/gtsmodel/tag.go @@ -0,0 +1,41 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package gtsmodel + +import "time" + +// Tag represents a hashtag for gathering public statuses together +type Tag struct { + // id of this tag in the database + ID string `pg:",unique,type:uuid,default:gen_random_uuid(),pk,notnull"` + // name of this tag -- the tag without the hash part + Name string `pg:",unique,pk,notnull"` + // Which account ID is the first one we saw using this tag? + FirstSeenFromAccountID string + // when was this tag created + CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // when was this tag last updated + UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // can our instance users use this tag? + Useable bool `pg:",notnull,default:true"` + // can our instance users look up this tag? + Listable bool `pg:",notnull,default:true"` + // when was this tag last used? + LastStatusAt time.Time `pg:"type:timestamp,notnull,default:now()"` +} diff --git a/internal/db/gtsmodel/user.go b/internal/db/gtsmodel/user.go @@ -0,0 +1,120 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package gtsmodel + +import ( + "net" + "time" +) + +// User represents an actual human user of gotosocial. Note, this is a LOCAL gotosocial user, not a remote account. +// To cross reference this local user with their account (which can be local or remote), use the AccountID field. +type User struct { + /* + BASIC INFO + */ + + // id of this user in the local database; the end-user will never need to know this, it's strictly internal + ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` + // confirmed email address for this user, this should be unique -- only one email address registered per instance, multiple users per email are not supported + Email string `pg:"default:null,unique"` + // The id of the local gtsmodel.Account entry for this user, if it exists (unconfirmed users don't have an account yet) + AccountID string `pg:"default:'',notnull,unique"` + // The encrypted password of this user, generated using https://pkg.go.dev/golang.org/x/crypto/bcrypt#GenerateFromPassword. A salt is included so we're safe against 🌈 tables + EncryptedPassword string `pg:",notnull"` + + /* + USER METADATA + */ + + // When was this user created? + CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // From what IP was this user created? + SignUpIP net.IP + // When was this user updated (eg., password changed, email address changed)? + UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // When did this user sign in for their current session? + CurrentSignInAt time.Time `pg:"type:timestamp"` + // What's the most recent IP of this user + CurrentSignInIP net.IP + // When did this user last sign in? + LastSignInAt time.Time `pg:"type:timestamp"` + // What's the previous IP of this user? + LastSignInIP net.IP + // How many times has this user signed in? + SignInCount int + // id of the user who invited this user (who let this guy in?) + InviteID string + // What languages does this user want to see? + ChosenLanguages []string + // What languages does this user not want to see? + FilteredLanguages []string + // In what timezone/locale is this user located? + Locale string + // Which application id created this user? See gtsmodel.Application + CreatedByApplicationID string + // When did we last contact this user + LastEmailedAt time.Time `pg:"type:timestamp"` + + /* + USER CONFIRMATION + */ + + // What confirmation token did we send this user/what are we expecting back? + ConfirmationToken string + // When did the user confirm their email address + ConfirmedAt time.Time `pg:"type:timestamp"` + // When did we send email confirmation to this user? + ConfirmationSentAt time.Time `pg:"type:timestamp"` + // Email address that hasn't yet been confirmed + UnconfirmedEmail string + + /* + ACL FLAGS + */ + + // Is this user a moderator? + Moderator bool + // Is this user an admin? + Admin bool + // Is this user disabled from posting? + Disabled bool + // Has this user been approved by a moderator? + Approved bool + + /* + USER SECURITY + */ + + // The generated token that the user can use to reset their password + ResetPasswordToken string + // When did we email the user their reset-password email? + ResetPasswordSentAt time.Time `pg:"type:timestamp"` + + EncryptedOTPSecret string + EncryptedOTPSecretIv string + EncryptedOTPSecretSalt string + OTPRequiredForLogin bool + OTPBackupCodes []string + ConsumedTimestamp int + RememberToken string + SignInToken string + SignInTokenSentAt time.Time `pg:"type:timestamp"` + WebauthnID string +} diff --git a/internal/db/mock_DB.go b/internal/db/mock_DB.go @@ -6,9 +6,7 @@ import ( context "context" mock "github.com/stretchr/testify/mock" - mastotypes "github.com/superseriousbusiness/gotosocial/pkg/mastotypes" - - model "github.com/superseriousbusiness/gotosocial/internal/db/model" + gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" net "net" @@ -20,22 +18,20 @@ type MockDB struct { mock.Mock } -// AccountToMastoSensitive provides a mock function with given fields: account -func (_m *MockDB) AccountToMastoSensitive(account *model.Account) (*mastotypes.Account, error) { - ret := _m.Called(account) +// Blocked provides a mock function with given fields: account1, account2 +func (_m *MockDB) Blocked(account1 string, account2 string) (bool, error) { + ret := _m.Called(account1, account2) - var r0 *mastotypes.Account - if rf, ok := ret.Get(0).(func(*model.Account) *mastotypes.Account); ok { - r0 = rf(account) + var r0 bool + if rf, ok := ret.Get(0).(func(string, string) bool); ok { + r0 = rf(account1, account2) } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*mastotypes.Account) - } + r0 = ret.Get(0).(bool) } var r1 error - if rf, ok := ret.Get(1).(func(*model.Account) error); ok { - r1 = rf(account) + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(account1, account2) } else { r1 = ret.Error(1) } @@ -99,6 +95,29 @@ func (_m *MockDB) DropTable(i interface{}) error { return r0 } +// EmojiStringsToEmojis provides a mock function with given fields: emojis, originAccountID, statusID +func (_m *MockDB) EmojiStringsToEmojis(emojis []string, originAccountID string, statusID string) ([]*gtsmodel.Emoji, error) { + ret := _m.Called(emojis, originAccountID, statusID) + + var r0 []*gtsmodel.Emoji + if rf, ok := ret.Get(0).(func([]string, string, string) []*gtsmodel.Emoji); ok { + r0 = rf(emojis, originAccountID, statusID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*gtsmodel.Emoji) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func([]string, string, string) error); ok { + r1 = rf(emojis, originAccountID, statusID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // Federation provides a mock function with given fields: func (_m *MockDB) Federation() pub.Database { ret := _m.Called() @@ -116,11 +135,11 @@ func (_m *MockDB) Federation() pub.Database { } // GetAccountByUserID provides a mock function with given fields: userID, account -func (_m *MockDB) GetAccountByUserID(userID string, account *model.Account) error { +func (_m *MockDB) GetAccountByUserID(userID string, account *gtsmodel.Account) error { ret := _m.Called(userID, account) var r0 error - if rf, ok := ret.Get(0).(func(string, *model.Account) error); ok { + if rf, ok := ret.Get(0).(func(string, *gtsmodel.Account) error); ok { r0 = rf(userID, account) } else { r0 = ret.Error(0) @@ -143,6 +162,20 @@ func (_m *MockDB) GetAll(i interface{}) error { return r0 } +// GetAvatarForAccountID provides a mock function with given fields: avatar, accountID +func (_m *MockDB) GetAvatarForAccountID(avatar *gtsmodel.MediaAttachment, accountID string) error { + ret := _m.Called(avatar, accountID) + + var r0 error + if rf, ok := ret.Get(0).(func(*gtsmodel.MediaAttachment, string) error); ok { + r0 = rf(avatar, accountID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // GetByID provides a mock function with given fields: id, i func (_m *MockDB) GetByID(id string, i interface{}) error { ret := _m.Called(id, i) @@ -158,11 +191,11 @@ func (_m *MockDB) GetByID(id string, i interface{}) error { } // GetFollowRequestsForAccountID provides a mock function with given fields: accountID, followRequests -func (_m *MockDB) GetFollowRequestsForAccountID(accountID string, followRequests *[]model.FollowRequest) error { +func (_m *MockDB) GetFollowRequestsForAccountID(accountID string, followRequests *[]gtsmodel.FollowRequest) error { ret := _m.Called(accountID, followRequests) var r0 error - if rf, ok := ret.Get(0).(func(string, *[]model.FollowRequest) error); ok { + if rf, ok := ret.Get(0).(func(string, *[]gtsmodel.FollowRequest) error); ok { r0 = rf(accountID, followRequests) } else { r0 = ret.Error(0) @@ -172,11 +205,11 @@ func (_m *MockDB) GetFollowRequestsForAccountID(accountID string, followRequests } // GetFollowersByAccountID provides a mock function with given fields: accountID, followers -func (_m *MockDB) GetFollowersByAccountID(accountID string, followers *[]model.Follow) error { +func (_m *MockDB) GetFollowersByAccountID(accountID string, followers *[]gtsmodel.Follow) error { ret := _m.Called(accountID, followers) var r0 error - if rf, ok := ret.Get(0).(func(string, *[]model.Follow) error); ok { + if rf, ok := ret.Get(0).(func(string, *[]gtsmodel.Follow) error); ok { r0 = rf(accountID, followers) } else { r0 = ret.Error(0) @@ -186,11 +219,11 @@ func (_m *MockDB) GetFollowersByAccountID(accountID string, followers *[]model.F } // GetFollowingByAccountID provides a mock function with given fields: accountID, following -func (_m *MockDB) GetFollowingByAccountID(accountID string, following *[]model.Follow) error { +func (_m *MockDB) GetFollowingByAccountID(accountID string, following *[]gtsmodel.Follow) error { ret := _m.Called(accountID, following) var r0 error - if rf, ok := ret.Get(0).(func(string, *[]model.Follow) error); ok { + if rf, ok := ret.Get(0).(func(string, *[]gtsmodel.Follow) error); ok { r0 = rf(accountID, following) } else { r0 = ret.Error(0) @@ -199,12 +232,26 @@ func (_m *MockDB) GetFollowingByAccountID(accountID string, following *[]model.F return r0 } +// GetHeaderForAccountID provides a mock function with given fields: header, accountID +func (_m *MockDB) GetHeaderForAccountID(header *gtsmodel.MediaAttachment, accountID string) error { + ret := _m.Called(header, accountID) + + var r0 error + if rf, ok := ret.Get(0).(func(*gtsmodel.MediaAttachment, string) error); ok { + r0 = rf(header, accountID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // GetLastStatusForAccountID provides a mock function with given fields: accountID, status -func (_m *MockDB) GetLastStatusForAccountID(accountID string, status *model.Status) error { +func (_m *MockDB) GetLastStatusForAccountID(accountID string, status *gtsmodel.Status) error { ret := _m.Called(accountID, status) var r0 error - if rf, ok := ret.Get(0).(func(string, *model.Status) error); ok { + if rf, ok := ret.Get(0).(func(string, *gtsmodel.Status) error); ok { r0 = rf(accountID, status) } else { r0 = ret.Error(0) @@ -214,11 +261,11 @@ func (_m *MockDB) GetLastStatusForAccountID(accountID string, status *model.Stat } // GetStatusesByAccountID provides a mock function with given fields: accountID, statuses -func (_m *MockDB) GetStatusesByAccountID(accountID string, statuses *[]model.Status) error { +func (_m *MockDB) GetStatusesByAccountID(accountID string, statuses *[]gtsmodel.Status) error { ret := _m.Called(accountID, statuses) var r0 error - if rf, ok := ret.Get(0).(func(string, *[]model.Status) error); ok { + if rf, ok := ret.Get(0).(func(string, *[]gtsmodel.Status) error); ok { r0 = rf(accountID, statuses) } else { r0 = ret.Error(0) @@ -228,11 +275,11 @@ func (_m *MockDB) GetStatusesByAccountID(accountID string, statuses *[]model.Sta } // GetStatusesByTimeDescending provides a mock function with given fields: accountID, statuses, limit -func (_m *MockDB) GetStatusesByTimeDescending(accountID string, statuses *[]model.Status, limit int) error { +func (_m *MockDB) GetStatusesByTimeDescending(accountID string, statuses *[]gtsmodel.Status, limit int) error { ret := _m.Called(accountID, statuses, limit) var r0 error - if rf, ok := ret.Get(0).(func(string, *[]model.Status, int) error); ok { + if rf, ok := ret.Get(0).(func(string, *[]gtsmodel.Status, int) error); ok { r0 = rf(accountID, statuses, limit) } else { r0 = ret.Error(0) @@ -297,16 +344,39 @@ func (_m *MockDB) IsUsernameAvailable(username string) error { return r0 } +// MentionStringsToMentions provides a mock function with given fields: targetAccounts, originAccountID, statusID +func (_m *MockDB) MentionStringsToMentions(targetAccounts []string, originAccountID string, statusID string) ([]*gtsmodel.Mention, error) { + ret := _m.Called(targetAccounts, originAccountID, statusID) + + var r0 []*gtsmodel.Mention + if rf, ok := ret.Get(0).(func([]string, string, string) []*gtsmodel.Mention); ok { + r0 = rf(targetAccounts, originAccountID, statusID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*gtsmodel.Mention) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func([]string, string, string) error); ok { + r1 = rf(targetAccounts, originAccountID, statusID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // NewSignup provides a mock function with given fields: username, reason, requireApproval, email, password, signUpIP, locale, appID -func (_m *MockDB) NewSignup(username string, reason string, requireApproval bool, email string, password string, signUpIP net.IP, locale string, appID string) (*model.User, error) { +func (_m *MockDB) NewSignup(username string, reason string, requireApproval bool, email string, password string, signUpIP net.IP, locale string, appID string) (*gtsmodel.User, error) { ret := _m.Called(username, reason, requireApproval, email, password, signUpIP, locale, appID) - var r0 *model.User - if rf, ok := ret.Get(0).(func(string, string, bool, string, string, net.IP, string, string) *model.User); ok { + var r0 *gtsmodel.User + if rf, ok := ret.Get(0).(func(string, string, bool, string, string, net.IP, string, string) *gtsmodel.User); ok { r0 = rf(username, reason, requireApproval, email, password, signUpIP, locale, appID) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).(*model.User) + r0 = ret.Get(0).(*gtsmodel.User) } } @@ -334,6 +404,20 @@ func (_m *MockDB) Put(i interface{}) error { return r0 } +// SetHeaderOrAvatarForAccountID provides a mock function with given fields: mediaAttachment, accountID +func (_m *MockDB) SetHeaderOrAvatarForAccountID(mediaAttachment *gtsmodel.MediaAttachment, accountID string) error { + ret := _m.Called(mediaAttachment, accountID) + + var r0 error + if rf, ok := ret.Get(0).(func(*gtsmodel.MediaAttachment, string) error); ok { + r0 = rf(mediaAttachment, accountID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // Stop provides a mock function with given fields: ctx func (_m *MockDB) Stop(ctx context.Context) error { ret := _m.Called(ctx) @@ -348,6 +432,29 @@ func (_m *MockDB) Stop(ctx context.Context) error { return r0 } +// TagStringsToTags provides a mock function with given fields: tags, originAccountID, statusID +func (_m *MockDB) TagStringsToTags(tags []string, originAccountID string, statusID string) ([]*gtsmodel.Tag, error) { + ret := _m.Called(tags, originAccountID, statusID) + + var r0 []*gtsmodel.Tag + if rf, ok := ret.Get(0).(func([]string, string, string) []*gtsmodel.Tag); ok { + r0 = rf(tags, originAccountID, statusID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*gtsmodel.Tag) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func([]string, string, string) error); ok { + r1 = rf(tags, originAccountID, statusID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // UpdateByID provides a mock function with given fields: id, i func (_m *MockDB) UpdateByID(id string, i interface{}) error { ret := _m.Called(id, i) @@ -361,3 +468,17 @@ func (_m *MockDB) UpdateByID(id string, i interface{}) error { return r0 } + +// UpdateOneByID provides a mock function with given fields: id, key, value, i +func (_m *MockDB) UpdateOneByID(id string, key string, value interface{}, i interface{}) error { + ret := _m.Called(id, key, value, i) + + var r0 error + if rf, ok := ret.Get(0).(func(string, string, interface{}, interface{}) error); ok { + r0 = rf(id, key, value, i) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/internal/db/model/account.go b/internal/db/model/account.go @@ -1,164 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -// Package model contains types used *internally* by GoToSocial and added/removed/selected from the database. -// These types should never be serialized and/or sent out via public APIs, as they contain sensitive information. -// The annotation used on these structs is for handling them via the go-pg ORM (hence why they're in this db subdir). -// See here for more info on go-pg model annotations: https://pg.uptrace.dev/models/ -package model - -import ( - "crypto/rsa" - "net/url" - "time" -) - -// Account represents either a local or a remote fediverse account, gotosocial or otherwise (mastodon, pleroma, etc) -type Account struct { - /* - BASIC INFO - */ - - // id of this account in the local database; the end-user will never need to know this, it's strictly internal - ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` - // Username of the account, should just be a string of [a-z0-9_]. Can be added to domain to create the full username in the form ``[username]@[domain]`` eg., ``user_96@example.org`` - Username string `pg:",notnull,unique:userdomain"` // username and domain should be unique *with* each other - // Domain of the account, will be empty if this is a local account, otherwise something like ``example.org`` or ``mastodon.social``. Should be unique with username. - Domain string `pg:",unique:userdomain"` // username and domain should be unique *with* each other - - /* - ACCOUNT METADATA - */ - - // File name of the avatar on local storage - AvatarFileName string - // Gif? png? jpeg? - AvatarContentType string - // Size of the avatar in bytes - AvatarFileSize int - // When was the avatar last updated? - AvatarUpdatedAt time.Time `pg:"type:timestamp"` - // Where can the avatar be retrieved? - AvatarRemoteURL *url.URL `pg:"type:text"` - // File name of the header on local storage - HeaderFileName string - // Gif? png? jpeg? - HeaderContentType string - // Size of the header in bytes - HeaderFileSize int - // When was the header last updated? - HeaderUpdatedAt time.Time `pg:"type:timestamp"` - // Where can the header be retrieved? - HeaderRemoteURL *url.URL `pg:"type:text"` - // DisplayName for this account. Can be empty, then just the Username will be used for display purposes. - DisplayName string - // a key/value map of fields that this account has added to their profile - Fields []Field - // A note that this account has on their profile (ie., the account's bio/description of themselves) - Note string - // Is this a memorial account, ie., has the user passed away? - Memorial bool - // This account has moved this account id in the database - MovedToAccountID int - // When was this account created? - CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` - // When was this account last updated? - UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` - // When should this account function until - SubscriptionExpiresAt time.Time `pg:"type:timestamp"` - // Does this account identify itself as a bot? - Bot bool - // What reason was given for signing up when this account was created? - Reason string - - /* - USER AND PRIVACY PREFERENCES - */ - - // Does this account need an approval for new followers? - Locked bool - // Should this account be shown in the instance's profile directory? - Discoverable bool - // Default post privacy for this account - Privacy string - // Set posts from this account to sensitive by default? - Sensitive bool - // What language does this account post in? - Language string - - /* - ACTIVITYPUB THINGS - */ - - // What is the activitypub URI for this account discovered by webfinger? - URI string `pg:",unique"` - // At which URL can we see the user account in a web browser? - URL string `pg:",unique"` - // Last time this account was located using the webfinger API. - LastWebfingeredAt time.Time `pg:"type:timestamp"` - // Address of this account's activitypub inbox, for sending activity to - InboxURL string `pg:",unique"` - // Address of this account's activitypub outbox - OutboxURL string `pg:",unique"` - // Don't support shared inbox right now so this is just a stub for a future implementation - SharedInboxURL string `pg:",unique"` - // URL for getting the followers list of this account - FollowersURL string `pg:",unique"` - // URL for getting the featured collection list of this account - FeaturedCollectionURL string `pg:",unique"` - // What type of activitypub actor is this account? - ActorType string - // This account is associated with x account id - AlsoKnownAs string - - /* - CRYPTO FIELDS - */ - - Secret string - // Privatekey for validating activitypub requests, will obviously only be defined for local accounts - PrivateKey *rsa.PrivateKey - // Publickey for encoding activitypub requests, will be defined for both local and remote accounts - PublicKey *rsa.PublicKey - - /* - ADMIN FIELDS - */ - - // When was this account set to have all its media shown as sensitive? - SensitizedAt time.Time `pg:"type:timestamp"` - // When was this account silenced (eg., statuses only visible to followers, not public)? - SilencedAt time.Time `pg:"type:timestamp"` - // When was this account suspended (eg., don't allow it to log in/post, don't accept media/posts from this account) - SuspendedAt time.Time `pg:"type:timestamp"` - // How much do we trust this account 🤔 - TrustLevel int - // Should we hide this account's collections? - HideCollections bool - // id of the user that suspended this account through an admin action - SuspensionOrigin int -} - -// Field represents a key value field on an account, for things like pronouns, website, etc. -// VerifiedAt is optional, to be used only if Value is a URL to a webpage that contains the -// username of the user. -type Field struct { - Name string - Value string - VerifiedAt time.Time `pg:"type:timestamp"` -} diff --git a/internal/db/model/application.go b/internal/db/model/application.go @@ -1,55 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package model - -import "github.com/superseriousbusiness/gotosocial/pkg/mastotypes" - -// Application represents an application that can perform actions on behalf of a user. -// It is used to authorize tokens etc, and is associated with an oauth client id in the database. -type Application struct { - // id of this application in the db - ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull"` - // name of the application given when it was created (eg., 'tusky') - Name string - // website for the application given when it was created (eg., 'https://tusky.app') - Website string - // redirect uri requested by the application for oauth2 flow - RedirectURI string - // id of the associated oauth client entity in the db - ClientID string - // secret of the associated oauth client entity in the db - ClientSecret string - // scopes requested when this app was created - Scopes string - // a vapid key generated for this app when it was created - VapidKey string -} - -// ToMasto returns this application as a mastodon api type, ready for serialization -func (a *Application) ToMasto() *mastotypes.Application { - return &mastotypes.Application{ - ID: a.ID, - Name: a.Name, - Website: a.Website, - RedirectURI: a.RedirectURI, - ClientID: a.ClientID, - ClientSecret: a.ClientSecret, - VapidKey: a.VapidKey, - } -} diff --git a/internal/db/model/domainblock.go b/internal/db/model/domainblock.go @@ -1,47 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package model - -import "time" - -// DomainBlock represents a federation block against a particular domain, of varying severity. -type DomainBlock struct { - // ID of this block in the database - ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` - // Domain to block. If ANY PART of the candidate domain contains this string, it will be blocked. - // For example: 'example.org' also blocks 'gts.example.org'. '.com' blocks *any* '.com' domains. - // TODO: implement wildcards here - Domain string `pg:",notnull"` - // When was this block created - CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` - // When was this block updated - UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` - // Account ID of the creator of this block - CreatedByAccountID string `pg:",notnull"` - // TODO: define this - Severity int - // Reject media from this domain? - RejectMedia bool - // Reject reports from this domain? - RejectReports bool - // Private comment on this block, viewable to admins - PrivateComment string - // Public comment on this block, viewable (optionally) by everyone - PublicComment string -} diff --git a/internal/db/model/emaildomainblock.go b/internal/db/model/emaildomainblock.go @@ -1,35 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package model - -import "time" - -// EmailDomainBlock represents a domain that the server should automatically reject sign-up requests from. -type EmailDomainBlock struct { - // ID of this block in the database - ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` - // Email domain to block. Eg. 'gmail.com' or 'hotmail.com' - Domain string `pg:",notnull"` - // When was this block created - CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` - // When was this block updated - UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` - // Account ID of the creator of this block - CreatedByAccountID string `pg:",notnull"` -} diff --git a/internal/db/model/follow.go b/internal/db/model/follow.go @@ -1,41 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package model - -import "time" - -// Follow represents one account following another, and the metadata around that follow. -type Follow struct { - // id of this follow in the database - ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` - // When was this follow created? - CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` - // When was this follow last updated? - UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` - // Who does this follow belong to? - AccountID string `pg:",unique:srctarget,notnull"` - // Who does AccountID follow? - TargetAccountID string `pg:",unique:srctarget,notnull"` - // Does this follow also want to see reblogs and not just posts? - ShowReblogs bool `pg:"default:true"` - // What is the activitypub URI of this follow? - URI string `pg:",unique"` - // does the following account want to be notified when the followed account posts? - Notify bool -} diff --git a/internal/db/model/followrequest.go b/internal/db/model/followrequest.go @@ -1,41 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package model - -import "time" - -// FollowRequest represents one account requesting to follow another, and the metadata around that request. -type FollowRequest struct { - // id of this follow request in the database - ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` - // When was this follow request created? - CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` - // When was this follow request last updated? - UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` - // Who does this follow request originate from? - AccountID string `pg:",unique:srctarget,notnull"` - // Who is the target of this follow request? - TargetAccountID string `pg:",unique:srctarget,notnull"` - // Does this follow also want to see reblogs and not just posts? - ShowReblogs bool `pg:"default:true"` - // What is the activitypub URI of this follow request? - URI string `pg:",unique"` - // does the following account want to be notified when the followed account posts? - Notify bool -} diff --git a/internal/db/model/mediaattachment.go b/internal/db/model/mediaattachment.go @@ -1,136 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package model - -import ( - "time" -) - -// MediaAttachment represents a user-uploaded media attachment: an image/video/audio/gif that is -// somewhere in storage and that can be retrieved and served by the router. -type MediaAttachment struct { - // ID of the attachment in the database - ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` - // ID of the status to which this is attached - StatusID string - // Where can the attachment be retrieved on a remote server - RemoteURL string - // When was the attachment created - CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` - // When was the attachment last updated - UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` - // Type of file (image/gif/audio/video) - Type FileType `pg:",notnull"` - // Metadata about the file - FileMeta FileMeta - // To which account does this attachment belong - AccountID string `pg:",notnull"` - // Description of the attachment (for screenreaders) - Description string - // To which scheduled status does this attachment belong - ScheduledStatusID string - // What is the generated blurhash of this attachment - Blurhash string - // What is the processing status of this attachment - Processing ProcessingStatus - // metadata for the whole file - File File - // small image thumbnail derived from a larger image, video, or audio file. - Thumbnail Thumbnail - // Is this attachment being used as an avatar? - Avatar bool - // Is this attachment being used as a header? - Header bool -} - -// File refers to the metadata for the whole file -type File struct { - // What is the path of the file in storage. - Path string - // What is the MIME content type of the file. - ContentType string - // What is the size of the file in bytes. - FileSize int - // When was the file last updated. - UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` -} - -// Thumbnail refers to a small image thumbnail derived from a larger image, video, or audio file. -type Thumbnail struct { - // What is the path of the file in storage - Path string - // What is the MIME content type of the file. - ContentType string - // What is the size of the file in bytes - FileSize int - // When was the file last updated - UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` - // What is the remote URL of the thumbnail - RemoteURL string -} - -// ProcessingStatus refers to how far along in the processing stage the attachment is. -type ProcessingStatus int - -const ( - // ProcessingStatusReceived: the attachment has been received and is awaiting processing. No thumbnail available yet. - ProcessingStatusReceived ProcessingStatus = 0 - // ProcessingStatusProcessing: the attachment is currently being processed. Thumbnail is available but full media is not. - ProcessingStatusProcessing ProcessingStatus = 1 - // ProcessingStatusProcessed: the attachment has been fully processed and is ready to be served. - ProcessingStatusProcessed ProcessingStatus = 2 - // ProcessingStatusError: something went wrong processing the attachment and it won't be tried again--these can be deleted. - ProcessingStatusError ProcessingStatus = 666 -) - -// FileType refers to the file type of the media attaachment. -type FileType string - -const ( - // FileTypeImage is for jpegs and pngs - FileTypeImage FileType = "image" - // FileTypeGif is for native gifs and soundless videos that have been converted to gifs - FileTypeGif FileType = "gif" - // FileTypeAudio is for audio-only files (no video) - FileTypeAudio FileType = "audio" - // FileTypeVideo is for files with audio + visual - FileTypeVideo FileType = "video" -) - -// FileMeta describes metadata about the actual contents of the file. -type FileMeta struct { - Original Original - Small Small -} - -// Small implements SmallMeta and can be used for a thumbnail of any media type -type Small struct { - Width int - Height int - Size int - Aspect float64 -} - -// ImageOriginal implements OriginalMeta for still images -type Original struct { - Width int - Height int - Size int - Aspect float64 -} diff --git a/internal/db/model/status.go b/internal/db/model/status.go @@ -1,63 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package model - -import "time" - -// Status represents a user-created 'post' or 'status' in the database, either remote or local -type Status struct { - // id of the status in the database - ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull"` - // uri at which this status is reachable - URI string `pg:",unique"` - // web url for viewing this status - URL string `pg:",unique"` - // the html-formatted content of this status - Content string - // when was this status created? - CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` - // when was this status updated? - UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` - // is this status from a local account? - Local bool - // which account posted this status? - AccountID string - // id of the status this status is a reply to - InReplyToID string - // id of the status this status is a boost of - BoostOfID string - // cw string for this status - ContentWarning string - // visibility entry for this status - Visibility *Visibility -} - -// Visibility represents the visibility granularity of a status. It is a combination of flags. -type Visibility struct { - // Is this status viewable as a direct message? - Direct bool - // Is this status viewable to followers? - Followers bool - // Is this status viewable on the local timeline? - Local bool - // Is this status boostable but not shown on public timelines? - Unlisted bool - // Is this status shown on public and federated timelines? - Public bool -} diff --git a/internal/db/model/user.go b/internal/db/model/user.go @@ -1,120 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package model - -import ( - "net" - "time" -) - -// User represents an actual human user of gotosocial. Note, this is a LOCAL gotosocial user, not a remote account. -// To cross reference this local user with their account (which can be local or remote), use the AccountID field. -type User struct { - /* - BASIC INFO - */ - - // id of this user in the local database; the end-user will never need to know this, it's strictly internal - ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` - // confirmed email address for this user, this should be unique -- only one email address registered per instance, multiple users per email are not supported - Email string `pg:"default:null,unique"` - // The id of the local gtsmodel.Account entry for this user, if it exists (unconfirmed users don't have an account yet) - AccountID string `pg:"default:'',notnull,unique"` - // The encrypted password of this user, generated using https://pkg.go.dev/golang.org/x/crypto/bcrypt#GenerateFromPassword. A salt is included so we're safe against 🌈 tables - EncryptedPassword string `pg:",notnull"` - - /* - USER METADATA - */ - - // When was this user created? - CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` - // From what IP was this user created? - SignUpIP net.IP - // When was this user updated (eg., password changed, email address changed)? - UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` - // When did this user sign in for their current session? - CurrentSignInAt time.Time `pg:"type:timestamp"` - // What's the most recent IP of this user - CurrentSignInIP net.IP - // When did this user last sign in? - LastSignInAt time.Time `pg:"type:timestamp"` - // What's the previous IP of this user? - LastSignInIP net.IP - // How many times has this user signed in? - SignInCount int - // id of the user who invited this user (who let this guy in?) - InviteID string - // What languages does this user want to see? - ChosenLanguages []string - // What languages does this user not want to see? - FilteredLanguages []string - // In what timezone/locale is this user located? - Locale string - // Which application id created this user? See gtsmodel.Application - CreatedByApplicationID string - // When did we last contact this user - LastEmailedAt time.Time `pg:"type:timestamp"` - - /* - USER CONFIRMATION - */ - - // What confirmation token did we send this user/what are we expecting back? - ConfirmationToken string - // When did the user confirm their email address - ConfirmedAt time.Time `pg:"type:timestamp"` - // When did we send email confirmation to this user? - ConfirmationSentAt time.Time `pg:"type:timestamp"` - // Email address that hasn't yet been confirmed - UnconfirmedEmail string - - /* - ACL FLAGS - */ - - // Is this user a moderator? - Moderator bool - // Is this user an admin? - Admin bool - // Is this user disabled from posting? - Disabled bool - // Has this user been approved by a moderator? - Approved bool - - /* - USER SECURITY - */ - - // The generated token that the user can use to reset their password - ResetPasswordToken string - // When did we email the user their reset-password email? - ResetPasswordSentAt time.Time `pg:"type:timestamp"` - - EncryptedOTPSecret string - EncryptedOTPSecretIv string - EncryptedOTPSecretSalt string - OTPRequiredForLogin bool - OTPBackupCodes []string - ConsumedTimestamp int - RememberToken string - SignInToken string - SignInTokenSentAt time.Time `pg:"type:timestamp"` - WebauthnID string -} diff --git a/internal/db/pg.go b/internal/db/pg.go @@ -34,11 +34,11 @@ import ( "github.com/go-pg/pg/extra/pgdebug" "github.com/go-pg/pg/v10" "github.com/go-pg/pg/v10/orm" + "github.com/google/uuid" "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db/model" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/util" - "github.com/superseriousbusiness/gotosocial/pkg/mastotypes" "golang.org/x/crypto/bcrypt" ) @@ -60,12 +60,6 @@ func newPostgresService(ctx context.Context, c *config.Config, log *logrus.Entry } log.Debugf("using pg options: %+v", opts) - readyChan := make(chan interface{}) - opts.OnConnect = func(ctx context.Context, c *pg.Conn) error { - close(readyChan) - return nil - } - // create a connection pgCtx, cancel := context.WithCancel(ctx) conn := pg.Connect(opts).WithContext(pgCtx) @@ -80,8 +74,7 @@ func newPostgresService(ctx context.Context, c *config.Config, log *logrus.Entry }) } - // actually *begin* the connection so that we can tell if the db is there - // and listening, and also trigger the opts.OnConnect function passed in above + // actually *begin* the connection so that we can tell if the db is there and listening if err := conn.Ping(ctx); err != nil { cancel() return nil, fmt.Errorf("db connection error: %s", err) @@ -95,16 +88,6 @@ func newPostgresService(ctx context.Context, c *config.Config, log *logrus.Entry } log.Infof("connected to postgres version: %s", version) - // make sure the opts.OnConnect function has been triggered - // and closed the ready channel - select { - case <-readyChan: - log.Infof("postgres connection ready") - case <-time.After(5 * time.Second): - cancel() - return nil, errors.New("db connection timeout") - } - ps := &postgresService{ config: c, conn: conn, @@ -214,9 +197,9 @@ func (ps *postgresService) IsHealthy(ctx context.Context) error { func (ps *postgresService) CreateSchema(ctx context.Context) error { models := []interface{}{ - (*model.Account)(nil), - (*model.Status)(nil), - (*model.User)(nil), + (*gtsmodel.Account)(nil), + (*gtsmodel.Status)(nil), + (*gtsmodel.User)(nil), } ps.log.Info("creating db schema") @@ -254,6 +237,10 @@ func (ps *postgresService) GetWhere(key string, value interface{}, i interface{} return nil } +// func (ps *postgresService) GetWhereMany(i interface{}, where ...model.Where) error { +// return nil +// } + func (ps *postgresService) GetAll(i interface{}) error { if err := ps.conn.Model(i).Select(); err != nil { if err == pg.ErrNoRows { @@ -269,8 +256,18 @@ func (ps *postgresService) Put(i interface{}) error { return err } +func (ps *postgresService) Upsert(i interface{}, conflictColumn string) error { + if _, err := ps.conn.Model(i).OnConflict(fmt.Sprintf("(%s) DO UPDATE", conflictColumn)).Insert(); err != nil { + if err == pg.ErrNoRows { + return ErrNoEntries{} + } + return err + } + return nil +} + func (ps *postgresService) UpdateByID(id string, i interface{}) error { - if _, err := ps.conn.Model(i).OnConflict("(id) DO UPDATE").Insert(); err != nil { + if _, err := ps.conn.Model(i).Where("id = ?", id).OnConflict("(id) DO UPDATE").Insert(); err != nil { if err == pg.ErrNoRows { return ErrNoEntries{} } @@ -308,8 +305,25 @@ func (ps *postgresService) DeleteWhere(key string, value interface{}, i interfac HANDY SHORTCUTS */ -func (ps *postgresService) GetAccountByUserID(userID string, account *model.Account) error { - user := &model.User{ +func (ps *postgresService) CreateInstanceAccount() error { + username := ps.config.Host + instanceAccount := &gtsmodel.Account{ + Username: username, + } + inserted, err := ps.conn.Model(instanceAccount).Where("username = ?", username).SelectOrInsert() + if err != nil { + return err + } + if inserted { + ps.log.Infof("created instance account %s with id %s", username, instanceAccount.ID) + } else { + ps.log.Infof("instance account %s already exists with id %s", username, instanceAccount.ID) + } + return nil +} + +func (ps *postgresService) GetAccountByUserID(userID string, account *gtsmodel.Account) error { + user := &gtsmodel.User{ ID: userID, } if err := ps.conn.Model(user).Where("id = ?", userID).Select(); err != nil { @@ -327,7 +341,7 @@ func (ps *postgresService) GetAccountByUserID(userID string, account *model.Acco return nil } -func (ps *postgresService) GetFollowRequestsForAccountID(accountID string, followRequests *[]model.FollowRequest) error { +func (ps *postgresService) GetFollowRequestsForAccountID(accountID string, followRequests *[]gtsmodel.FollowRequest) error { if err := ps.conn.Model(followRequests).Where("target_account_id = ?", accountID).Select(); err != nil { if err == pg.ErrNoRows { return ErrNoEntries{} @@ -337,7 +351,7 @@ func (ps *postgresService) GetFollowRequestsForAccountID(accountID string, follo return nil } -func (ps *postgresService) GetFollowingByAccountID(accountID string, following *[]model.Follow) error { +func (ps *postgresService) GetFollowingByAccountID(accountID string, following *[]gtsmodel.Follow) error { if err := ps.conn.Model(following).Where("account_id = ?", accountID).Select(); err != nil { if err == pg.ErrNoRows { return ErrNoEntries{} @@ -347,7 +361,7 @@ func (ps *postgresService) GetFollowingByAccountID(accountID string, following * return nil } -func (ps *postgresService) GetFollowersByAccountID(accountID string, followers *[]model.Follow) error { +func (ps *postgresService) GetFollowersByAccountID(accountID string, followers *[]gtsmodel.Follow) error { if err := ps.conn.Model(followers).Where("target_account_id = ?", accountID).Select(); err != nil { if err == pg.ErrNoRows { return ErrNoEntries{} @@ -357,7 +371,7 @@ func (ps *postgresService) GetFollowersByAccountID(accountID string, followers * return nil } -func (ps *postgresService) GetStatusesByAccountID(accountID string, statuses *[]model.Status) error { +func (ps *postgresService) GetStatusesByAccountID(accountID string, statuses *[]gtsmodel.Status) error { if err := ps.conn.Model(statuses).Where("account_id = ?", accountID).Select(); err != nil { if err == pg.ErrNoRows { return ErrNoEntries{} @@ -367,7 +381,7 @@ func (ps *postgresService) GetStatusesByAccountID(accountID string, statuses *[] return nil } -func (ps *postgresService) GetStatusesByTimeDescending(accountID string, statuses *[]model.Status, limit int) error { +func (ps *postgresService) GetStatusesByTimeDescending(accountID string, statuses *[]gtsmodel.Status, limit int) error { q := ps.conn.Model(statuses).Order("created_at DESC") if limit != 0 { q = q.Limit(limit) @@ -384,7 +398,7 @@ func (ps *postgresService) GetStatusesByTimeDescending(accountID string, statuse return nil } -func (ps *postgresService) GetLastStatusForAccountID(accountID string, status *model.Status) error { +func (ps *postgresService) GetLastStatusForAccountID(accountID string, status *gtsmodel.Status) error { if err := ps.conn.Model(status).Order("created_at DESC").Limit(1).Where("account_id = ?", accountID).Select(); err != nil { if err == pg.ErrNoRows { return ErrNoEntries{} @@ -399,7 +413,7 @@ func (ps *postgresService) IsUsernameAvailable(username string) error { // if no error we fail because it means we found something // if error but it's not pg.ErrNoRows then we fail // if err is pg.ErrNoRows we're good, we found nothing so continue - if err := ps.conn.Model(&model.Account{}).Where("username = ?", username).Where("domain = ?", nil).Select(); err == nil { + if err := ps.conn.Model(&gtsmodel.Account{}).Where("username = ?", username).Where("domain = ?", nil).Select(); err == nil { return fmt.Errorf("username %s already in use", username) } else if err != pg.ErrNoRows { return fmt.Errorf("db error: %s", err) @@ -416,7 +430,7 @@ func (ps *postgresService) IsEmailAvailable(email string) error { domain := strings.Split(m.Address, "@")[1] // domain will always be the second part after @ // check if the email domain is blocked - if err := ps.conn.Model(&model.EmailDomainBlock{}).Where("domain = ?", domain).Select(); err == nil { + if err := ps.conn.Model(&gtsmodel.EmailDomainBlock{}).Where("domain = ?", domain).Select(); err == nil { // fail because we found something return fmt.Errorf("email domain %s is blocked", domain) } else if err != pg.ErrNoRows { @@ -425,7 +439,7 @@ func (ps *postgresService) IsEmailAvailable(email string) error { } // check if this email is associated with a user already - if err := ps.conn.Model(&model.User{}).Where("email = ?", email).WhereOr("unconfirmed_email = ?", email).Select(); err == nil { + if err := ps.conn.Model(&gtsmodel.User{}).Where("email = ?", email).WhereOr("unconfirmed_email = ?", email).Select(); err == nil { // fail because we found something return fmt.Errorf("email %s already in use", email) } else if err != pg.ErrNoRows { @@ -435,7 +449,7 @@ func (ps *postgresService) IsEmailAvailable(email string) error { return nil } -func (ps *postgresService) NewSignup(username string, reason string, requireApproval bool, email string, password string, signUpIP net.IP, locale string, appID string) (*model.User, error) { +func (ps *postgresService) NewSignup(username string, reason string, requireApproval bool, email string, password string, signUpIP net.IP, locale string, appID string) (*gtsmodel.User, error) { key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { ps.log.Errorf("error creating new rsa key: %s", err) @@ -444,19 +458,19 @@ func (ps *postgresService) NewSignup(username string, reason string, requireAppr uris := util.GenerateURIs(username, ps.config.Protocol, ps.config.Host) - a := &model.Account{ + a := &gtsmodel.Account{ Username: username, DisplayName: username, Reason: reason, URL: uris.UserURL, PrivateKey: key, PublicKey: &key.PublicKey, - ActorType: "Person", + ActorType: gtsmodel.ActivityStreamsPerson, URI: uris.UserURI, - InboxURL: uris.InboxURL, - OutboxURL: uris.OutboxURL, - FollowersURL: uris.FollowersURL, - FeaturedCollectionURL: uris.CollectionURL, + InboxURL: uris.InboxURI, + OutboxURL: uris.OutboxURI, + FollowersURL: uris.FollowersURI, + FeaturedCollectionURL: uris.CollectionURI, } if _, err = ps.conn.Model(a).Insert(); err != nil { return nil, err @@ -466,7 +480,7 @@ func (ps *postgresService) NewSignup(username string, reason string, requireAppr if err != nil { return nil, fmt.Errorf("error hashing password: %s", err) } - u := &model.User{ + u := &gtsmodel.User{ AccountID: a.ID, EncryptedPassword: string(pw), SignUpIP: signUpIP, @@ -482,13 +496,45 @@ func (ps *postgresService) NewSignup(username string, reason string, requireAppr return u, nil } -func (ps *postgresService) SetHeaderOrAvatarForAccountID(mediaAttachment *model.MediaAttachment, accountID string) error { - _, err := ps.conn.Model(mediaAttachment).Insert() - return err +func (ps *postgresService) SetHeaderOrAvatarForAccountID(mediaAttachment *gtsmodel.MediaAttachment, accountID string) error { + if mediaAttachment.Avatar && mediaAttachment.Header { + return errors.New("one media attachment cannot be both header and avatar") + } + + var headerOrAVI string + if mediaAttachment.Avatar { + headerOrAVI = "avatar" + } else if mediaAttachment.Header { + headerOrAVI = "header" + } else { + return errors.New("given media attachment was neither a header nor an avatar") + } + + // TODO: there are probably more side effects here that need to be handled + if _, err := ps.conn.Model(mediaAttachment).OnConflict("(id) DO UPDATE").Insert(); err != nil { + return err + } + + if _, err := ps.conn.Model(&gtsmodel.Account{}).Set(fmt.Sprintf("%s_media_attachment_id = ?", headerOrAVI), mediaAttachment.ID).Where("id = ?", accountID).Update(); err != nil { + return err + } + return nil } -func (ps *postgresService) GetHeaderForAccountID(header *model.MediaAttachment, accountID string) error { - if err := ps.conn.Model(header).Where("account_id = ?", accountID).Where("header = ?", true).Select(); err != nil { +func (ps *postgresService) GetHeaderForAccountID(header *gtsmodel.MediaAttachment, accountID string) error { + acct := &gtsmodel.Account{} + if err := ps.conn.Model(acct).Where("id = ?", accountID).Select(); err != nil { + if err == pg.ErrNoRows { + return ErrNoEntries{} + } + return err + } + + if acct.HeaderMediaAttachmentID == "" { + return ErrNoEntries{} + } + + if err := ps.conn.Model(header).Where("id = ?", acct.HeaderMediaAttachmentID).Select(); err != nil { if err == pg.ErrNoRows { return ErrNoEntries{} } @@ -497,8 +543,20 @@ func (ps *postgresService) GetHeaderForAccountID(header *model.MediaAttachment, return nil } -func (ps *postgresService) GetAvatarForAccountID(avatar *model.MediaAttachment, accountID string) error { - if err := ps.conn.Model(avatar).Where("account_id = ?", accountID).Where("avatar = ?", true).Select(); err != nil { +func (ps *postgresService) GetAvatarForAccountID(avatar *gtsmodel.MediaAttachment, accountID string) error { + acct := &gtsmodel.Account{} + if err := ps.conn.Model(acct).Where("id = ?", accountID).Select(); err != nil { + if err == pg.ErrNoRows { + return ErrNoEntries{} + } + return err + } + + if acct.AvatarMediaAttachmentID == "" { + return ErrNoEntries{} + } + + if err := ps.conn.Model(avatar).Where("id = ?", acct.AvatarMediaAttachmentID).Select(); err != nil { if err == pg.ErrNoRows { return ErrNoEntries{} } @@ -507,156 +565,480 @@ func (ps *postgresService) GetAvatarForAccountID(avatar *model.MediaAttachment, return nil } -/* - CONVERSION FUNCTIONS -*/ +func (ps *postgresService) Blocked(account1 string, account2 string) (bool, error) { + var blocked bool + if err := ps.conn.Model(&gtsmodel.Block{}). + Where("account_id = ?", account1).Where("target_account_id = ?", account2). + WhereOr("target_account_id = ?", account1).Where("account_id = ?", account2). + Select(); err != nil { + if err == pg.ErrNoRows { + blocked = false + return blocked, nil + } else { + return blocked, err + } + } + blocked = true + return blocked, nil +} -// AccountToMastoSensitive takes an internal account model and transforms it into an account ready to be served through the API. -// The resulting account fits the specifications for the path /api/v1/accounts/verify_credentials, as described here: -// https://docs.joinmastodon.org/methods/accounts/. Note that it's *sensitive* because it's only meant to be exposed to the user -// that the account actually belongs to. -func (ps *postgresService) AccountToMastoSensitive(a *model.Account) (*mastotypes.Account, error) { - // we can build this sensitive account easily by first getting the public account.... - mastoAccount, err := ps.AccountToMastoPublic(a) - if err != nil { - return nil, err +func (ps *postgresService) StatusVisible(targetStatus *gtsmodel.Status, targetAccount *gtsmodel.Account, requestingAccount *gtsmodel.Account, relevantAccounts *gtsmodel.RelevantAccounts) (bool, error) { + l := ps.log.WithField("func", "StatusVisible") + + // if target account is suspended then don't show the status + if !targetAccount.SuspendedAt.IsZero() { + l.Debug("target account suspended at is not zero") + return false, nil } - // then adding the Source object to it... + // if the target user doesn't exist (anymore) then the status also shouldn't be visible + targetUser := &gtsmodel.User{} + if err := ps.conn.Model(targetUser).Where("account_id = ?", targetAccount.ID).Select(); err != nil { + l.Debug("target user could not be selected") + if err == pg.ErrNoRows { + return false, ErrNoEntries{} + } else { + return false, err + } + } - // check pending follow requests aimed at this account - fr := []model.FollowRequest{} - if err := ps.GetFollowRequestsForAccountID(a.ID, &fr); err != nil { - if _, ok := err.(ErrNoEntries); !ok { - return nil, fmt.Errorf("error getting follow requests: %s", err) + // if target user is disabled, not yet approved, or not confirmed then don't show the status + // (although in the latter two cases it's unlikely they posted a status yet anyway, but you never know!) + if targetUser.Disabled || !targetUser.Approved || targetUser.ConfirmedAt.IsZero() { + l.Debug("target user is disabled, not approved, or not confirmed") + return false, nil + } + + // If requesting account is nil, that means whoever requested the status didn't auth, or their auth failed. + // In this case, we can still serve the status if it's public, otherwise we definitely shouldn't. + if requestingAccount == nil { + + if targetStatus.Visibility == gtsmodel.VisibilityPublic { + return true, nil } + l.Debug("requesting account is nil but the target status isn't public") + return false, nil } - var frc int - if fr != nil { - frc = len(fr) + + // if requesting account is suspended then don't show the status -- although they probably shouldn't have gotten + // this far (ie., been authed) in the first place: this is just for safety. + if !requestingAccount.SuspendedAt.IsZero() { + l.Debug("requesting account is suspended") + return false, nil } - mastoAccount.Source = &mastotypes.Source{ - Privacy: a.Privacy, - Sensitive: a.Sensitive, - Language: a.Language, - Note: a.Note, - Fields: mastoAccount.Fields, - FollowRequestsCount: frc, + // check if we have a local account -- if so we can check the user for that account in the DB + if requestingAccount.Domain == "" { + requestingUser := &gtsmodel.User{} + if err := ps.conn.Model(requestingUser).Where("account_id = ?", requestingAccount.ID).Select(); err != nil { + // if the requesting account is local but doesn't have a corresponding user in the db this is a problem + if err == pg.ErrNoRows { + l.Debug("requesting account is local but there's no corresponding user") + return false, nil + } else { + l.Debugf("requesting account is local but there was an error getting the corresponding user: %s", err) + return false, err + } + } + // okay, user exists, so make sure it has full privileges/is confirmed/approved + if requestingUser.Disabled || !requestingUser.Approved || requestingUser.ConfirmedAt.IsZero() { + l.Debug("requesting account is local but corresponding user is either disabled, not approved, or not confirmed") + return false, nil + } } - return mastoAccount, nil -} + // if the target status belongs to the requesting account, they should always be able to view it at this point + if targetStatus.AccountID == requestingAccount.ID { + return true, nil + } + + // At this point we have a populated targetAccount, targetStatus, and requestingAccount, so we can check for blocks and whathaveyou + // First check if a block exists directly between the target account (which authored the status) and the requesting account. + if blocked, err := ps.Blocked(targetAccount.ID, requestingAccount.ID); err != nil { + l.Debugf("something went wrong figuring out if the accounts have a block: %s", err) + return false, err + } else if blocked { + // don't allow the status to be viewed if a block exists in *either* direction between these two accounts, no creepy stalking please + l.Debug("a block exists between requesting account and target account") + return false, nil + } + + // check other accounts mentioned/boosted by/replied to by the status, if they exist + if relevantAccounts != nil { + // status replies to account id + if relevantAccounts.ReplyToAccount != nil { + if blocked, err := ps.Blocked(relevantAccounts.ReplyToAccount.ID, requestingAccount.ID); err != nil { + return false, err + } else if blocked { + return false, nil + } + } -func (ps *postgresService) AccountToMastoPublic(a *model.Account) (*mastotypes.Account, error) { - // count followers - followers := []model.Follow{} - if err := ps.GetFollowersByAccountID(a.ID, &followers); err != nil { - if _, ok := err.(ErrNoEntries); !ok { - return nil, fmt.Errorf("error getting followers: %s", err) + // status boosts accounts id + if relevantAccounts.BoostedAccount != nil { + if blocked, err := ps.Blocked(relevantAccounts.BoostedAccount.ID, requestingAccount.ID); err != nil { + return false, err + } else if blocked { + return false, nil + } + } + + // status boosts a reply to account id + if relevantAccounts.BoostedReplyToAccount != nil { + if blocked, err := ps.Blocked(relevantAccounts.BoostedReplyToAccount.ID, requestingAccount.ID); err != nil { + return false, err + } else if blocked { + return false, nil + } + } + + // status mentions accounts + for _, a := range relevantAccounts.MentionedAccounts { + if blocked, err := ps.Blocked(a.ID, requestingAccount.ID); err != nil { + return false, err + } else if blocked { + return false, nil + } + } + } + + // at this point we know neither account blocks the other, or another account mentioned or otherwise referred to in the status + // that means it's now just a matter of checking the visibility settings of the status itself + switch targetStatus.Visibility { + case gtsmodel.VisibilityPublic, gtsmodel.VisibilityUnlocked: + // no problem here, just return OK + return true, nil + case gtsmodel.VisibilityFollowersOnly: + // check one-way follow + follows, err := ps.Follows(requestingAccount, targetAccount) + if err != nil { + return false, err + } + if !follows { + return false, nil + } + return true, nil + case gtsmodel.VisibilityMutualsOnly: + // check mutual follow + mutuals, err := ps.Mutuals(requestingAccount, targetAccount) + if err != nil { + return false, err + } + if !mutuals { + return false, nil + } + return true, nil + case gtsmodel.VisibilityDirect: + // make sure the requesting account is mentioned in the status + for _, menchie := range targetStatus.Mentions { + if menchie == requestingAccount.ID { + return true, nil // yep it's mentioned! + } } + return false, nil // it's not mentioned -_- } - var followersCount int - if followers != nil { - followersCount = len(followers) + + return false, errors.New("reached the end of StatusVisible with no result") +} + +func (ps *postgresService) Follows(sourceAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) (bool, error) { + return ps.conn.Model(&gtsmodel.Follow{}).Where("account_id = ?", sourceAccount.ID).Where("target_account_id = ?", targetAccount.ID).Exists() +} + +func (ps *postgresService) Mutuals(account1 *gtsmodel.Account, account2 *gtsmodel.Account) (bool, error) { + // make sure account 1 follows account 2 + f1, err := ps.conn.Model(&gtsmodel.Follow{}).Where("account_id = ?", account1.ID).Where("target_account_id = ?", account2.ID).Exists() + if err != nil { + if err == pg.ErrNoRows { + return false, nil + } else { + return false, err + } } - // count following - following := []model.Follow{} - if err := ps.GetFollowingByAccountID(a.ID, &following); err != nil { - if _, ok := err.(ErrNoEntries); !ok { - return nil, fmt.Errorf("error getting following: %s", err) + // make sure account 2 follows account 1 + f2, err := ps.conn.Model(&gtsmodel.Follow{}).Where("account_id = ?", account2.ID).Where("target_account_id = ?", account1.ID).Exists() + if err != nil { + if err == pg.ErrNoRows { + return false, nil + } else { + return false, err } } - var followingCount int - if following != nil { - followingCount = len(following) + + return f1 && f2, nil +} + +func (ps *postgresService) PullRelevantAccountsFromStatus(targetStatus *gtsmodel.Status) (*gtsmodel.RelevantAccounts, error) { + accounts := &gtsmodel.RelevantAccounts{ + MentionedAccounts: []*gtsmodel.Account{}, } - // count statuses - statuses := []model.Status{} - if err := ps.GetStatusesByAccountID(a.ID, &statuses); err != nil { - if _, ok := err.(ErrNoEntries); !ok { - return nil, fmt.Errorf("error getting last statuses: %s", err) + // get the replied to account from the status and add it to the pile + if targetStatus.InReplyToAccountID != "" { + repliedToAccount := &gtsmodel.Account{} + if err := ps.conn.Model(repliedToAccount).Where("id = ?", targetStatus.InReplyToAccountID).Select(); err != nil { + return accounts, err } + accounts.ReplyToAccount = repliedToAccount } - var statusesCount int - if statuses != nil { - statusesCount = len(statuses) + + // get the boosted account from the status and add it to the pile + if targetStatus.BoostOfID != "" { + // retrieve the boosted status first + boostedStatus := &gtsmodel.Status{} + if err := ps.conn.Model(boostedStatus).Where("id = ?", targetStatus.BoostOfID).Select(); err != nil { + return accounts, err + } + boostedAccount := &gtsmodel.Account{} + if err := ps.conn.Model(boostedAccount).Where("id = ?", boostedStatus.AccountID).Select(); err != nil { + return accounts, err + } + accounts.BoostedAccount = boostedAccount + + // the boosted status might be a reply to another account so we should get that too + if boostedStatus.InReplyToAccountID != "" { + boostedStatusRepliedToAccount := &gtsmodel.Account{} + if err := ps.conn.Model(boostedStatusRepliedToAccount).Where("id = ?", boostedStatus.InReplyToAccountID).Select(); err != nil { + return accounts, err + } + accounts.BoostedReplyToAccount = boostedStatusRepliedToAccount + } } - // check when the last status was - lastStatus := &model.Status{} - if err := ps.GetLastStatusForAccountID(a.ID, lastStatus); err != nil { - if _, ok := err.(ErrNoEntries); !ok { - return nil, fmt.Errorf("error getting last status: %s", err) + // now get all accounts with IDs that are mentioned in the status + for _, mentionedAccountID := range targetStatus.Mentions { + mentionedAccount := &gtsmodel.Account{} + if err := ps.conn.Model(mentionedAccount).Where("id = ?", mentionedAccountID).Select(); err != nil { + return accounts, err } + accounts.MentionedAccounts = append(accounts.MentionedAccounts, mentionedAccount) + } + + return accounts, nil +} + +func (ps *postgresService) GetReplyCountForStatus(status *gtsmodel.Status) (int, error) { + return ps.conn.Model(&gtsmodel.Status{}).Where("in_reply_to_id = ?", status.ID).Count() +} + +func (ps *postgresService) GetReblogCountForStatus(status *gtsmodel.Status) (int, error) { + return ps.conn.Model(&gtsmodel.Status{}).Where("boost_of_id = ?", status.ID).Count() +} + +func (ps *postgresService) GetFaveCountForStatus(status *gtsmodel.Status) (int, error) { + return ps.conn.Model(&gtsmodel.StatusFave{}).Where("status_id = ?", status.ID).Count() +} + +func (ps *postgresService) StatusFavedBy(status *gtsmodel.Status, accountID string) (bool, error) { + return ps.conn.Model(&gtsmodel.StatusFave{}).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Exists() +} + +func (ps *postgresService) StatusRebloggedBy(status *gtsmodel.Status, accountID string) (bool, error) { + return ps.conn.Model(&gtsmodel.Status{}).Where("boost_of_id = ?", status.ID).Where("account_id = ?", accountID).Exists() +} + +func (ps *postgresService) StatusMutedBy(status *gtsmodel.Status, accountID string) (bool, error) { + return ps.conn.Model(&gtsmodel.StatusMute{}).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Exists() +} + +func (ps *postgresService) StatusBookmarkedBy(status *gtsmodel.Status, accountID string) (bool, error) { + return ps.conn.Model(&gtsmodel.StatusBookmark{}).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Exists() +} + +func (ps *postgresService) StatusPinnedBy(status *gtsmodel.Status, accountID string) (bool, error) { + return ps.conn.Model(&gtsmodel.StatusPin{}).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Exists() +} + +func (ps *postgresService) FaveStatus(status *gtsmodel.Status, accountID string) (*gtsmodel.StatusFave, error) { + // first check if a fave already exists, we can just return if so + existingFave := &gtsmodel.StatusFave{} + err := ps.conn.Model(existingFave).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Select() + if err == nil { + // fave already exists so just return nothing at all + return nil, nil + } + + // an error occurred so it might exist or not, we don't know + if err != pg.ErrNoRows { + return nil, err } - var lastStatusAt string - if lastStatus != nil { - lastStatusAt = lastStatus.CreatedAt.Format(time.RFC3339) + + // it doesn't exist so create it + newFave := &gtsmodel.StatusFave{ + AccountID: accountID, + TargetAccountID: status.AccountID, + StatusID: status.ID, + } + if _, err = ps.conn.Model(newFave).Insert(); err != nil { + return nil, err + } + + return newFave, nil +} + +func (ps *postgresService) UnfaveStatus(status *gtsmodel.Status, accountID string) (*gtsmodel.StatusFave, error) { + // if a fave doesn't exist, we don't need to do anything + existingFave := &gtsmodel.StatusFave{} + err := ps.conn.Model(existingFave).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Select() + // the fave doesn't exist so return nothing at all + if err == pg.ErrNoRows { + return nil, nil + } + + // an error occurred so it might exist or not, we don't know + if err != nil && err != pg.ErrNoRows { + return nil, err + } + + // the fave exists so remove it + if _, err = ps.conn.Model(&gtsmodel.StatusFave{}).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Delete(); err != nil { + return nil, err } - // build the avatar and header URLs - avi := &model.MediaAttachment{} - if err := ps.GetAvatarForAccountID(avi, a.ID); err != nil { - if _, ok := err.(ErrNoEntries); !ok { - return nil, fmt.Errorf("error getting avatar: %s", err) + return existingFave, nil +} + +func (ps *postgresService) WhoFavedStatus(status *gtsmodel.Status) ([]*gtsmodel.Account, error) { + accounts := []*gtsmodel.Account{} + + faves := []*gtsmodel.StatusFave{} + if err := ps.conn.Model(&faves).Where("status_id = ?", status.ID).Select(); err != nil { + if err == pg.ErrNoRows { + return accounts, nil // no rows just means nobody has faved this status, so that's fine } + return nil, err // an actual error has occurred } - aviURL := avi.File.Path - aviURLStatic := avi.Thumbnail.Path - header := &model.MediaAttachment{} - if err := ps.GetHeaderForAccountID(avi, a.ID); err != nil { - if _, ok := err.(ErrNoEntries); !ok { - return nil, fmt.Errorf("error getting header: %s", err) + for _, f := range faves { + acc := &gtsmodel.Account{} + if err := ps.conn.Model(acc).Where("id = ?", f.AccountID).Select(); err != nil { + if err == pg.ErrNoRows { + continue // the account doesn't exist for some reason??? but this isn't the place to worry about that so just skip it + } + return nil, err // an actual error has occurred } + accounts = append(accounts, acc) } - headerURL := header.File.Path - headerURLStatic := header.Thumbnail.Path + return accounts, nil +} + +/* + CONVERSION FUNCTIONS +*/ - // get the fields set on this account - fields := []mastotypes.Field{} - for _, f := range a.Fields { - mField := mastotypes.Field{ - Name: f.Name, - Value: f.Value, +func (ps *postgresService) MentionStringsToMentions(targetAccounts []string, originAccountID string, statusID string) ([]*gtsmodel.Mention, error) { + menchies := []*gtsmodel.Mention{} + for _, a := range targetAccounts { + // A mentioned account looks like "@test@example.org" or just "@test" for a local account + // -- we can guarantee this from the regex that targetAccounts should have been derived from. + // But we still need to do a bit of fiddling to get what we need here -- the username and domain (if given). + + // 1. trim off the first @ + t := strings.TrimPrefix(a, "@") + + // 2. split the username and domain + s := strings.Split(t, "@") + + // 3. if it's length 1 it's a local account, length 2 means remote, anything else means something is wrong + var local bool + switch len(s) { + case 1: + local = true + case 2: + local = false + default: + return nil, fmt.Errorf("mentioned account format '%s' was not valid", a) } - if !f.VerifiedAt.IsZero() { - mField.VerifiedAt = f.VerifiedAt.Format(time.RFC3339) + + var username, domain string + username = s[0] + if !local { + domain = s[1] + } + + // 4. check we now have a proper username and domain + if username == "" || (!local && domain == "") { + return nil, fmt.Errorf("username or domain for '%s' was nil", a) } - fields = append(fields, mField) + + // okay we're good now, we can start pulling accounts out of the database + mentionedAccount := &gtsmodel.Account{} + var err error + if local { + // local user -- should have a null domain + err = ps.conn.Model(mentionedAccount).Where("username = ?", username).Where("? IS NULL", pg.Ident("domain")).Select() + } else { + // remote user -- should have domain defined + err = ps.conn.Model(mentionedAccount).Where("username = ?", username).Where("? = ?", pg.Ident("domain"), domain).Select() + } + + if err != nil { + if err == pg.ErrNoRows { + // no result found for this username/domain so just don't include it as a mencho and carry on about our business + ps.log.Debugf("no account found with username '%s' and domain '%s', skipping it", username, domain) + continue + } + // a serious error has happened so bail + return nil, fmt.Errorf("error getting account with username '%s' and domain '%s': %s", username, domain, err) + } + + // id, createdAt and updatedAt will be populated by the db, so we have everything we need! + menchies = append(menchies, &gtsmodel.Mention{ + StatusID: statusID, + OriginAccountID: originAccountID, + TargetAccountID: mentionedAccount.ID, + }) } + return menchies, nil +} - var acct string - if a.Domain != "" { - // this is a remote user - acct = fmt.Sprintf("%s@%s", a.Username, a.Domain) - } else { - // this is a local user - acct = a.Username - } - - return &mastotypes.Account{ - ID: a.ID, - Username: a.Username, - Acct: acct, - DisplayName: a.DisplayName, - Locked: a.Locked, - Bot: a.Bot, - CreatedAt: a.CreatedAt.Format(time.RFC3339), - Note: a.Note, - URL: a.URL, - Avatar: aviURL, - AvatarStatic: aviURLStatic, - Header: headerURL, - HeaderStatic: headerURLStatic, - FollowersCount: followersCount, - FollowingCount: followingCount, - StatusesCount: statusesCount, - LastStatusAt: lastStatusAt, - Emojis: nil, // TODO: implement this - Fields: fields, - }, nil +func (ps *postgresService) TagStringsToTags(tags []string, originAccountID string, statusID string) ([]*gtsmodel.Tag, error) { + newTags := []*gtsmodel.Tag{} + for _, t := range tags { + tag := &gtsmodel.Tag{} + // we can use selectorinsert here to create the new tag if it doesn't exist already + // inserted will be true if this is a new tag we just created + if err := ps.conn.Model(tag).Where("name = ?", t).Select(); err != nil { + if err == pg.ErrNoRows { + // tag doesn't exist yet so populate it + tag.ID = uuid.NewString() + tag.Name = t + tag.FirstSeenFromAccountID = originAccountID + tag.CreatedAt = time.Now() + tag.UpdatedAt = time.Now() + tag.Useable = true + tag.Listable = true + } else { + return nil, fmt.Errorf("error getting tag with name %s: %s", t, err) + } + } + + // bail already if the tag isn't useable + if !tag.Useable { + continue + } + tag.LastStatusAt = time.Now() + newTags = append(newTags, tag) + } + return newTags, nil +} + +func (ps *postgresService) EmojiStringsToEmojis(emojis []string, originAccountID string, statusID string) ([]*gtsmodel.Emoji, error) { + newEmojis := []*gtsmodel.Emoji{} + for _, e := range emojis { + emoji := &gtsmodel.Emoji{} + err := ps.conn.Model(emoji).Where("shortcode = ?", e).Where("visible_in_picker = true").Where("disabled = false").Select() + if err != nil { + if err == pg.ErrNoRows { + // no result found for this username/domain so just don't include it as an emoji and carry on about our business + ps.log.Debugf("no emoji found with shortcode %s, skipping it", e) + continue + } + // a serious error has happened so bail + return nil, fmt.Errorf("error getting emoji with shortcode %s: %s", e, err) + } + newEmojis = append(newEmojis, emoji) + } + return newEmojis, nil } diff --git a/internal/distributor/distributor.go b/internal/distributor/distributor.go @@ -19,8 +19,8 @@ package distributor import ( - "github.com/go-fed/activity/pub" "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" ) // Distributor should be passed to api modules (see internal/apimodule/...). It is used for @@ -30,10 +30,10 @@ import ( // fire messages into the distributor and not wait for a reply before proceeding with other work. This allows // for clean distribution of messages without slowing down the client API and harming the user experience. type Distributor interface { - // ClientAPIIn returns a channel for accepting messages that come from the gts client API. - ClientAPIIn() chan interface{} + // FromClientAPI returns a channel for accepting messages that come from the gts client API. + FromClientAPI() chan FromClientAPI // ClientAPIOut returns a channel for putting in messages that need to go to the gts client API. - ClientAPIOut() chan interface{} + ToClientAPI() chan ToClientAPI // Start starts the Distributor, reading from its channels and passing messages back and forth. Start() error // Stop stops the distributor cleanly, finishing handling any remaining messages before closing down. @@ -42,32 +42,32 @@ type Distributor interface { // distributor just implements the Distributor interface type distributor struct { - federator pub.FederatingActor - clientAPIIn chan interface{} - clientAPIOut chan interface{} - stop chan interface{} - log *logrus.Logger + // federator pub.FederatingActor + fromClientAPI chan FromClientAPI + toClientAPI chan ToClientAPI + stop chan interface{} + log *logrus.Logger } // New returns a new Distributor that uses the given federator and logger -func New(federator pub.FederatingActor, log *logrus.Logger) Distributor { +func New(log *logrus.Logger) Distributor { return &distributor{ - federator: federator, - clientAPIIn: make(chan interface{}, 100), - clientAPIOut: make(chan interface{}, 100), - stop: make(chan interface{}), - log: log, + // federator: federator, + fromClientAPI: make(chan FromClientAPI, 100), + toClientAPI: make(chan ToClientAPI, 100), + stop: make(chan interface{}), + log: log, } } // ClientAPIIn returns a channel for accepting messages that come from the gts client API. -func (d *distributor) ClientAPIIn() chan interface{} { - return d.clientAPIIn +func (d *distributor) FromClientAPI() chan FromClientAPI { + return d.fromClientAPI } // ClientAPIOut returns a channel for putting in messages that need to go to the gts client API. -func (d *distributor) ClientAPIOut() chan interface{} { - return d.clientAPIOut +func (d *distributor) ToClientAPI() chan ToClientAPI { + return d.toClientAPI } // Start starts the Distributor, reading from its channels and passing messages back and forth. @@ -76,10 +76,10 @@ func (d *distributor) Start() error { DistLoop: for { select { - case clientMsgIn := <-d.clientAPIIn: - d.log.Infof("received clientMsgIn: %+v", clientMsgIn) - case clientMsgOut := <-d.clientAPIOut: - d.log.Infof("received clientMsgOut: %+v", clientMsgOut) + case clientMsg := <-d.fromClientAPI: + d.log.Infof("received message FROM client API: %+v", clientMsg) + case clientMsg := <-d.toClientAPI: + d.log.Infof("received message TO client API: %+v", clientMsg) case <-d.stop: break DistLoop } @@ -94,3 +94,15 @@ func (d *distributor) Stop() error { close(d.stop) return nil } + +type FromClientAPI struct { + APObjectType gtsmodel.ActivityStreamsObject + APActivityType gtsmodel.ActivityStreamsActivity + Activity interface{} +} + +type ToClientAPI struct { + APObjectType gtsmodel.ActivityStreamsObject + APActivityType gtsmodel.ActivityStreamsActivity + Activity interface{} +} diff --git a/internal/distributor/mock_Distributor.go b/internal/distributor/mock_Distributor.go @@ -9,40 +9,38 @@ type MockDistributor struct { mock.Mock } -// ClientAPIIn provides a mock function with given fields: -func (_m *MockDistributor) ClientAPIIn() chan interface{} { +// FromClientAPI provides a mock function with given fields: +func (_m *MockDistributor) FromClientAPI() chan FromClientAPI { ret := _m.Called() - var r0 chan interface{} - if rf, ok := ret.Get(0).(func() chan interface{}); ok { + var r0 chan FromClientAPI + if rf, ok := ret.Get(0).(func() chan FromClientAPI); ok { r0 = rf() } else { if ret.Get(0) != nil { - r0 = ret.Get(0).(chan interface{}) + r0 = ret.Get(0).(chan FromClientAPI) } } return r0 } -// ClientAPIOut provides a mock function with given fields: -func (_m *MockDistributor) ClientAPIOut() chan interface{} { +// Start provides a mock function with given fields: +func (_m *MockDistributor) Start() error { ret := _m.Called() - var r0 chan interface{} - if rf, ok := ret.Get(0).(func() chan interface{}); ok { + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { r0 = rf() } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(chan interface{}) - } + r0 = ret.Error(0) } return r0 } -// Start provides a mock function with given fields: -func (_m *MockDistributor) Start() error { +// Stop provides a mock function with given fields: +func (_m *MockDistributor) Stop() error { ret := _m.Called() var r0 error @@ -55,15 +53,17 @@ func (_m *MockDistributor) Start() error { return r0 } -// Stop provides a mock function with given fields: -func (_m *MockDistributor) Stop() error { +// ToClientAPI provides a mock function with given fields: +func (_m *MockDistributor) ToClientAPI() chan ToClientAPI { ret := _m.Called() - var r0 error - if rf, ok := ret.Get(0).(func() error); ok { + var r0 chan ToClientAPI + if rf, ok := ret.Get(0).(func() chan ToClientAPI); ok { r0 = rf() } else { - r0 = ret.Error(0) + if ret.Get(0) != nil { + r0 = ret.Get(0).(chan ToClientAPI) + } } return r0 diff --git a/internal/gotosocial/actions.go b/internal/gotosocial/actions.go @@ -29,12 +29,19 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/action" "github.com/superseriousbusiness/gotosocial/internal/apimodule" "github.com/superseriousbusiness/gotosocial/internal/apimodule/account" + "github.com/superseriousbusiness/gotosocial/internal/apimodule/admin" "github.com/superseriousbusiness/gotosocial/internal/apimodule/app" "github.com/superseriousbusiness/gotosocial/internal/apimodule/auth" + "github.com/superseriousbusiness/gotosocial/internal/apimodule/fileserver" + mediaModule "github.com/superseriousbusiness/gotosocial/internal/apimodule/media" + "github.com/superseriousbusiness/gotosocial/internal/apimodule/security" + "github.com/superseriousbusiness/gotosocial/internal/apimodule/status" "github.com/superseriousbusiness/gotosocial/internal/cache" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/distributor" "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/mastotypes" "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/router" @@ -53,7 +60,7 @@ var Run action.GTSAction = func(ctx context.Context, c *config.Config, log *logr return fmt.Errorf("error creating router: %s", err) } - storageBackend, err := storage.NewInMem(c, log) + storageBackend, err := storage.NewLocal(c, log) if err != nil { return fmt.Errorf("error creating storage backend: %s", err) } @@ -61,16 +68,36 @@ var Run action.GTSAction = func(ctx context.Context, c *config.Config, log *logr // build backend handlers mediaHandler := media.New(c, dbService, storageBackend, log) oauthServer := oauth.New(dbService, log) + distributor := distributor.New(log) + if err := distributor.Start(); err != nil { + return fmt.Errorf("error starting distributor: %s", err) + } + + // build converters and util + mastoConverter := mastotypes.New(c, dbService) // build client api modules authModule := auth.New(oauthServer, dbService, log) - accountModule := account.New(c, dbService, oauthServer, mediaHandler, log) - appsModule := app.New(oauthServer, dbService, log) + accountModule := account.New(c, dbService, oauthServer, mediaHandler, mastoConverter, log) + appsModule := app.New(oauthServer, dbService, mastoConverter, log) + mm := mediaModule.New(dbService, mediaHandler, mastoConverter, c, log) + fileServerModule := fileserver.New(c, dbService, storageBackend, log) + adminModule := admin.New(c, dbService, mediaHandler, mastoConverter, log) + statusModule := status.New(c, dbService, mediaHandler, mastoConverter, distributor, log) + securityModule := security.New(c, log) apiModules := []apimodule.ClientAPIModule{ - authModule, // this one has to go first so the other modules use its middleware + // modules with middleware go first + securityModule, + authModule, + + // now everything else accountModule, appsModule, + mm, + fileServerModule, + adminModule, + statusModule, } for _, m := range apiModules { @@ -82,6 +109,10 @@ var Run action.GTSAction = func(ctx context.Context, c *config.Config, log *logr } } + if err := dbService.CreateInstanceAccount(); err != nil { + return fmt.Errorf("error creating instance account: %s", err) + } + gts, err := New(dbService, &cache.MockCache{}, router, federation.New(dbService, log), c) if err != nil { return fmt.Errorf("error creating gotosocial service: %s", err) diff --git a/internal/gotosocial/mock_Gotosocial.go b/internal/gotosocial/mock_Gotosocial.go @@ -26,3 +26,17 @@ func (_m *MockGotosocial) Start(_a0 context.Context) error { return r0 } + +// Stop provides a mock function with given fields: _a0 +func (_m *MockGotosocial) Stop(_a0 context.Context) error { + ret := _m.Called(_a0) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context) error); ok { + r0 = rf(_a0) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/internal/mastotypes/converter.go b/internal/mastotypes/converter.go @@ -0,0 +1,544 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package mastotypes + +import ( + "fmt" + "time" + + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +// Converter is an interface for the common action of converting between mastotypes (frontend, serializable) models and internal gts models used in the database. +// It requires access to the database because many of the conversions require pulling out database entries and counting them etc. +type Converter interface { + // AccountToMastoSensitive takes a db model account as a param, and returns a populated mastotype account, or an error + // if something goes wrong. The returned account should be ready to serialize on an API level, and may have sensitive fields, + // so serve it only to an authorized user who should have permission to see it. + AccountToMastoSensitive(account *gtsmodel.Account) (*mastotypes.Account, error) + + // AccountToMastoPublic takes a db model account as a param, and returns a populated mastotype account, or an error + // if something goes wrong. The returned account should be ready to serialize on an API level, and may NOT have sensitive fields. + // In other words, this is the public record that the server has of an account. + AccountToMastoPublic(account *gtsmodel.Account) (*mastotypes.Account, error) + + // AppToMastoSensitive takes a db model application as a param, and returns a populated mastotype application, or an error + // if something goes wrong. The returned application should be ready to serialize on an API level, and may have sensitive fields + // (such as client id and client secret), so serve it only to an authorized user who should have permission to see it. + AppToMastoSensitive(application *gtsmodel.Application) (*mastotypes.Application, error) + + // AppToMastoPublic takes a db model application as a param, and returns a populated mastotype application, or an error + // if something goes wrong. The returned application should be ready to serialize on an API level, and has sensitive + // fields sanitized so that it can be served to non-authorized accounts without revealing any private information. + AppToMastoPublic(application *gtsmodel.Application) (*mastotypes.Application, error) + + // AttachmentToMasto converts a gts model media attacahment into its mastodon representation for serialization on the API. + AttachmentToMasto(attachment *gtsmodel.MediaAttachment) (mastotypes.Attachment, error) + + // MentionToMasto converts a gts model mention into its mastodon (frontend) representation for serialization on the API. + MentionToMasto(m *gtsmodel.Mention) (mastotypes.Mention, error) + + // EmojiToMasto converts a gts model emoji into its mastodon (frontend) representation for serialization on the API. + EmojiToMasto(e *gtsmodel.Emoji) (mastotypes.Emoji, error) + + // TagToMasto converts a gts model tag into its mastodon (frontend) representation for serialization on the API. + TagToMasto(t *gtsmodel.Tag) (mastotypes.Tag, error) + + // StatusToMasto converts a gts model status into its mastodon (frontend) representation for serialization on the API. + StatusToMasto(s *gtsmodel.Status, targetAccount *gtsmodel.Account, requestingAccount *gtsmodel.Account, boostOfAccount *gtsmodel.Account, replyToAccount *gtsmodel.Account, reblogOfStatus *gtsmodel.Status) (*mastotypes.Status, error) +} + +type converter struct { + config *config.Config + db db.DB +} + +// New returns a new Converter +func New(config *config.Config, db db.DB) Converter { + return &converter{ + config: config, + db: db, + } +} + +func (c *converter) AccountToMastoSensitive(a *gtsmodel.Account) (*mastotypes.Account, error) { + // we can build this sensitive account easily by first getting the public account.... + mastoAccount, err := c.AccountToMastoPublic(a) + if err != nil { + return nil, err + } + + // then adding the Source object to it... + + // check pending follow requests aimed at this account + fr := []gtsmodel.FollowRequest{} + if err := c.db.GetFollowRequestsForAccountID(a.ID, &fr); err != nil { + if _, ok := err.(db.ErrNoEntries); !ok { + return nil, fmt.Errorf("error getting follow requests: %s", err) + } + } + var frc int + if fr != nil { + frc = len(fr) + } + + mastoAccount.Source = &mastotypes.Source{ + Privacy: util.ParseMastoVisFromGTSVis(a.Privacy), + Sensitive: a.Sensitive, + Language: a.Language, + Note: a.Note, + Fields: mastoAccount.Fields, + FollowRequestsCount: frc, + } + + return mastoAccount, nil +} + +func (c *converter) AccountToMastoPublic(a *gtsmodel.Account) (*mastotypes.Account, error) { + // count followers + followers := []gtsmodel.Follow{} + if err := c.db.GetFollowersByAccountID(a.ID, &followers); err != nil { + if _, ok := err.(db.ErrNoEntries); !ok { + return nil, fmt.Errorf("error getting followers: %s", err) + } + } + var followersCount int + if followers != nil { + followersCount = len(followers) + } + + // count following + following := []gtsmodel.Follow{} + if err := c.db.GetFollowingByAccountID(a.ID, &following); err != nil { + if _, ok := err.(db.ErrNoEntries); !ok { + return nil, fmt.Errorf("error getting following: %s", err) + } + } + var followingCount int + if following != nil { + followingCount = len(following) + } + + // count statuses + statuses := []gtsmodel.Status{} + if err := c.db.GetStatusesByAccountID(a.ID, &statuses); err != nil { + if _, ok := err.(db.ErrNoEntries); !ok { + return nil, fmt.Errorf("error getting last statuses: %s", err) + } + } + var statusesCount int + if statuses != nil { + statusesCount = len(statuses) + } + + // check when the last status was + lastStatus := &gtsmodel.Status{} + if err := c.db.GetLastStatusForAccountID(a.ID, lastStatus); err != nil { + if _, ok := err.(db.ErrNoEntries); !ok { + return nil, fmt.Errorf("error getting last status: %s", err) + } + } + var lastStatusAt string + if lastStatus != nil { + lastStatusAt = lastStatus.CreatedAt.Format(time.RFC3339) + } + + // build the avatar and header URLs + avi := &gtsmodel.MediaAttachment{} + if err := c.db.GetAvatarForAccountID(avi, a.ID); err != nil { + if _, ok := err.(db.ErrNoEntries); !ok { + return nil, fmt.Errorf("error getting avatar: %s", err) + } + } + aviURL := avi.URL + aviURLStatic := avi.Thumbnail.URL + + header := &gtsmodel.MediaAttachment{} + if err := c.db.GetHeaderForAccountID(avi, a.ID); err != nil { + if _, ok := err.(db.ErrNoEntries); !ok { + return nil, fmt.Errorf("error getting header: %s", err) + } + } + headerURL := header.URL + headerURLStatic := header.Thumbnail.URL + + // get the fields set on this account + fields := []mastotypes.Field{} + for _, f := range a.Fields { + mField := mastotypes.Field{ + Name: f.Name, + Value: f.Value, + } + if !f.VerifiedAt.IsZero() { + mField.VerifiedAt = f.VerifiedAt.Format(time.RFC3339) + } + fields = append(fields, mField) + } + + var acct string + if a.Domain != "" { + // this is a remote user + acct = fmt.Sprintf("%s@%s", a.Username, a.Domain) + } else { + // this is a local user + acct = a.Username + } + + return &mastotypes.Account{ + ID: a.ID, + Username: a.Username, + Acct: acct, + DisplayName: a.DisplayName, + Locked: a.Locked, + Bot: a.Bot, + CreatedAt: a.CreatedAt.Format(time.RFC3339), + Note: a.Note, + URL: a.URL, + Avatar: aviURL, + AvatarStatic: aviURLStatic, + Header: headerURL, + HeaderStatic: headerURLStatic, + FollowersCount: followersCount, + FollowingCount: followingCount, + StatusesCount: statusesCount, + LastStatusAt: lastStatusAt, + Emojis: nil, // TODO: implement this + Fields: fields, + }, nil +} + +func (c *converter) AppToMastoSensitive(a *gtsmodel.Application) (*mastotypes.Application, error) { + return &mastotypes.Application{ + ID: a.ID, + Name: a.Name, + Website: a.Website, + RedirectURI: a.RedirectURI, + ClientID: a.ClientID, + ClientSecret: a.ClientSecret, + VapidKey: a.VapidKey, + }, nil +} + +func (c *converter) AppToMastoPublic(a *gtsmodel.Application) (*mastotypes.Application, error) { + return &mastotypes.Application{ + Name: a.Name, + Website: a.Website, + }, nil +} + +func (c *converter) AttachmentToMasto(a *gtsmodel.MediaAttachment) (mastotypes.Attachment, error) { + return mastotypes.Attachment{ + ID: a.ID, + Type: string(a.Type), + URL: a.URL, + PreviewURL: a.Thumbnail.URL, + RemoteURL: a.RemoteURL, + PreviewRemoteURL: a.Thumbnail.RemoteURL, + Meta: mastotypes.MediaMeta{ + Original: mastotypes.MediaDimensions{ + Width: a.FileMeta.Original.Width, + Height: a.FileMeta.Original.Height, + Size: fmt.Sprintf("%dx%d", a.FileMeta.Original.Width, a.FileMeta.Original.Height), + Aspect: float32(a.FileMeta.Original.Aspect), + }, + Small: mastotypes.MediaDimensions{ + Width: a.FileMeta.Small.Width, + Height: a.FileMeta.Small.Height, + Size: fmt.Sprintf("%dx%d", a.FileMeta.Small.Width, a.FileMeta.Small.Height), + Aspect: float32(a.FileMeta.Small.Aspect), + }, + Focus: mastotypes.MediaFocus{ + X: a.FileMeta.Focus.X, + Y: a.FileMeta.Focus.Y, + }, + }, + Description: a.Description, + Blurhash: a.Blurhash, + }, nil +} + +func (c *converter) MentionToMasto(m *gtsmodel.Mention) (mastotypes.Mention, error) { + target := &gtsmodel.Account{} + if err := c.db.GetByID(m.TargetAccountID, target); err != nil { + return mastotypes.Mention{}, err + } + + var local bool + if target.Domain == "" { + local = true + } + + var acct string + if local { + acct = fmt.Sprintf("@%s", target.Username) + } else { + acct = fmt.Sprintf("@%s@%s", target.Username, target.Domain) + } + + return mastotypes.Mention{ + ID: target.ID, + Username: target.Username, + URL: target.URL, + Acct: acct, + }, nil +} + +func (c *converter) EmojiToMasto(e *gtsmodel.Emoji) (mastotypes.Emoji, error) { + return mastotypes.Emoji{ + Shortcode: e.Shortcode, + URL: e.ImageURL, + StaticURL: e.ImageStaticURL, + VisibleInPicker: e.VisibleInPicker, + Category: e.CategoryID, + }, nil +} + +func (c *converter) TagToMasto(t *gtsmodel.Tag) (mastotypes.Tag, error) { + tagURL := fmt.Sprintf("%s://%s/tags/%s", c.config.Protocol, c.config.Host, t.Name) + + return mastotypes.Tag{ + Name: t.Name, + URL: tagURL, // we don't serve URLs with collections of tagged statuses (FOR NOW) so this is purely for mastodon compatibility ¯\_(ツ)_/¯ + }, nil +} + +func (c *converter) StatusToMasto( + s *gtsmodel.Status, + targetAccount *gtsmodel.Account, + requestingAccount *gtsmodel.Account, + boostOfAccount *gtsmodel.Account, + replyToAccount *gtsmodel.Account, + reblogOfStatus *gtsmodel.Status) (*mastotypes.Status, error) { + + repliesCount, err := c.db.GetReplyCountForStatus(s) + if err != nil { + return nil, fmt.Errorf("error counting replies: %s", err) + } + + reblogsCount, err := c.db.GetReblogCountForStatus(s) + if err != nil { + return nil, fmt.Errorf("error counting reblogs: %s", err) + } + + favesCount, err := c.db.GetFaveCountForStatus(s) + if err != nil { + return nil, fmt.Errorf("error counting faves: %s", err) + } + + var faved bool + var reblogged bool + var bookmarked bool + var pinned bool + var muted bool + + // requestingAccount will be nil for public requests without auth + // But if it's not nil, we can also get information about the requestingAccount's interaction with this status + if requestingAccount != nil { + faved, err = c.db.StatusFavedBy(s, requestingAccount.ID) + if err != nil { + return nil, fmt.Errorf("error checking if requesting account has faved status: %s", err) + } + + reblogged, err = c.db.StatusRebloggedBy(s, requestingAccount.ID) + if err != nil { + return nil, fmt.Errorf("error checking if requesting account has reblogged status: %s", err) + } + + muted, err = c.db.StatusMutedBy(s, requestingAccount.ID) + if err != nil { + return nil, fmt.Errorf("error checking if requesting account has muted status: %s", err) + } + + bookmarked, err = c.db.StatusBookmarkedBy(s, requestingAccount.ID) + if err != nil { + return nil, fmt.Errorf("error checking if requesting account has bookmarked status: %s", err) + } + + pinned, err = c.db.StatusPinnedBy(s, requestingAccount.ID) + if err != nil { + return nil, fmt.Errorf("error checking if requesting account has pinned status: %s", err) + } + } + + var mastoRebloggedStatus *mastotypes.Status // TODO + + var mastoApplication *mastotypes.Application + if s.CreatedWithApplicationID != "" { + gtsApplication := &gtsmodel.Application{} + if err := c.db.GetByID(s.CreatedWithApplicationID, gtsApplication); err != nil { + return nil, fmt.Errorf("error fetching application used to create status: %s", err) + } + mastoApplication, err = c.AppToMastoPublic(gtsApplication) + if err != nil { + return nil, fmt.Errorf("error parsing application used to create status: %s", err) + } + } + + mastoTargetAccount, err := c.AccountToMastoPublic(targetAccount) + if err != nil { + return nil, fmt.Errorf("error parsing account of status author: %s", err) + } + + mastoAttachments := []mastotypes.Attachment{} + // the status might already have some gts attachments on it if it's not been pulled directly from the database + // if so, we can directly convert the gts attachments into masto ones + if s.GTSMediaAttachments != nil { + for _, gtsAttachment := range s.GTSMediaAttachments { + mastoAttachment, err := c.AttachmentToMasto(gtsAttachment) + if err != nil { + return nil, fmt.Errorf("error converting attachment with id %s: %s", gtsAttachment.ID, err) + } + mastoAttachments = append(mastoAttachments, mastoAttachment) + } + // the status doesn't have gts attachments on it, but it does have attachment IDs + // in this case, we need to pull the gts attachments from the db to convert them into masto ones + } else { + for _, a := range s.Attachments { + gtsAttachment := &gtsmodel.MediaAttachment{} + if err := c.db.GetByID(a, gtsAttachment); err != nil { + return nil, fmt.Errorf("error getting attachment with id %s: %s", a, err) + } + mastoAttachment, err := c.AttachmentToMasto(gtsAttachment) + if err != nil { + return nil, fmt.Errorf("error converting attachment with id %s: %s", a, err) + } + mastoAttachments = append(mastoAttachments, mastoAttachment) + } + } + + mastoMentions := []mastotypes.Mention{} + // the status might already have some gts mentions on it if it's not been pulled directly from the database + // if so, we can directly convert the gts mentions into masto ones + if s.GTSMentions != nil { + for _, gtsMention := range s.GTSMentions { + mastoMention, err := c.MentionToMasto(gtsMention) + if err != nil { + return nil, fmt.Errorf("error converting mention with id %s: %s", gtsMention.ID, err) + } + mastoMentions = append(mastoMentions, mastoMention) + } + // the status doesn't have gts mentions on it, but it does have mention IDs + // in this case, we need to pull the gts mentions from the db to convert them into masto ones + } else { + for _, m := range s.Mentions { + gtsMention := &gtsmodel.Mention{} + if err := c.db.GetByID(m, gtsMention); err != nil { + return nil, fmt.Errorf("error getting mention with id %s: %s", m, err) + } + mastoMention, err := c.MentionToMasto(gtsMention) + if err != nil { + return nil, fmt.Errorf("error converting mention with id %s: %s", gtsMention.ID, err) + } + mastoMentions = append(mastoMentions, mastoMention) + } + } + + mastoTags := []mastotypes.Tag{} + // the status might already have some gts tags on it if it's not been pulled directly from the database + // if so, we can directly convert the gts tags into masto ones + if s.GTSTags != nil { + for _, gtsTag := range s.GTSTags { + mastoTag, err := c.TagToMasto(gtsTag) + if err != nil { + return nil, fmt.Errorf("error converting tag with id %s: %s", gtsTag.ID, err) + } + mastoTags = append(mastoTags, mastoTag) + } + // the status doesn't have gts tags on it, but it does have tag IDs + // in this case, we need to pull the gts tags from the db to convert them into masto ones + } else { + for _, t := range s.Tags { + gtsTag := &gtsmodel.Tag{} + if err := c.db.GetByID(t, gtsTag); err != nil { + return nil, fmt.Errorf("error getting tag with id %s: %s", t, err) + } + mastoTag, err := c.TagToMasto(gtsTag) + if err != nil { + return nil, fmt.Errorf("error converting tag with id %s: %s", gtsTag.ID, err) + } + mastoTags = append(mastoTags, mastoTag) + } + } + + mastoEmojis := []mastotypes.Emoji{} + // the status might already have some gts emojis on it if it's not been pulled directly from the database + // if so, we can directly convert the gts emojis into masto ones + if s.GTSEmojis != nil { + for _, gtsEmoji := range s.GTSEmojis { + mastoEmoji, err := c.EmojiToMasto(gtsEmoji) + if err != nil { + return nil, fmt.Errorf("error converting emoji with id %s: %s", gtsEmoji.ID, err) + } + mastoEmojis = append(mastoEmojis, mastoEmoji) + } + // the status doesn't have gts emojis on it, but it does have emoji IDs + // in this case, we need to pull the gts emojis from the db to convert them into masto ones + } else { + for _, e := range s.Emojis { + gtsEmoji := &gtsmodel.Emoji{} + if err := c.db.GetByID(e, gtsEmoji); err != nil { + return nil, fmt.Errorf("error getting emoji with id %s: %s", e, err) + } + mastoEmoji, err := c.EmojiToMasto(gtsEmoji) + if err != nil { + return nil, fmt.Errorf("error converting emoji with id %s: %s", gtsEmoji.ID, err) + } + mastoEmojis = append(mastoEmojis, mastoEmoji) + } + } + + var mastoCard *mastotypes.Card + var mastoPoll *mastotypes.Poll + + return &mastotypes.Status{ + ID: s.ID, + CreatedAt: s.CreatedAt.Format(time.RFC3339), + InReplyToID: s.InReplyToID, + InReplyToAccountID: s.InReplyToAccountID, + Sensitive: s.Sensitive, + SpoilerText: s.ContentWarning, + Visibility: util.ParseMastoVisFromGTSVis(s.Visibility), + Language: s.Language, + URI: s.URI, + URL: s.URL, + RepliesCount: repliesCount, + ReblogsCount: reblogsCount, + FavouritesCount: favesCount, + Favourited: faved, + Reblogged: reblogged, + Muted: muted, + Bookmarked: bookmarked, + Pinned: pinned, + Content: s.Content, + Reblog: mastoRebloggedStatus, + Application: mastoApplication, + Account: mastoTargetAccount, + MediaAttachments: mastoAttachments, + Mentions: mastoMentions, + Tags: mastoTags, + Emojis: mastoEmojis, + Card: mastoCard, // TODO: implement cards + Poll: mastoPoll, // TODO: implement polls + Text: s.Text, + }, nil +} diff --git a/pkg/mastotypes/README.md b/internal/mastotypes/mastomodel/README.md diff --git a/internal/mastotypes/mastomodel/account.go b/internal/mastotypes/mastomodel/account.go @@ -0,0 +1,131 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package mastotypes + +import "mime/multipart" + +// Account represents a mastodon-api Account object, as described here: https://docs.joinmastodon.org/entities/account/ +type Account struct { + // The account id + ID string `json:"id"` + // The username of the account, not including domain. + Username string `json:"username"` + // The Webfinger account URI. Equal to username for local users, or username@domain for remote users. + Acct string `json:"acct"` + // The profile's display name. + DisplayName string `json:"display_name"` + // Whether the account manually approves follow requests. + Locked bool `json:"locked"` + // Whether the account has opted into discovery features such as the profile directory. + Discoverable bool `json:"discoverable,omitempty"` + // A presentational flag. Indicates that the account may perform automated actions, may not be monitored, or identifies as a robot. + Bot bool `json:"bot"` + // When the account was created. (ISO 8601 Datetime) + CreatedAt string `json:"created_at"` + // The profile's bio / description. + Note string `json:"note"` + // The location of the user's profile page. + URL string `json:"url"` + // An image icon that is shown next to statuses and in the profile. + Avatar string `json:"avatar"` + // A static version of the avatar. Equal to avatar if its value is a static image; different if avatar is an animated GIF. + AvatarStatic string `json:"avatar_static"` + // An image banner that is shown above the profile and in profile cards. + Header string `json:"header"` + // A static version of the header. Equal to header if its value is a static image; different if header is an animated GIF. + HeaderStatic string `json:"header_static"` + // The reported followers of this profile. + FollowersCount int `json:"followers_count"` + // The reported follows of this profile. + FollowingCount int `json:"following_count"` + // How many statuses are attached to this account. + StatusesCount int `json:"statuses_count"` + // When the most recent status was posted. (ISO 8601 Datetime) + LastStatusAt string `json:"last_status_at"` + // Custom emoji entities to be used when rendering the profile. If none, an empty array will be returned. + Emojis []Emoji `json:"emojis"` + // Additional metadata attached to a profile as name-value pairs. + Fields []Field `json:"fields"` + // An extra entity returned when an account is suspended. + Suspended bool `json:"suspended,omitempty"` + // When a timed mute will expire, if applicable. (ISO 8601 Datetime) + MuteExpiresAt string `json:"mute_expires_at,omitempty"` + // An extra entity to be used with API methods to verify credentials and update credentials. + Source *Source `json:"source,omitempty"` +} + +// AccountCreateRequest represents the form submitted during a POST request to /api/v1/accounts. +// See https://docs.joinmastodon.org/methods/accounts/ +type AccountCreateRequest struct { + // Text that will be reviewed by moderators if registrations require manual approval. + Reason string `form:"reason"` + // The desired username for the account + Username string `form:"username" binding:"required"` + // The email address to be used for login + Email string `form:"email" binding:"required"` + // The password to be used for login + Password string `form:"password" binding:"required"` + // Whether the user agrees to the local rules, terms, and policies. + // These should be presented to the user in order to allow them to consent before setting this parameter to TRUE. + Agreement bool `form:"agreement" binding:"required"` + // The language of the confirmation email that will be sent + Locale string `form:"locale" binding:"required"` +} + +// UpdateCredentialsRequest represents the form submitted during a PATCH request to /api/v1/accounts/update_credentials. +// See https://docs.joinmastodon.org/methods/accounts/ +type UpdateCredentialsRequest struct { + // Whether the account should be shown in the profile directory. + Discoverable *bool `form:"discoverable"` + // Whether the account has a bot flag. + Bot *bool `form:"bot"` + // The display name to use for the profile. + DisplayName *string `form:"display_name"` + // The account bio. + Note *string `form:"note"` + // Avatar image encoded using multipart/form-data + Avatar *multipart.FileHeader `form:"avatar"` + // Header image encoded using multipart/form-data + Header *multipart.FileHeader `form:"header"` + // Whether manual approval of follow requests is required. + Locked *bool `form:"locked"` + // New Source values for this account + Source *UpdateSource `form:"source"` + // Profile metadata name and value + FieldsAttributes *[]UpdateField `form:"fields_attributes"` +} + +// UpdateSource is to be used specifically in an UpdateCredentialsRequest. +type UpdateSource struct { + // Default post privacy for authored statuses. + Privacy *string `form:"privacy"` + // Whether to mark authored statuses as sensitive by default. + Sensitive *bool `form:"sensitive"` + // Default language to use for authored statuses. (ISO 6391) + Language *string `form:"language"` +} + +// UpdateField is to be used specifically in an UpdateCredentialsRequest. +// By default, max 4 fields and 255 characters per property/value. +type UpdateField struct { + // Name of the field + Name *string `form:"name"` + // Value of the field + Value *string `form:"value"` +} diff --git a/pkg/mastotypes/activity.go b/internal/mastotypes/mastomodel/activity.go diff --git a/pkg/mastotypes/admin.go b/internal/mastotypes/mastomodel/admin.go diff --git a/pkg/mastotypes/announcement.go b/internal/mastotypes/mastomodel/announcement.go diff --git a/pkg/mastotypes/announcementreaction.go b/internal/mastotypes/mastomodel/announcementreaction.go diff --git a/internal/mastotypes/mastomodel/application.go b/internal/mastotypes/mastomodel/application.go @@ -0,0 +1,55 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package mastotypes + +// Application represents a mastodon-api Application, as defined here: https://docs.joinmastodon.org/entities/application/. +// Primarily, application is used for allowing apps like Tusky etc to connect to Mastodon on behalf of a user. +// See https://docs.joinmastodon.org/methods/apps/ +type Application struct { + // The application ID in the db + ID string `json:"id,omitempty"` + // The name of your application. + Name string `json:"name"` + // The website associated with your application (url) + Website string `json:"website,omitempty"` + // Where the user should be redirected after authorization. + RedirectURI string `json:"redirect_uri,omitempty"` + // ClientID to use when obtaining an oauth token for this application (ie., in client_id parameter of https://docs.joinmastodon.org/methods/apps/) + ClientID string `json:"client_id,omitempty"` + // Client secret to use when obtaining an auth token for this application (ie., in client_secret parameter of https://docs.joinmastodon.org/methods/apps/) + ClientSecret string `json:"client_secret,omitempty"` + // Used for Push Streaming API. Returned with POST /api/v1/apps. Equivalent to https://docs.joinmastodon.org/entities/pushsubscription/#server_key + VapidKey string `json:"vapid_key,omitempty"` +} + +// ApplicationPOSTRequest represents a POST request to https://example.org/api/v1/apps. +// See here: https://docs.joinmastodon.org/methods/apps/ +// And here: https://docs.joinmastodon.org/client/token/ +type ApplicationPOSTRequest struct { + // A name for your application + ClientName string `form:"client_name" binding:"required"` + // Where the user should be redirected after authorization. + // To display the authorization code to the user instead of redirecting + // to a web page, use urn:ietf:wg:oauth:2.0:oob in this parameter. + RedirectURIs string `form:"redirect_uris" binding:"required"` + // Space separated list of scopes. If none is provided, defaults to read. + Scopes string `form:"scopes"` + // A URL to the homepage of your app + Website string `form:"website"` +} diff --git a/internal/mastotypes/mastomodel/attachment.go b/internal/mastotypes/mastomodel/attachment.go @@ -0,0 +1,98 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package mastotypes + +import "mime/multipart" + +// AttachmentRequest represents the form data parameters submitted by a client during a media upload request. +// See: https://docs.joinmastodon.org/methods/statuses/media/ +type AttachmentRequest struct { + File *multipart.FileHeader `form:"file"` + Thumbnail *multipart.FileHeader `form:"thumbnail"` + Description string `form:"description"` + Focus string `form:"focus"` +} + +// Attachment represents the object returned to a client after a successful media upload request. +// See: https://docs.joinmastodon.org/methods/statuses/media/ +type Attachment struct { + // The ID of the attachment in the database. + ID string `json:"id"` + // The type of the attachment. + // unknown = unsupported or unrecognized file type. + // image = Static image. + // gifv = Looping, soundless animation. + // video = Video clip. + // audio = Audio track. + Type string `json:"type"` + // The location of the original full-size attachment. + URL string `json:"url"` + // The location of a scaled-down preview of the attachment. + PreviewURL string `json:"preview_url"` + // The location of the full-size original attachment on the remote server. + RemoteURL string `json:"remote_url,omitempty"` + // The location of a scaled-down preview of the attachment on the remote server. + PreviewRemoteURL string `json:"preview_remote_url,omitempty"` + // A shorter URL for the attachment. + TextURL string `json:"text_url,omitempty"` + // Metadata returned by Paperclip. + // May contain subtrees small and original, as well as various other top-level properties. + // More importantly, there may be another top-level focus Hash object as of 2.3.0, with coordinates can be used for smart thumbnail cropping. + // See https://docs.joinmastodon.org/methods/statuses/media/#focal-points points for more. + Meta MediaMeta `json:"meta,omitempty"` + // Alternate text that describes what is in the media attachment, to be used for the visually impaired or when media attachments do not load. + Description string `json:"description,omitempty"` + // A hash computed by the BlurHash algorithm, for generating colorful preview thumbnails when media has not been downloaded yet. + // See https://github.com/woltapp/blurhash + Blurhash string `json:"blurhash,omitempty"` +} + +// MediaMeta describes the returned media +type MediaMeta struct { + Length string `json:"length,omitempty"` + Duration float32 `json:"duration,omitempty"` + FPS uint16 `json:"fps,omitempty"` + Size string `json:"size,omitempty"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + Aspect float32 `json:"aspect,omitempty"` + AudioEncode string `json:"audio_encode,omitempty"` + AudioBitrate string `json:"audio_bitrate,omitempty"` + AudioChannels string `json:"audio_channels,omitempty"` + Original MediaDimensions `json:"original"` + Small MediaDimensions `json:"small,omitempty"` + Focus MediaFocus `json:"focus,omitempty"` +} + +// MediaFocus describes the focal point of a piece of media. It should be returned to the caller as part of MediaMeta. +type MediaFocus struct { + X float32 `json:"x"` // should be between -1 and 1 + Y float32 `json:"y"` // should be between -1 and 1 +} + +// MediaDimensions describes the physical properties of a piece of media. It should be returned to the caller as part of MediaMeta. +type MediaDimensions struct { + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + FrameRate string `json:"frame_rate,omitempty"` + Duration float32 `json:"duration,omitempty"` + Bitrate int `json:"bitrate,omitempty"` + Size string `json:"size,omitempty"` + Aspect float32 `json:"aspect,omitempty"` +} diff --git a/pkg/mastotypes/card.go b/internal/mastotypes/mastomodel/card.go diff --git a/pkg/mastotypes/context.go b/internal/mastotypes/mastomodel/context.go diff --git a/pkg/mastotypes/conversation.go b/internal/mastotypes/mastomodel/conversation.go diff --git a/internal/mastotypes/mastomodel/emoji.go b/internal/mastotypes/mastomodel/emoji.go @@ -0,0 +1,48 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package mastotypes + +import "mime/multipart" + +// Emoji represents a custom emoji. See https://docs.joinmastodon.org/entities/emoji/ +type Emoji struct { + // REQUIRED + + // The name of the custom emoji. + Shortcode string `json:"shortcode"` + // A link to the custom emoji. + URL string `json:"url"` + // A link to a static copy of the custom emoji. + StaticURL string `json:"static_url"` + // Whether this Emoji should be visible in the picker or unlisted. + VisibleInPicker bool `json:"visible_in_picker"` + + // OPTIONAL + + // Used for sorting custom emoji in the picker. + Category string `json:"category,omitempty"` +} + +// EmojiCreateRequest represents a request to create a custom emoji made through the admin API. +type EmojiCreateRequest struct { + // Desired shortcode for the emoji, without surrounding colons. This must be unique for the domain. + Shortcode string `form:"shortcode" validation:"required"` + // Image file to use for the emoji. Must be png or gif and no larger than 50kb. + Image *multipart.FileHeader `form:"image" validation:"required"` +} diff --git a/pkg/mastotypes/error.go b/internal/mastotypes/mastomodel/error.go diff --git a/pkg/mastotypes/featuredtag.go b/internal/mastotypes/mastomodel/featuredtag.go diff --git a/pkg/mastotypes/field.go b/internal/mastotypes/mastomodel/field.go diff --git a/pkg/mastotypes/filter.go b/internal/mastotypes/mastomodel/filter.go diff --git a/pkg/mastotypes/history.go b/internal/mastotypes/mastomodel/history.go diff --git a/pkg/mastotypes/identityproof.go b/internal/mastotypes/mastomodel/identityproof.go diff --git a/pkg/mastotypes/instance.go b/internal/mastotypes/mastomodel/instance.go diff --git a/pkg/mastotypes/list.go b/internal/mastotypes/mastomodel/list.go diff --git a/pkg/mastotypes/marker.go b/internal/mastotypes/mastomodel/marker.go diff --git a/pkg/mastotypes/mention.go b/internal/mastotypes/mastomodel/mention.go diff --git a/pkg/mastotypes/notification.go b/internal/mastotypes/mastomodel/notification.go diff --git a/pkg/mastotypes/oauth.go b/internal/mastotypes/mastomodel/oauth.go diff --git a/pkg/mastotypes/poll.go b/internal/mastotypes/mastomodel/poll.go diff --git a/pkg/mastotypes/preferences.go b/internal/mastotypes/mastomodel/preferences.go diff --git a/pkg/mastotypes/pushsubscription.go b/internal/mastotypes/mastomodel/pushsubscription.go diff --git a/pkg/mastotypes/relationship.go b/internal/mastotypes/mastomodel/relationship.go diff --git a/pkg/mastotypes/results.go b/internal/mastotypes/mastomodel/results.go diff --git a/pkg/mastotypes/scheduledstatus.go b/internal/mastotypes/mastomodel/scheduledstatus.go diff --git a/internal/mastotypes/mastomodel/source.go b/internal/mastotypes/mastomodel/source.go @@ -0,0 +1,41 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package mastotypes + +// Source represents display or publishing preferences of user's own account. +// Returned as an additional entity when verifying and updated credentials, as an attribute of Account. +// See https://docs.joinmastodon.org/entities/source/ +type Source struct { + // The default post privacy to be used for new statuses. + // public = Public post + // unlisted = Unlisted post + // private = Followers-only post + // direct = Direct post + Privacy Visibility `json:"privacy,omitempty"` + // Whether new statuses should be marked sensitive by default. + Sensitive bool `json:"sensitive,omitempty"` + // The default posting language for new statuses. + Language string `json:"language,omitempty"` + // Profile bio. + Note string `json:"note"` + // Metadata about the account. + Fields []Field `json:"fields"` + // The number of pending follow requests. + FollowRequestsCount int `json:"follow_requests_count,omitempty"` +} diff --git a/internal/mastotypes/mastomodel/status.go b/internal/mastotypes/mastomodel/status.go @@ -0,0 +1,119 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package mastotypes + +// Status represents a mastodon-api Status type, as defined here: https://docs.joinmastodon.org/entities/status/ +type Status struct { + // ID of the status in the database. + ID string `json:"id"` + // The date when this status was created (ISO 8601 Datetime) + CreatedAt string `json:"created_at"` + // ID of the status being replied. + InReplyToID string `json:"in_reply_to_id,omitempty"` + // ID of the account being replied to. + InReplyToAccountID string `json:"in_reply_to_account_id,omitempty"` + // Is this status marked as sensitive content? + Sensitive bool `json:"sensitive"` + // Subject or summary line, below which status content is collapsed until expanded. + SpoilerText string `json:"spoiler_text,omitempty"` + // Visibility of this status. + Visibility Visibility `json:"visibility"` + // Primary language of this status. (ISO 639 Part 1 two-letter language code) + Language string `json:"language"` + // URI of the status used for federation. + URI string `json:"uri"` + // A link to the status's HTML representation. + URL string `json:"url"` + // How many replies this status has received. + RepliesCount int `json:"replies_count"` + // How many boosts this status has received. + ReblogsCount int `json:"reblogs_count"` + // How many favourites this status has received. + FavouritesCount int `json:"favourites_count"` + // Have you favourited this status? + Favourited bool `json:"favourited"` + // Have you boosted this status? + Reblogged bool `json:"reblogged"` + // Have you muted notifications for this status's conversation? + Muted bool `json:"muted"` + // Have you bookmarked this status? + Bookmarked bool `json:"bookmarked"` + // Have you pinned this status? Only appears if the status is pinnable. + Pinned bool `json:"pinned"` + // HTML-encoded status content. + Content string `json:"content"` + // The status being reblogged. + Reblog *Status `json:"reblog,omitempty"` + // The application used to post this status. + Application *Application `json:"application"` + // The account that authored this status. + Account *Account `json:"account"` + // Media that is attached to this status. + MediaAttachments []Attachment `json:"media_attachments"` + // Mentions of users within the status content. + Mentions []Mention `json:"mentions"` + // Hashtags used within the status content. + Tags []Tag `json:"tags"` + // Custom emoji to be used when rendering status content. + Emojis []Emoji `json:"emojis"` + // Preview card for links included within status content. + Card *Card `json:"card"` + // The poll attached to the status. + Poll *Poll `json:"poll"` + // Plain-text source of a status. Returned instead of content when status is deleted, + // so the user may redraft from the source text without the client having to reverse-engineer + // the original text from the HTML content. + Text string `json:"text"` +} + +// StatusCreateRequest represents a mastodon-api status POST request, as defined here: https://docs.joinmastodon.org/methods/statuses/ +// It should be used at the path https://mastodon.example/api/v1/statuses +type StatusCreateRequest struct { + // Text content of the status. If media_ids is provided, this becomes optional. Attaching a poll is optional while status is provided. + Status string `form:"status"` + // Array of Attachment ids to be attached as media. If provided, status becomes optional, and poll cannot be used. + MediaIDs []string `form:"media_ids"` + // Poll to include with this status. + Poll *PollRequest `form:"poll"` + // ID of the status being replied to, if status is a reply + InReplyToID string `form:"in_reply_to_id"` + // Mark status and attached media as sensitive? + Sensitive bool `form:"sensitive"` + // Text to be shown as a warning or subject before the actual content. Statuses are generally collapsed behind this field. + SpoilerText string `form:"spoiler_text"` + // Visibility of the posted status. Enumerable oneOf public, unlisted, private, direct. + Visibility Visibility `form:"visibility"` + // ISO 8601 Datetime at which to schedule a status. Providing this paramter will cause ScheduledStatus to be returned instead of Status. Must be at least 5 minutes in the future. + ScheduledAt string `form:"scheduled_at"` + // ISO 639 language code for this status. + Language string `form:"language"` +} + +type Visibility string + +const ( + // visible to everyone + VisibilityPublic Visibility = "public" + // visible to everyone but only on home timelines or in lists + VisibilityUnlisted Visibility = "unlisted" + // visible to followers only + VisibilityPrivate Visibility = "private" + // visible only to tagged recipients + VisibilityDirect Visibility = "direct" +) diff --git a/internal/mastotypes/mastomodel/tag.go b/internal/mastotypes/mastomodel/tag.go @@ -0,0 +1,27 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package mastotypes + +// Tag represents a hashtag used within the content of a status. See https://docs.joinmastodon.org/entities/tag/ +type Tag struct { + // The value of the hashtag after the # sign. + Name string `json:"name"` + // A link to the hashtag on the instance. + URL string `json:"url"` +} diff --git a/pkg/mastotypes/token.go b/internal/mastotypes/mastomodel/token.go diff --git a/internal/mastotypes/mock_Converter.go b/internal/mastotypes/mock_Converter.go @@ -0,0 +1,148 @@ +// Code generated by mockery v2.7.4. DO NOT EDIT. + +package mastotypes + +import ( + mock "github.com/stretchr/testify/mock" + gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" +) + +// MockConverter is an autogenerated mock type for the Converter type +type MockConverter struct { + mock.Mock +} + +// AccountToMastoPublic provides a mock function with given fields: account +func (_m *MockConverter) AccountToMastoPublic(account *gtsmodel.Account) (*mastotypes.Account, error) { + ret := _m.Called(account) + + var r0 *mastotypes.Account + if rf, ok := ret.Get(0).(func(*gtsmodel.Account) *mastotypes.Account); ok { + r0 = rf(account) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*mastotypes.Account) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*gtsmodel.Account) error); ok { + r1 = rf(account) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// AccountToMastoSensitive provides a mock function with given fields: account +func (_m *MockConverter) AccountToMastoSensitive(account *gtsmodel.Account) (*mastotypes.Account, error) { + ret := _m.Called(account) + + var r0 *mastotypes.Account + if rf, ok := ret.Get(0).(func(*gtsmodel.Account) *mastotypes.Account); ok { + r0 = rf(account) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*mastotypes.Account) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*gtsmodel.Account) error); ok { + r1 = rf(account) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// AppToMastoPublic provides a mock function with given fields: application +func (_m *MockConverter) AppToMastoPublic(application *gtsmodel.Application) (*mastotypes.Application, error) { + ret := _m.Called(application) + + var r0 *mastotypes.Application + if rf, ok := ret.Get(0).(func(*gtsmodel.Application) *mastotypes.Application); ok { + r0 = rf(application) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*mastotypes.Application) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*gtsmodel.Application) error); ok { + r1 = rf(application) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// AppToMastoSensitive provides a mock function with given fields: application +func (_m *MockConverter) AppToMastoSensitive(application *gtsmodel.Application) (*mastotypes.Application, error) { + ret := _m.Called(application) + + var r0 *mastotypes.Application + if rf, ok := ret.Get(0).(func(*gtsmodel.Application) *mastotypes.Application); ok { + r0 = rf(application) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*mastotypes.Application) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*gtsmodel.Application) error); ok { + r1 = rf(application) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// AttachmentToMasto provides a mock function with given fields: attachment +func (_m *MockConverter) AttachmentToMasto(attachment *gtsmodel.MediaAttachment) (mastotypes.Attachment, error) { + ret := _m.Called(attachment) + + var r0 mastotypes.Attachment + if rf, ok := ret.Get(0).(func(*gtsmodel.MediaAttachment) mastotypes.Attachment); ok { + r0 = rf(attachment) + } else { + r0 = ret.Get(0).(mastotypes.Attachment) + } + + var r1 error + if rf, ok := ret.Get(1).(func(*gtsmodel.MediaAttachment) error); ok { + r1 = rf(attachment) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MentionToMasto provides a mock function with given fields: m +func (_m *MockConverter) MentionToMasto(m *gtsmodel.Mention) (mastotypes.Mention, error) { + ret := _m.Called(m) + + var r0 mastotypes.Mention + if rf, ok := ret.Get(0).(func(*gtsmodel.Mention) mastotypes.Mention); ok { + r0 = rf(m) + } else { + r0 = ret.Get(0).(mastotypes.Mention) + } + + var r1 error + if rf, ok := ret.Get(1).(func(*gtsmodel.Mention) error); ok { + r1 = rf(m) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/internal/media/media.go b/internal/media/media.go @@ -28,16 +28,46 @@ import ( "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/model" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/storage" ) +const ( + // Key for small/thumbnail versions of media + MediaSmall = "small" + // Key for original/fullsize versions of media and emoji + MediaOriginal = "original" + // Key for static (non-animated) versions of emoji + MediaStatic = "static" + // Key for media attachments + MediaAttachment = "attachment" + // Key for profile header + MediaHeader = "header" + // Key for profile avatar + MediaAvatar = "avatar" + // Key for emoji type + MediaEmoji = "emoji" + + // Maximum permitted bytes of an emoji upload (50kb) + EmojiMaxBytes = 51200 +) + // MediaHandler provides an interface for parsing, storing, and retrieving media objects like photos, videos, and gifs. type MediaHandler interface { - // SetHeaderOrAvatarForAccountID takes a new header image for an account, checks it out, removes exif data from it, + // ProcessHeaderOrAvatar takes a new header image for an account, checks it out, removes exif data from it, // puts it in whatever storage backend we're using, sets the relevant fields in the database for the new image, // and then returns information to the caller about the new header. - SetHeaderOrAvatarForAccountID(img []byte, accountID string, headerOrAvi string) (*model.MediaAttachment, error) + ProcessHeaderOrAvatar(img []byte, accountID string, headerOrAvi string) (*gtsmodel.MediaAttachment, error) + + // ProcessLocalAttachment takes a new attachment and the requesting account, checks it out, removes exif data from it, + // puts it in whatever storage backend we're using, sets the relevant fields in the database for the new media, + // and then returns information to the caller about the attachment. + ProcessLocalAttachment(attachment []byte, accountID string) (*gtsmodel.MediaAttachment, error) + + // ProcessLocalEmoji takes a new emoji and a shortcode, cleans it up, puts it in storage, and creates a new + // *gts.Emoji for it, then returns it to the caller. It's the caller's responsibility to put the returned struct + // in the database. + ProcessLocalEmoji(emojiBytes []byte, shortcode string) (*gtsmodel.Emoji, error) } type mediaHandler struct { @@ -56,27 +86,19 @@ func New(config *config.Config, database db.DB, storage storage.Storage, log *lo } } -// HeaderInfo wraps the urls at which a Header and a StaticHeader is available from the server. -type HeaderInfo struct { - // URL to the header - Header string - // Static version of the above (eg., a path to a still image if the header is a gif) - HeaderStatic string -} - /* INTERFACE FUNCTIONS */ -func (mh *mediaHandler) SetHeaderOrAvatarForAccountID(img []byte, accountID string, headerOrAvi string) (*model.MediaAttachment, error) { +func (mh *mediaHandler) ProcessHeaderOrAvatar(attachment []byte, accountID string, headerOrAvi string) (*gtsmodel.MediaAttachment, error) { l := mh.log.WithField("func", "SetHeaderForAccountID") - if headerOrAvi != "header" && headerOrAvi != "avatar" { + if headerOrAvi != MediaHeader && headerOrAvi != MediaAvatar { return nil, errors.New("header or avatar not selected") } - // make sure we have an image we can handle - contentType, err := parseContentType(img) + // make sure we have a type we can handle + contentType, err := parseContentType(attachment) if err != nil { return nil, err } @@ -84,13 +106,13 @@ func (mh *mediaHandler) SetHeaderOrAvatarForAccountID(img []byte, accountID stri return nil, fmt.Errorf("%s is not an accepted image type", contentType) } - if len(img) == 0 { + if len(attachment) == 0 { return nil, fmt.Errorf("passed reader was of size 0") } - l.Tracef("read %d bytes of file", len(img)) + l.Tracef("read %d bytes of file", len(attachment)) // process it - ma, err := mh.processHeaderOrAvi(img, contentType, headerOrAvi, accountID) + ma, err := mh.processHeaderOrAvi(attachment, contentType, headerOrAvi, accountID) if err != nil { return nil, fmt.Errorf("error processing %s: %s", headerOrAvi, err) } @@ -103,18 +125,265 @@ func (mh *mediaHandler) SetHeaderOrAvatarForAccountID(img []byte, accountID stri return ma, nil } +func (mh *mediaHandler) ProcessLocalAttachment(attachment []byte, accountID string) (*gtsmodel.MediaAttachment, error) { + contentType, err := parseContentType(attachment) + if err != nil { + return nil, err + } + mainType := strings.Split(contentType, "/")[0] + switch mainType { + case "video": + if !supportedVideoType(contentType) { + return nil, fmt.Errorf("video type %s not supported", contentType) + } + if len(attachment) == 0 { + return nil, errors.New("video was of size 0") + } + if len(attachment) > mh.config.MediaConfig.MaxVideoSize { + return nil, fmt.Errorf("video size %d bytes exceeded max video size of %d bytes", len(attachment), mh.config.MediaConfig.MaxVideoSize) + } + return mh.processVideoAttachment(attachment, accountID, contentType) + case "image": + if !supportedImageType(contentType) { + return nil, fmt.Errorf("image type %s not supported", contentType) + } + if len(attachment) == 0 { + return nil, errors.New("image was of size 0") + } + if len(attachment) > mh.config.MediaConfig.MaxImageSize { + return nil, fmt.Errorf("image size %d bytes exceeded max image size of %d bytes", len(attachment), mh.config.MediaConfig.MaxImageSize) + } + return mh.processImageAttachment(attachment, accountID, contentType) + default: + break + } + return nil, fmt.Errorf("content type %s not (yet) supported", contentType) +} + +func (mh *mediaHandler) ProcessLocalEmoji(emojiBytes []byte, shortcode string) (*gtsmodel.Emoji, error) { + var clean []byte + var err error + var original *imageAndMeta + var static *imageAndMeta + + // check content type of the submitted emoji and make sure it's supported by us + contentType, err := parseContentType(emojiBytes) + if err != nil { + return nil, err + } + if !supportedEmojiType(contentType) { + return nil, fmt.Errorf("content type %s not supported for emojis", contentType) + } + + if len(emojiBytes) == 0 { + return nil, errors.New("emoji was of size 0") + } + if len(emojiBytes) > EmojiMaxBytes { + return nil, fmt.Errorf("emoji size %d bytes exceeded max emoji size of %d bytes", len(emojiBytes), EmojiMaxBytes) + } + + // clean any exif data from image/png type but leave gifs alone + switch contentType { + case "image/png": + if clean, err = purgeExif(emojiBytes); err != nil { + return nil, fmt.Errorf("error cleaning exif data: %s", err) + } + case "image/gif": + clean = emojiBytes + default: + return nil, errors.New("media type unrecognized") + } + + // unlike with other attachments we don't need to derive anything here because we don't care about the width/height etc + original = &imageAndMeta{ + image: clean, + } + + static, err = deriveStaticEmoji(clean, contentType) + if err != nil { + return nil, fmt.Errorf("error deriving static emoji: %s", err) + } + + // since emoji aren't 'owned' by an account, but we still want to use the same pattern for serving them through the filserver, + // (ie., fileserver/ACCOUNT_ID/etc etc) we need to fetch the INSTANCE ACCOUNT from the database. That is, the account that's created + // with the same username as the instance hostname, which doesn't belong to any particular user. + instanceAccount := &gtsmodel.Account{} + if err := mh.db.GetWhere("username", mh.config.Host, instanceAccount); err != nil { + return nil, fmt.Errorf("error fetching instance account: %s", err) + } + + // the file extension (either png or gif) + extension := strings.Split(contentType, "/")[1] + + // create the urls and storage paths + URLbase := fmt.Sprintf("%s://%s%s", mh.config.StorageConfig.ServeProtocol, mh.config.StorageConfig.ServeHost, mh.config.StorageConfig.ServeBasePath) + + // generate a uuid for the new emoji -- normally we could let the database do this for us, + // but we need it below so we should create it here instead. + newEmojiID := uuid.NewString() + + // webfinger uri for the emoji -- unrelated to actually serving the image + // will be something like https://example.org/emoji/70a7f3d7-7e35-4098-8ce3-9b5e8203bb9c + emojiURI := fmt.Sprintf("%s://%s/%s/%s", mh.config.Protocol, mh.config.Host, MediaEmoji, newEmojiID) + + // serve url and storage path for the original emoji -- can be png or gif + emojiURL := fmt.Sprintf("%s/%s/%s/%s/%s.%s", URLbase, instanceAccount.ID, MediaEmoji, MediaOriginal, newEmojiID, extension) + emojiPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, instanceAccount.ID, MediaEmoji, MediaOriginal, newEmojiID, extension) + + // serve url and storage path for the static version -- will always be png + emojiStaticURL := fmt.Sprintf("%s/%s/%s/%s/%s.png", URLbase, instanceAccount.ID, MediaEmoji, MediaStatic, newEmojiID) + emojiStaticPath := fmt.Sprintf("%s/%s/%s/%s/%s.png", mh.config.StorageConfig.BasePath, instanceAccount.ID, MediaEmoji, MediaStatic, newEmojiID) + + // store the original + if err := mh.storage.StoreFileAt(emojiPath, original.image); err != nil { + return nil, fmt.Errorf("storage error: %s", err) + } + + // store the static + if err := mh.storage.StoreFileAt(emojiStaticPath, static.image); err != nil { + return nil, fmt.Errorf("storage error: %s", err) + } + + // and finally return the new emoji data to the caller -- it's up to them what to do with it + e := &gtsmodel.Emoji{ + ID: newEmojiID, + Shortcode: shortcode, + Domain: "", // empty because this is a local emoji + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + ImageRemoteURL: "", // empty because this is a local emoji + ImageStaticRemoteURL: "", // empty because this is a local emoji + ImageURL: emojiURL, + ImageStaticURL: emojiStaticURL, + ImagePath: emojiPath, + ImageStaticPath: emojiStaticPath, + ImageContentType: contentType, + ImageFileSize: len(original.image), + ImageStaticFileSize: len(static.image), + ImageUpdatedAt: time.Now(), + Disabled: false, + URI: emojiURI, + VisibleInPicker: true, + CategoryID: "", // empty because this is a new emoji -- no category yet + } + return e, nil +} + /* HELPER FUNCTIONS */ -func (mh *mediaHandler) processHeaderOrAvi(imageBytes []byte, contentType string, headerOrAvi string, accountID string) (*model.MediaAttachment, error) { +func (mh *mediaHandler) processVideoAttachment(data []byte, accountID string, contentType string) (*gtsmodel.MediaAttachment, error) { + return nil, nil +} + +func (mh *mediaHandler) processImageAttachment(data []byte, accountID string, contentType string) (*gtsmodel.MediaAttachment, error) { + var clean []byte + var err error + var original *imageAndMeta + var small *imageAndMeta + + switch contentType { + case "image/jpeg", "image/png": + if clean, err = purgeExif(data); err != nil { + return nil, fmt.Errorf("error cleaning exif data: %s", err) + } + original, err = deriveImage(clean, contentType) + if err != nil { + return nil, fmt.Errorf("error parsing image: %s", err) + } + case "image/gif": + clean = data + original, err = deriveGif(clean, contentType) + if err != nil { + return nil, fmt.Errorf("error parsing gif: %s", err) + } + default: + return nil, errors.New("media type unrecognized") + } + + small, err = deriveThumbnail(clean, contentType, 256, 256) + if err != nil { + return nil, fmt.Errorf("error deriving thumbnail: %s", err) + } + + // now put it in storage, take a new uuid for the name of the file so we don't store any unnecessary info about it + extension := strings.Split(contentType, "/")[1] + newMediaID := uuid.NewString() + + URLbase := fmt.Sprintf("%s://%s%s", mh.config.StorageConfig.ServeProtocol, mh.config.StorageConfig.ServeHost, mh.config.StorageConfig.ServeBasePath) + originalURL := fmt.Sprintf("%s/%s/attachment/original/%s.%s", URLbase, accountID, newMediaID, extension) + smallURL := fmt.Sprintf("%s/%s/attachment/small/%s.jpeg", URLbase, accountID, newMediaID) // all thumbnails/smalls are encoded as jpeg + + // we store the original... + originalPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, accountID, MediaAttachment, MediaOriginal, newMediaID, extension) + if err := mh.storage.StoreFileAt(originalPath, original.image); err != nil { + return nil, fmt.Errorf("storage error: %s", err) + } + + // and a thumbnail... + smallPath := fmt.Sprintf("%s/%s/%s/%s/%s.jpeg", mh.config.StorageConfig.BasePath, accountID, MediaAttachment, MediaSmall, newMediaID) // all thumbnails/smalls are encoded as jpeg + if err := mh.storage.StoreFileAt(smallPath, small.image); err != nil { + return nil, fmt.Errorf("storage error: %s", err) + } + + ma := &gtsmodel.MediaAttachment{ + ID: newMediaID, + StatusID: "", + URL: originalURL, + RemoteURL: "", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Type: gtsmodel.FileTypeImage, + FileMeta: gtsmodel.FileMeta{ + Original: gtsmodel.Original{ + Width: original.width, + Height: original.height, + Size: original.size, + Aspect: original.aspect, + }, + Small: gtsmodel.Small{ + Width: small.width, + Height: small.height, + Size: small.size, + Aspect: small.aspect, + }, + }, + AccountID: accountID, + Description: "", + ScheduledStatusID: "", + Blurhash: original.blurhash, + Processing: 2, + File: gtsmodel.File{ + Path: originalPath, + ContentType: contentType, + FileSize: len(original.image), + UpdatedAt: time.Now(), + }, + Thumbnail: gtsmodel.Thumbnail{ + Path: smallPath, + ContentType: "image/jpeg", // all thumbnails/smalls are encoded as jpeg + FileSize: len(small.image), + UpdatedAt: time.Now(), + URL: smallURL, + RemoteURL: "", + }, + Avatar: false, + Header: false, + } + + return ma, nil + +} + +func (mh *mediaHandler) processHeaderOrAvi(imageBytes []byte, contentType string, headerOrAvi string, accountID string) (*gtsmodel.MediaAttachment, error) { var isHeader bool var isAvatar bool switch headerOrAvi { - case "header": + case MediaHeader: isHeader = true - case "avatar": + case MediaAvatar: isAvatar = true default: return nil, errors.New("header or avatar not selected") @@ -143,7 +412,7 @@ func (mh *mediaHandler) processHeaderOrAvi(imageBytes []byte, contentType string return nil, fmt.Errorf("error parsing image: %s", err) } - small, err := deriveThumbnail(clean, contentType) + small, err := deriveThumbnail(clean, contentType, 256, 256) if err != nil { return nil, fmt.Errorf("error deriving thumbnail: %s", err) } @@ -152,34 +421,38 @@ func (mh *mediaHandler) processHeaderOrAvi(imageBytes []byte, contentType string extension := strings.Split(contentType, "/")[1] newMediaID := uuid.NewString() - base := fmt.Sprintf("%s://%s%s", mh.config.StorageConfig.ServeProtocol, mh.config.StorageConfig.ServeHost, mh.config.StorageConfig.ServeBasePath) + URLbase := fmt.Sprintf("%s://%s%s", mh.config.StorageConfig.ServeProtocol, mh.config.StorageConfig.ServeHost, mh.config.StorageConfig.ServeBasePath) + originalURL := fmt.Sprintf("%s/%s/%s/original/%s.%s", URLbase, accountID, headerOrAvi, newMediaID, extension) + smallURL := fmt.Sprintf("%s/%s/%s/small/%s.%s", URLbase, accountID, headerOrAvi, newMediaID, extension) // we store the original... - originalPath := fmt.Sprintf("%s/%s/%s/original/%s.%s", base, accountID, headerOrAvi, newMediaID, extension) + originalPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, accountID, headerOrAvi, MediaOriginal, newMediaID, extension) if err := mh.storage.StoreFileAt(originalPath, original.image); err != nil { return nil, fmt.Errorf("storage error: %s", err) } + // and a thumbnail... - smallPath := fmt.Sprintf("%s/%s/%s/small/%s.%s", base, accountID, headerOrAvi, newMediaID, extension) + smallPath := fmt.Sprintf("%s/%s/%s/%s/%s.%s", mh.config.StorageConfig.BasePath, accountID, headerOrAvi, MediaSmall, newMediaID, extension) if err := mh.storage.StoreFileAt(smallPath, small.image); err != nil { return nil, fmt.Errorf("storage error: %s", err) } - ma := &model.MediaAttachment{ + ma := &gtsmodel.MediaAttachment{ ID: newMediaID, StatusID: "", + URL: originalURL, RemoteURL: "", CreatedAt: time.Now(), UpdatedAt: time.Now(), - Type: model.FileTypeImage, - FileMeta: model.FileMeta{ - Original: model.Original{ + Type: gtsmodel.FileTypeImage, + FileMeta: gtsmodel.FileMeta{ + Original: gtsmodel.Original{ Width: original.width, Height: original.height, Size: original.size, Aspect: original.aspect, }, - Small: model.Small{ + Small: gtsmodel.Small{ Width: small.width, Height: small.height, Size: small.size, @@ -191,17 +464,18 @@ func (mh *mediaHandler) processHeaderOrAvi(imageBytes []byte, contentType string ScheduledStatusID: "", Blurhash: original.blurhash, Processing: 2, - File: model.File{ + File: gtsmodel.File{ Path: originalPath, ContentType: contentType, FileSize: len(original.image), UpdatedAt: time.Now(), }, - Thumbnail: model.Thumbnail{ + Thumbnail: gtsmodel.Thumbnail{ Path: smallPath, ContentType: contentType, FileSize: len(small.image), UpdatedAt: time.Now(), + URL: smallURL, RemoteURL: "", }, Avatar: isAvatar, diff --git a/internal/media/media_test.go b/internal/media/media_test.go @@ -29,7 +29,7 @@ import ( "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/model" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/storage" ) @@ -95,7 +95,6 @@ func (suite *MediaTestSuite) SetupSuite() { storage: suite.mockStorage, log: log, } - } func (suite *MediaTestSuite) TearDownSuite() { @@ -108,14 +107,19 @@ func (suite *MediaTestSuite) TearDownSuite() { func (suite *MediaTestSuite) SetupTest() { // create all the tables we might need in thie suite models := []interface{}{ - &model.Account{}, - &model.MediaAttachment{}, + &gtsmodel.Account{}, + &gtsmodel.MediaAttachment{}, } for _, m := range models { if err := suite.db.CreateTable(m); err != nil { logrus.Panicf("db connection error: %s", err) } } + + err := suite.db.CreateInstanceAccount() + if err != nil { + logrus.Panic(err) + } } // TearDownTest drops tables to make sure there's no data in the db @@ -123,8 +127,8 @@ func (suite *MediaTestSuite) TearDownTest() { // remove all the tables we might have used so it's clear for the next test models := []interface{}{ - &model.Account{}, - &model.MediaAttachment{}, + &gtsmodel.Account{}, + &gtsmodel.MediaAttachment{}, } for _, m := range models { if err := suite.db.DropTable(m); err != nil { @@ -142,7 +146,7 @@ func (suite *MediaTestSuite) TestSetHeaderOrAvatarForAccountID() { f, err := ioutil.ReadFile("./test/test-jpeg.jpg") assert.Nil(suite.T(), err) - ma, err := suite.mediaHandler.SetHeaderOrAvatarForAccountID(f, "weeeeeee", "header") + ma, err := suite.mediaHandler.ProcessHeaderOrAvatar(f, "weeeeeee", "header") assert.Nil(suite.T(), err) suite.log.Debugf("%+v", ma) @@ -152,6 +156,15 @@ func (suite *MediaTestSuite) TestSetHeaderOrAvatarForAccountID() { //TODO: add more checks here, cba right now! } +func (suite *MediaTestSuite) TestProcessLocalEmoji() { + f, err := ioutil.ReadFile("./test/rainbow-original.png") + assert.NoError(suite.T(), err) + + emoji, err := suite.mediaHandler.ProcessLocalEmoji(f, "rainbow") + assert.NoError(suite.T(), err) + suite.log.Debugf("%+v", emoji) +} + // TODO: add tests for sad path, gif, png.... func TestMediaTestSuite(t *testing.T) { diff --git a/internal/media/mock_MediaHandler.go b/internal/media/mock_MediaHandler.go @@ -4,7 +4,7 @@ package media import ( mock "github.com/stretchr/testify/mock" - model "github.com/superseriousbusiness/gotosocial/internal/db/model" + gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" ) // MockMediaHandler is an autogenerated mock type for the MediaHandler type @@ -12,16 +12,39 @@ type MockMediaHandler struct { mock.Mock } +// ProcessAttachment provides a mock function with given fields: img, accountID +func (_m *MockMediaHandler) ProcessAttachment(img []byte, accountID string) (*gtsmodel.MediaAttachment, error) { + ret := _m.Called(img, accountID) + + var r0 *gtsmodel.MediaAttachment + if rf, ok := ret.Get(0).(func([]byte, string) *gtsmodel.MediaAttachment); ok { + r0 = rf(img, accountID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*gtsmodel.MediaAttachment) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func([]byte, string) error); ok { + r1 = rf(img, accountID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // SetHeaderOrAvatarForAccountID provides a mock function with given fields: img, accountID, headerOrAvi -func (_m *MockMediaHandler) SetHeaderOrAvatarForAccountID(img []byte, accountID string, headerOrAvi string) (*model.MediaAttachment, error) { +func (_m *MockMediaHandler) SetHeaderOrAvatarForAccountID(img []byte, accountID string, headerOrAvi string) (*gtsmodel.MediaAttachment, error) { ret := _m.Called(img, accountID, headerOrAvi) - var r0 *model.MediaAttachment - if rf, ok := ret.Get(0).(func([]byte, string, string) *model.MediaAttachment); ok { + var r0 *gtsmodel.MediaAttachment + if rf, ok := ret.Get(0).(func([]byte, string, string) *gtsmodel.MediaAttachment); ok { r0 = rf(img, accountID, headerOrAvi) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).(*model.MediaAttachment) + r0 = ret.Get(0).(*gtsmodel.MediaAttachment) } } diff --git a/internal/media/test/rainbow-original.png b/internal/media/test/rainbow-original.png Binary files differ. diff --git a/internal/media/test/rainbow-static.png b/internal/media/test/rainbow-static.png Binary files differ. diff --git a/internal/media/util.go b/internal/media/util.go @@ -70,6 +70,36 @@ func supportedImageType(mimeType string) bool { return false } +// supportedVideoType checks mime type of a video against a slice of accepted types, +// and returns True if the mime type is accepted. +func supportedVideoType(mimeType string) bool { + acceptedVideoTypes := []string{ + "video/mp4", + "video/mpeg", + "video/webm", + } + for _, accepted := range acceptedVideoTypes { + if mimeType == accepted { + return true + } + } + return false +} + +// supportedEmojiType checks that the content type is image/png -- the only type supported for emoji. +func supportedEmojiType(mimeType string) bool { + acceptedEmojiTypes := []string{ + "image/gif", + "image/png", + } + for _, accepted := range acceptedEmojiTypes { + if mimeType == accepted { + return true + } + } + return false +} + // purgeExif is a little wrapper for the action of removing exif data from an image. // Only pass pngs or jpegs to this function. func purgeExif(b []byte) ([]byte, error) { @@ -87,11 +117,50 @@ func purgeExif(b []byte) ([]byte, error) { return clean, nil } -func deriveImage(b []byte, extension string) (*imageAndMeta, error) { +func deriveGif(b []byte, extension string) (*imageAndMeta, error) { + var g *gif.GIF + var err error + switch extension { + case "image/gif": + g, err = gif.DecodeAll(bytes.NewReader(b)) + if err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("extension %s not recognised", extension) + } + + // use the first frame to get the static characteristics + width := g.Config.Width + height := g.Config.Height + size := width * height + aspect := float64(width) / float64(height) + + bh, err := blurhash.Encode(4, 3, g.Image[0]) + if err != nil || bh == "" { + return nil, err + } + + out := &bytes.Buffer{} + if err := gif.EncodeAll(out, g); err != nil { + return nil, err + } + + return &imageAndMeta{ + image: out.Bytes(), + width: width, + height: height, + size: size, + aspect: aspect, + blurhash: bh, + }, nil +} + +func deriveImage(b []byte, contentType string) (*imageAndMeta, error) { var i image.Image var err error - switch extension { + switch contentType { case "image/jpeg": i, err = jpeg.Decode(bytes.NewReader(b)) if err != nil { @@ -102,28 +171,25 @@ func deriveImage(b []byte, extension string) (*imageAndMeta, error) { if err != nil { return nil, err } - case "image/gif": - i, err = gif.Decode(bytes.NewReader(b)) - if err != nil { - return nil, err - } default: - return nil, fmt.Errorf("extension %s not recognised", extension) + return nil, fmt.Errorf("content type %s not recognised", contentType) } width := i.Bounds().Size().X height := i.Bounds().Size().Y size := width * height aspect := float64(width) / float64(height) + bh, err := blurhash.Encode(4, 3, i) if err != nil { - return nil, fmt.Errorf("error generating blurhash: %s", err) + return nil, err } out := &bytes.Buffer{} if err := jpeg.Encode(out, i, nil); err != nil { return nil, err } + return &imageAndMeta{ image: out.Bytes(), width: width, @@ -134,16 +200,16 @@ func deriveImage(b []byte, extension string) (*imageAndMeta, error) { }, nil } -// deriveThumbnailFromImage returns a byte slice and metadata for a 256-pixel-width thumbnail +// deriveThumbnail returns a byte slice and metadata for a thumbnail of width x and height y, // of a given jpeg, png, or gif, or an error if something goes wrong. // // Note that the aspect ratio of the image will be retained, -// so it will not necessarily be a square. -func deriveThumbnail(b []byte, extension string) (*imageAndMeta, error) { +// so it will not necessarily be a square, even if x and y are set as the same value. +func deriveThumbnail(b []byte, contentType string, x uint, y uint) (*imageAndMeta, error) { var i image.Image var err error - switch extension { + switch contentType { case "image/jpeg": i, err = jpeg.Decode(bytes.NewReader(b)) if err != nil { @@ -160,10 +226,10 @@ func deriveThumbnail(b []byte, extension string) (*imageAndMeta, error) { return nil, err } default: - return nil, fmt.Errorf("extension %s not recognised", extension) + return nil, fmt.Errorf("content type %s not recognised", contentType) } - thumb := resize.Thumbnail(256, 256, i, resize.NearestNeighbor) + thumb := resize.Thumbnail(x, y, i, resize.NearestNeighbor) width := thumb.Bounds().Size().X height := thumb.Bounds().Size().Y size := width * height @@ -182,6 +248,35 @@ func deriveThumbnail(b []byte, extension string) (*imageAndMeta, error) { }, nil } +// deriveStaticEmojji takes a given gif or png of an emoji, decodes it, and re-encodes it as a static png. +func deriveStaticEmoji(b []byte, contentType string) (*imageAndMeta, error) { + var i image.Image + var err error + + switch contentType { + case "image/png": + i, err = png.Decode(bytes.NewReader(b)) + if err != nil { + return nil, err + } + case "image/gif": + i, err = gif.Decode(bytes.NewReader(b)) + if err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("content type %s not allowed for emoji", contentType) + } + + out := &bytes.Buffer{} + if err := png.Encode(out, i); err != nil { + return nil, err + } + return &imageAndMeta{ + image: out.Bytes(), + }, nil +} + type imageAndMeta struct { image []byte width int diff --git a/internal/media/util_test.go b/internal/media/util_test.go @@ -121,7 +121,7 @@ func (suite *MediaUtilTestSuite) TestDeriveThumbnailFromJPEG() { assert.Nil(suite.T(), err) // clean it up and validate the clean version - imageAndMeta, err := deriveThumbnail(b, "image/jpeg") + imageAndMeta, err := deriveThumbnail(b, "image/jpeg", 256, 256) assert.Nil(suite.T(), err) assert.Equal(suite.T(), 256, imageAndMeta.width) diff --git a/internal/oauth/server.go b/internal/oauth/server.go @@ -26,7 +26,7 @@ import ( "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/model" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" "github.com/superseriousbusiness/oauth2/v4" "github.com/superseriousbusiness/oauth2/v4/errors" "github.com/superseriousbusiness/oauth2/v4/manage" @@ -34,6 +34,9 @@ import ( ) const ( + // SessionAuthorizedToken is the key set in the gin context for the Token + // of a User who has successfully passed Bearer token authorization. + // The interface returned from grabbing this key should be parsed as oauth2.TokenInfo SessionAuthorizedToken = "authorized_token" // SessionAuthorizedUser is the key set in the gin context for the id of // a User who has successfully passed Bearer token authorization. @@ -65,9 +68,9 @@ type s struct { type Authed struct { Token oauth2.TokenInfo - Application *model.Application - User *model.User - Account *model.Account + Application *gtsmodel.Application + User *gtsmodel.User + Account *gtsmodel.Account } // GetAuthed is a convenience function for returning an Authed struct from a gin context. @@ -96,7 +99,7 @@ func GetAuthed(c *gin.Context) (*Authed, error) { i, ok = ctx.Get(SessionAuthorizedApplication) if ok { - parsed, ok := i.(*model.Application) + parsed, ok := i.(*gtsmodel.Application) if !ok { return nil, errors.New("could not parse application from session context") } @@ -105,7 +108,7 @@ func GetAuthed(c *gin.Context) (*Authed, error) { i, ok = ctx.Get(SessionAuthorizedUser) if ok { - parsed, ok := i.(*model.User) + parsed, ok := i.(*gtsmodel.User) if !ok { return nil, errors.New("could not parse user from session context") } @@ -114,7 +117,7 @@ func GetAuthed(c *gin.Context) (*Authed, error) { i, ok = ctx.Get(SessionAuthorizedAccount) if ok { - parsed, ok := i.(*model.Account) + parsed, ok := i.(*gtsmodel.Account) if !ok { return nil, errors.New("could not parse account from session context") } diff --git a/internal/oauth/tokenstore.go b/internal/oauth/tokenstore.go @@ -98,7 +98,7 @@ func (pts *tokenStore) Create(ctx context.Context, info oauth2.TokenInfo) error if !ok { return errors.New("info param was not a models.Token") } - if err := pts.db.Put(oauthTokenToPGToken(t)); err != nil { + if err := pts.db.Put(OAuthTokenToPGToken(t)); err != nil { return fmt.Errorf("error in tokenstore create: %s", err) } return nil @@ -130,7 +130,7 @@ func (pts *tokenStore) GetByCode(ctx context.Context, code string) (oauth2.Token if err := pts.db.GetWhere("code", code, pgt); err != nil { return nil, err } - return pgTokenToOauthToken(pgt), nil + return PGTokenToOauthToken(pgt), nil } // GetByAccess selects a token from the DB based on the Access field @@ -144,7 +144,7 @@ func (pts *tokenStore) GetByAccess(ctx context.Context, access string) (oauth2.T if err := pts.db.GetWhere("access", access, pgt); err != nil { return nil, err } - return pgTokenToOauthToken(pgt), nil + return PGTokenToOauthToken(pgt), nil } // GetByRefresh selects a token from the DB based on the Refresh field @@ -158,7 +158,7 @@ func (pts *tokenStore) GetByRefresh(ctx context.Context, refresh string) (oauth2 if err := pts.db.GetWhere("refresh", refresh, pgt); err != nil { return nil, err } - return pgTokenToOauthToken(pgt), nil + return PGTokenToOauthToken(pgt), nil } /* @@ -194,8 +194,8 @@ type Token struct { RefreshExpiresAt time.Time `pg:"type:timestamp"` } -// oauthTokenToPGToken is a lil util function that takes a gotosocial token and gives back a token for inserting into postgres -func oauthTokenToPGToken(tkn *models.Token) *Token { +// OAuthTokenToPGToken is a lil util function that takes a gotosocial token and gives back a token for inserting into postgres +func OAuthTokenToPGToken(tkn *models.Token) *Token { now := time.Now() // For the following, we want to make sure we're not adding a time.Now() to an *empty* ExpiresIn, otherwise that's @@ -236,8 +236,8 @@ func oauthTokenToPGToken(tkn *models.Token) *Token { } } -// pgTokenToOauthToken is a lil util function that takes a postgres token and gives back a gotosocial token -func pgTokenToOauthToken(pgt *Token) *models.Token { +// PGTokenToOauthToken is a lil util function that takes a postgres token and gives back a gotosocial token +func PGTokenToOauthToken(pgt *Token) *models.Token { now := time.Now() return &models.Token{ diff --git a/internal/router/router.go b/internal/router/router.go @@ -83,7 +83,17 @@ func (r *router) AttachMiddleware(middleware gin.HandlerFunc) { // New returns a new Router with the specified configuration, using the given logrus logger. func New(config *config.Config, logger *logrus.Logger) (Router, error) { - engine := gin.New() + lvl, err := logrus.ParseLevel(config.LogLevel) + if err != nil { + return nil, fmt.Errorf("couldn't parse log level %s to set router level: %s", config.LogLevel, err) + } + switch lvl { + case logrus.TraceLevel, logrus.DebugLevel: + gin.SetMode(gin.DebugMode) + default: + gin.SetMode(gin.ReleaseMode) + } + engine := gin.Default() // create a new session store middleware store, err := sessionStore() diff --git a/internal/storage/inmem.go b/internal/storage/inmem.go @@ -7,25 +7,49 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/config" ) +// NewInMem returns an in-memory implementation of the Storage interface. +// This is good for testing and whatnot but ***SHOULD ABSOLUTELY NOT EVER +// BE USED IN A PRODUCTION SETTING***, because A) everything will be wiped out +// if you restart the server and B) if you store lots of images your RAM use +// will absolutely go through the roof. func NewInMem(c *config.Config, log *logrus.Logger) (Storage, error) { return &inMemStorage{ stored: make(map[string][]byte), + log: log, }, nil } type inMemStorage struct { stored map[string][]byte + log *logrus.Logger } func (s *inMemStorage) StoreFileAt(path string, data []byte) error { + l := s.log.WithField("func", "StoreFileAt") + l.Debugf("storing at path %s", path) s.stored[path] = data return nil } func (s *inMemStorage) RetrieveFileFrom(path string) ([]byte, error) { + l := s.log.WithField("func", "RetrieveFileFrom") + l.Debugf("retrieving from path %s", path) d, ok := s.stored[path] if !ok { return nil, fmt.Errorf("no data found at path %s", path) } return d, nil } + +func (s *inMemStorage) ListKeys() ([]string, error) { + keys := []string{} + for k := range s.stored { + keys = append(keys, k) + } + return keys, nil +} + +func (s *inMemStorage) RemoveFileAt(path string) error { + delete(s.stored, path) + return nil +} diff --git a/internal/storage/local.go b/internal/storage/local.go @@ -1,21 +1,70 @@ package storage import ( + "fmt" + "os" + "path/filepath" + "strings" + "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/config" ) +// NewLocal returns an implementation of the Storage interface that uses +// the local filesystem for storing and retrieving files, attachments, etc. func NewLocal(c *config.Config, log *logrus.Logger) (Storage, error) { - return &localStorage{}, nil + return &localStorage{ + config: c, + log: log, + }, nil } type localStorage struct { + config *config.Config + log *logrus.Logger } func (s *localStorage) StoreFileAt(path string, data []byte) error { + l := s.log.WithField("func", "StoreFileAt") + l.Debugf("storing at path %s", path) + components := strings.Split(path, "/") + dir := strings.Join(components[0:len(components)-1], "/") + if err := os.MkdirAll(dir, 0777); err != nil { + return fmt.Errorf("error writing file at %s: %s", path, err) + } + if err := os.WriteFile(path, data, 0777); err != nil { + return fmt.Errorf("error writing file at %s: %s", path, err) + } return nil } func (s *localStorage) RetrieveFileFrom(path string) ([]byte, error) { - return nil, nil + l := s.log.WithField("func", "RetrieveFileFrom") + l.Debugf("retrieving from path %s", path) + b, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("error reading file at %s: %s", path, err) + } + return b, nil +} + +func (s *localStorage) ListKeys() ([]string, error) { + keys := []string{} + err := filepath.Walk(s.config.StorageConfig.BasePath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() { + keys = append(keys, path) + } + return nil + }) + if err != nil { + return nil, err + } + return keys, nil +} + +func (s *localStorage) RemoveFileAt(path string) error { + return os.Remove(path) } diff --git a/internal/storage/mock_Storage.go b/internal/storage/mock_Storage.go @@ -9,6 +9,43 @@ type MockStorage struct { mock.Mock } +// ListKeys provides a mock function with given fields: +func (_m *MockStorage) ListKeys() ([]string, error) { + ret := _m.Called() + + var r0 []string + if rf, ok := ret.Get(0).(func() []string); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// RemoveFileAt provides a mock function with given fields: path +func (_m *MockStorage) RemoveFileAt(path string) error { + ret := _m.Called(path) + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(path) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // RetrieveFileFrom provides a mock function with given fields: path func (_m *MockStorage) RetrieveFileFrom(path string) ([]byte, error) { ret := _m.Called(path) diff --git a/internal/storage/storage.go b/internal/storage/storage.go @@ -16,9 +16,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. */ +// Package storage contains an interface and implementations for storing and retrieving files and attachments. package storage +// Storage is an interface for storing and retrieving blobs +// such as images, videos, and any other attachments/documents +// that shouldn't be stored in a database. type Storage interface { StoreFileAt(path string, data []byte) error RetrieveFileFrom(path string) ([]byte, error) + ListKeys() ([]string, error) + RemoveFileAt(path string) error } diff --git a/internal/util/parse.go b/internal/util/parse.go @@ -1,32 +1,96 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + package util -import "fmt" +import ( + "fmt" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel" +) + +// URIs contains a bunch of URIs and URLs for a user, host, account, etc. type URIs struct { - HostURL string - UserURL string + HostURL string + UserURL string + StatusesURL string + UserURI string - InboxURL string - OutboxURL string - FollowersURL string - CollectionURL string + StatusesURI string + InboxURI string + OutboxURI string + FollowersURI string + CollectionURI string } +// GenerateURIs throws together a bunch of URIs for the given username, with the given protocol and host. func GenerateURIs(username string, protocol string, host string) *URIs { hostURL := fmt.Sprintf("%s://%s", protocol, host) userURL := fmt.Sprintf("%s/@%s", hostURL, username) + statusesURL := fmt.Sprintf("%s/statuses", userURL) + userURI := fmt.Sprintf("%s/users/%s", hostURL, username) - inboxURL := fmt.Sprintf("%s/inbox", userURI) - outboxURL := fmt.Sprintf("%s/outbox", userURI) - followersURL := fmt.Sprintf("%s/followers", userURI) - collectionURL := fmt.Sprintf("%s/collections/featured", userURI) + statusesURI := fmt.Sprintf("%s/statuses", userURI) + inboxURI := fmt.Sprintf("%s/inbox", userURI) + outboxURI := fmt.Sprintf("%s/outbox", userURI) + followersURI := fmt.Sprintf("%s/followers", userURI) + collectionURI := fmt.Sprintf("%s/collections/featured", userURI) return &URIs{ - HostURL: hostURL, - UserURL: userURL, + HostURL: hostURL, + UserURL: userURL, + StatusesURL: statusesURL, + UserURI: userURI, - InboxURL: inboxURL, - OutboxURL: outboxURL, - FollowersURL: followersURL, - CollectionURL: collectionURL, + StatusesURI: statusesURI, + InboxURI: inboxURI, + OutboxURI: outboxURI, + FollowersURI: followersURI, + CollectionURI: collectionURI, + } +} + +// ParseGTSVisFromMastoVis converts a mastodon visibility into its gts equivalent. +func ParseGTSVisFromMastoVis(m mastotypes.Visibility) gtsmodel.Visibility { + switch m { + case mastotypes.VisibilityPublic: + return gtsmodel.VisibilityPublic + case mastotypes.VisibilityUnlisted: + return gtsmodel.VisibilityUnlocked + case mastotypes.VisibilityPrivate: + return gtsmodel.VisibilityFollowersOnly + case mastotypes.VisibilityDirect: + return gtsmodel.VisibilityDirect + } + return "" +} + +// ParseMastoVisFromGTSVis converts a gts visibility into its mastodon equivalent +func ParseMastoVisFromGTSVis(m gtsmodel.Visibility) mastotypes.Visibility { + switch m { + case gtsmodel.VisibilityPublic: + return mastotypes.VisibilityPublic + case gtsmodel.VisibilityUnlocked: + return mastotypes.VisibilityUnlisted + case gtsmodel.VisibilityFollowersOnly, gtsmodel.VisibilityMutualsOnly: + return mastotypes.VisibilityPrivate + case gtsmodel.VisibilityDirect: + return mastotypes.VisibilityDirect } + return "" } diff --git a/internal/util/regexes.go b/internal/util/regexes.go @@ -0,0 +1,36 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package util + +import "regexp" + +var ( + // mention regex can be played around with here: https://regex101.com/r/qwM9D3/1 + mentionRegexString = `(?: |^|\W)(@[a-zA-Z0-9_]+(?:@[a-zA-Z0-9_\-\.]+)?)(?: |\n)` + mentionRegex = regexp.MustCompile(mentionRegexString) + // hashtag regex can be played with here: https://regex101.com/r/Vhy8pg/1 + hashtagRegexString = `(?: |^|\W)?#([a-zA-Z0-9]{1,30})(?:\b|\r)` + hashtagRegex = regexp.MustCompile(hashtagRegexString) + // emoji regex can be played with here: https://regex101.com/r/478XGM/1 + emojiRegexString = `(?: |^|\W)?:([a-zA-Z0-9_]{2,30}):(?:\b|\r)?` + emojiRegex = regexp.MustCompile(emojiRegexString) + // emoji shortcode regex can be played with here: https://regex101.com/r/zMDRaG/1 + emojiShortcodeString = `^[a-z0-9_]{2,30}$` + emojiShortcodeRegex = regexp.MustCompile(emojiShortcodeString) +) diff --git a/internal/util/status.go b/internal/util/status.go @@ -0,0 +1,96 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package util + +import ( + "strings" +) + +// DeriveMentions takes a plaintext (ie., not html-formatted) status, +// and applies a regex to it to return a deduplicated list of accounts +// mentioned in that status. +// +// It will look for fully-qualified account names in the form "@user@example.org". +// or the form "@username" for local users. +// The case of the returned mentions will be lowered, for consistency. +func DeriveMentions(status string) []string { + mentionedAccounts := []string{} + for _, m := range mentionRegex.FindAllStringSubmatch(status, -1) { + mentionedAccounts = append(mentionedAccounts, m[1]) + } + return Lower(Unique(mentionedAccounts)) +} + +// DeriveHashtags takes a plaintext (ie., not html-formatted) status, +// and applies a regex to it to return a deduplicated list of hashtags +// used in that status, without the leading #. The case of the returned +// tags will be lowered, for consistency. +func DeriveHashtags(status string) []string { + tags := []string{} + for _, m := range hashtagRegex.FindAllStringSubmatch(status, -1) { + tags = append(tags, m[1]) + } + return Lower(Unique(tags)) +} + +// DeriveEmojis takes a plaintext (ie., not html-formatted) status, +// and applies a regex to it to return a deduplicated list of emojis +// used in that status, without the surround ::. The case of the returned +// emojis will be lowered, for consistency. +func DeriveEmojis(status string) []string { + emojis := []string{} + for _, m := range emojiRegex.FindAllStringSubmatch(status, -1) { + emojis = append(emojis, m[1]) + } + return Lower(Unique(emojis)) +} + +// Unique returns a deduplicated version of a given string slice. +func Unique(s []string) []string { + keys := make(map[string]bool) + list := []string{} + for _, entry := range s { + if _, value := keys[entry]; !value { + keys[entry] = true + list = append(list, entry) + } + } + return list +} + +// Lower lowercases all strings in a given string slice +func Lower(s []string) []string { + new := []string{} + for _, i := range s { + new = append(new, strings.ToLower(i)) + } + return new +} + +// HTMLFormat takes a plaintext formatted status string, and converts it into +// a nice HTML-formatted string. +// +// This includes: +// - Replacing line-breaks with <p> +// - Replacing URLs with hrefs. +// - Replacing mentions with links to that account's URL as stored in the database. +func HTMLFormat(status string) string { + // TODO: write proper HTML formatting logic for a status + return status +} diff --git a/internal/util/status_test.go b/internal/util/status_test.go @@ -0,0 +1,105 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package util + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type StatusTestSuite struct { + suite.Suite +} + +func (suite *StatusTestSuite) TestDeriveMentionsOK() { + statusText := `@dumpsterqueer@example.org testing testing + + is this thing on? + + @someone_else@testing.best-horse.com can you confirm? @hello@test.lgbt + + @thisisalocaluser ! @NORWILL@THIS.one!! + + here is a duplicate mention: @hello@test.lgbt + ` + + menchies := DeriveMentions(statusText) + assert.Len(suite.T(), menchies, 4) + assert.Equal(suite.T(), "@dumpsterqueer@example.org", menchies[0]) + assert.Equal(suite.T(), "@someone_else@testing.best-horse.com", menchies[1]) + assert.Equal(suite.T(), "@hello@test.lgbt", menchies[2]) + assert.Equal(suite.T(), "@thisisalocaluser", menchies[3]) +} + +func (suite *StatusTestSuite) TestDeriveMentionsEmpty() { + statusText := `` + menchies := DeriveMentions(statusText) + assert.Len(suite.T(), menchies, 0) +} + +func (suite *StatusTestSuite) TestDeriveHashtagsOK() { + statusText := `#testing123 #also testing + +# testing this one shouldn't work + + #thisshouldwork + +#ThisShouldAlsoWork #not_this_though + +#111111 thisalsoshouldn'twork#### ##` + + tags := DeriveHashtags(statusText) + assert.Len(suite.T(), tags, 5) + assert.Equal(suite.T(), "testing123", tags[0]) + assert.Equal(suite.T(), "also", tags[1]) + assert.Equal(suite.T(), "thisshouldwork", tags[2]) + assert.Equal(suite.T(), "thisshouldalsowork", tags[3]) + assert.Equal(suite.T(), "111111", tags[4]) +} + +func (suite *StatusTestSuite) TestDeriveEmojiOK() { + statusText := `:test: :another: + +Here's some normal text with an :emoji: at the end + +:spaces shouldnt work: + +:emoji1::emoji2: + +:anotheremoji:emoji2: +:anotheremoji::anotheremoji::anotheremoji::anotheremoji: +:underscores_ok_too: +` + + tags := DeriveEmojis(statusText) + assert.Len(suite.T(), tags, 7) + assert.Equal(suite.T(), "test", tags[0]) + assert.Equal(suite.T(), "another", tags[1]) + assert.Equal(suite.T(), "emoji", tags[2]) + assert.Equal(suite.T(), "emoji1", tags[3]) + assert.Equal(suite.T(), "emoji2", tags[4]) + assert.Equal(suite.T(), "anotheremoji", tags[5]) + assert.Equal(suite.T(), "underscores_ok_too", tags[6]) +} + +func TestStatusTestSuite(t *testing.T) { + suite.Run(t, new(StatusTestSuite)) +} diff --git a/internal/util/validation.go b/internal/util/validation.go @@ -142,3 +142,13 @@ func ValidatePrivacy(privacy string) error { // TODO: add some validation logic here -- length, characters, etc return nil } + +// ValidateEmojiShortcode just runs the given shortcode through the regular expression +// for emoji shortcodes, to figure out whether it's a valid shortcode, ie., 2-30 characters, +// lowercase a-z, numbers, and underscores. +func ValidateEmojiShortcode(shortcode string) error { + if !emojiShortcodeRegex.MatchString(shortcode) { + return fmt.Errorf("shortcode %s did not pass validation, must be between 2 and 30 characters, lowercase letters, numbers, and underscores only", shortcode) + } + return nil +} diff --git a/pkg/mastotypes/account.go b/pkg/mastotypes/account.go @@ -1,131 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package mastotypes - -import "mime/multipart" - -// Account represents a mastodon-api Account object, as described here: https://docs.joinmastodon.org/entities/account/ -type Account struct { - // The account id - ID string `json:"id"` - // The username of the account, not including domain. - Username string `json:"username"` - // The Webfinger account URI. Equal to username for local users, or username@domain for remote users. - Acct string `json:"acct"` - // The profile's display name. - DisplayName string `json:"display_name"` - // Whether the account manually approves follow requests. - Locked bool `json:"locked"` - // Whether the account has opted into discovery features such as the profile directory. - Discoverable bool `json:"discoverable,omitempty"` - // A presentational flag. Indicates that the account may perform automated actions, may not be monitored, or identifies as a robot. - Bot bool `json:"bot"` - // When the account was created. (ISO 8601 Datetime) - CreatedAt string `json:"created_at"` - // The profile's bio / description. - Note string `json:"note"` - // The location of the user's profile page. - URL string `json:"url"` - // An image icon that is shown next to statuses and in the profile. - Avatar string `json:"avatar"` - // A static version of the avatar. Equal to avatar if its value is a static image; different if avatar is an animated GIF. - AvatarStatic string `json:"avatar_static"` - // An image banner that is shown above the profile and in profile cards. - Header string `json:"header"` - // A static version of the header. Equal to header if its value is a static image; different if header is an animated GIF. - HeaderStatic string `json:"header_static"` - // The reported followers of this profile. - FollowersCount int `json:"followers_count"` - // The reported follows of this profile. - FollowingCount int `json:"following_count"` - // How many statuses are attached to this account. - StatusesCount int `json:"statuses_count"` - // When the most recent status was posted. (ISO 8601 Datetime) - LastStatusAt string `json:"last_status_at"` - // Custom emoji entities to be used when rendering the profile. If none, an empty array will be returned. - Emojis []Emoji `json:"emojis"` - // Additional metadata attached to a profile as name-value pairs. - Fields []Field `json:"fields"` - // An extra entity returned when an account is suspended. - Suspended bool `json:"suspended,omitempty"` - // When a timed mute will expire, if applicable. (ISO 8601 Datetime) - MuteExpiresAt string `json:"mute_expires_at,omitempty"` - // An extra entity to be used with API methods to verify credentials and update credentials. - Source *Source `json:"source"` -} - -// AccountCreateRequest represents the form submitted during a POST request to /api/v1/accounts. -// See https://docs.joinmastodon.org/methods/accounts/ -type AccountCreateRequest struct { - // Text that will be reviewed by moderators if registrations require manual approval. - Reason string `form:"reason"` - // The desired username for the account - Username string `form:"username" binding:"required"` - // The email address to be used for login - Email string `form:"email" binding:"required"` - // The password to be used for login - Password string `form:"password" binding:"required"` - // Whether the user agrees to the local rules, terms, and policies. - // These should be presented to the user in order to allow them to consent before setting this parameter to TRUE. - Agreement bool `form:"agreement" binding:"required"` - // The language of the confirmation email that will be sent - Locale string `form:"locale" binding:"required"` -} - -// UpdateCredentialsRequest represents the form submitted during a PATCH request to /api/v1/accounts/update_credentials. -// See https://docs.joinmastodon.org/methods/accounts/ -type UpdateCredentialsRequest struct { - // Whether the account should be shown in the profile directory. - Discoverable *bool `form:"discoverable"` - // Whether the account has a bot flag. - Bot *bool `form:"bot"` - // The display name to use for the profile. - DisplayName *string `form:"display_name"` - // The account bio. - Note *string `form:"note"` - // Avatar image encoded using multipart/form-data - Avatar *multipart.FileHeader `form:"avatar"` - // Header image encoded using multipart/form-data - Header *multipart.FileHeader `form:"header"` - // Whether manual approval of follow requests is required. - Locked *bool `form:"locked"` - // New Source values for this account - Source *UpdateSource `form:"source"` - // Profile metadata name and value - FieldsAttributes *[]UpdateField `form:"fields_attributes"` -} - -// UpdateSource is to be used specifically in an UpdateCredentialsRequest. -type UpdateSource struct { - // Default post privacy for authored statuses. - Privacy *string `form:"privacy"` - // Whether to mark authored statuses as sensitive by default. - Sensitive *bool `form:"sensitive"` - // Default language to use for authored statuses. (ISO 6391) - Language *string `form:"language"` -} - -// UpdateField is to be used specifically in an UpdateCredentialsRequest. -// By default, max 4 fields and 255 characters per property/value. -type UpdateField struct { - // Name of the field - Name *string `form:"name"` - // Value of the field - Value *string `form:"value"` -} diff --git a/pkg/mastotypes/application.go b/pkg/mastotypes/application.go @@ -1,55 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package mastotypes - -// Application represents a mastodon-api Application, as defined here: https://docs.joinmastodon.org/entities/application/. -// Primarily, application is used for allowing apps like Tusky etc to connect to Mastodon on behalf of a user. -// See https://docs.joinmastodon.org/methods/apps/ -type Application struct { - // The application ID in the db - ID string `json:"id,omitempty"` - // The name of your application. - Name string `json:"name"` - // The website associated with your application (url) - Website string `json:"website,omitempty"` - // Where the user should be redirected after authorization. - RedirectURI string `json:"redirect_uri,omitempty"` - // ClientID to use when obtaining an oauth token for this application (ie., in client_id parameter of https://docs.joinmastodon.org/methods/apps/) - ClientID string `json:"client_id,omitempty"` - // Client secret to use when obtaining an auth token for this application (ie., in client_secret parameter of https://docs.joinmastodon.org/methods/apps/) - ClientSecret string `json:"client_secret,omitempty"` - // Used for Push Streaming API. Returned with POST /api/v1/apps. Equivalent to https://docs.joinmastodon.org/entities/pushsubscription/#server_key - VapidKey string `json:"vapid_key"` -} - -// ApplicationPOSTRequest represents a POST request to https://example.org/api/v1/apps. -// See here: https://docs.joinmastodon.org/methods/apps/ -// And here: https://docs.joinmastodon.org/client/token/ -type ApplicationPOSTRequest struct { - // A name for your application - ClientName string `form:"client_name" binding:"required"` - // Where the user should be redirected after authorization. - // To display the authorization code to the user instead of redirecting - // to a web page, use urn:ietf:wg:oauth:2.0:oob in this parameter. - RedirectURIs string `form:"redirect_uris" binding:"required"` - // Space separated list of scopes. If none is provided, defaults to read. - Scopes string `form:"scopes"` - // A URL to the homepage of your app - Website string `form:"website"` -} diff --git a/pkg/mastotypes/attachment.go b/pkg/mastotypes/attachment.go @@ -1,96 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package mastotypes - -import "mime/multipart" - -// AttachmentRequest represents the form data parameters submitted by a client during a media upload request. -// See: https://docs.joinmastodon.org/methods/statuses/media/ -type AttachmentRequest struct { - File *multipart.FileHeader `form:"file"` - Thumbnail *multipart.FileHeader `form:"thumbnail"` - Description string `form:"description"` - Focus string `form:"focus"` -} - -// Attachment represents the object returned to a client after a successful media upload request. -// See: https://docs.joinmastodon.org/methods/statuses/media/ -type Attachment struct { - // The ID of the attachment in the database. - ID string `json:"id"` - // The type of the attachment. - // unknown = unsupported or unrecognized file type. - // image = Static image. - // gifv = Looping, soundless animation. - // video = Video clip. - // audio = Audio track. - Type string `json:"type"` - // The location of the original full-size attachment. - URL string `json:"url"` - // The location of a scaled-down preview of the attachment. - PreviewURL string `json:"preview_url"` - // The location of the full-size original attachment on the remote website. - RemoteURL string `json:"remote_url,omitempty"` - // A shorter URL for the attachment. - TextURL string `json:"text_url,omitempty"` - // Metadata returned by Paperclip. - // May contain subtrees small and original, as well as various other top-level properties. - // More importantly, there may be another top-level focus Hash object as of 2.3.0, with coordinates can be used for smart thumbnail cropping. - // See https://docs.joinmastodon.org/methods/statuses/media/#focal-points points for more. - Meta MediaMeta `json:"meta,omitempty"` - // Alternate text that describes what is in the media attachment, to be used for the visually impaired or when media attachments do not load. - Description string `json:"description,omitempty"` - // A hash computed by the BlurHash algorithm, for generating colorful preview thumbnails when media has not been downloaded yet. - // See https://github.com/woltapp/blurhash - Blurhash string `json:"blurhash,omitempty"` -} - -// MediaMeta describes the returned media -type MediaMeta struct { - Length string `json:"length,omitempty"` - Duration float32 `json:"duration,omitempty"` - FPS uint16 `json:"fps,omitempty"` - Size string `json:"size,omitempty"` - Width int `json:"width,omitempty"` - Height int `json:"height,omitempty"` - Aspect float32 `json:"aspect,omitempty"` - AudioEncode string `json:"audio_encode,omitempty"` - AudioBitrate string `json:"audio_bitrate,omitempty"` - AudioChannels string `json:"audio_channels,omitempty"` - Original MediaDimensions `json:"original"` - Small MediaDimensions `json:"small,omitempty"` - Focus MediaFocus `json:"focus,omitempty"` -} - -// MediaFocus describes the focal point of a piece of media. It should be returned to the caller as part of MediaMeta. -type MediaFocus struct { - X float32 `json:"x"` // should be between -1 and 1 - Y float32 `json:"y"` // should be between -1 and 1 -} - -// MediaDimensions describes the physical properties of a piece of media. It should be returned to the caller as part of MediaMeta. -type MediaDimensions struct { - Width int `json:"width,omitempty"` - Height int `json:"height,omitempty"` - FrameRate string `json:"frame_rate,omitempty"` - Duration float32 `json:"duration,omitempty"` - Bitrate int `json:"bitrate,omitempty"` - Size string `json:"size,omitempty"` - Aspect float32 `json:"aspect,omitempty"` -} diff --git a/pkg/mastotypes/emoji.go b/pkg/mastotypes/emoji.go @@ -1,38 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package mastotypes - -// Emoji represents a custom emoji. See https://docs.joinmastodon.org/entities/emoji/ -type Emoji struct { - // REQUIRED - - // The name of the custom emoji. - Shortcode string `json:"shortcode"` - // A link to the custom emoji. - URL string `json:"url"` - // A link to a static copy of the custom emoji. - StaticURL string `json:"static_url"` - // Whether this Emoji should be visible in the picker or unlisted. - VisibleInPicker bool `json:"visible_in_picker"` - - // OPTIONAL - - // Used for sorting custom emoji in the picker. - Category string `json:"category,omitempty"` -} diff --git a/pkg/mastotypes/source.go b/pkg/mastotypes/source.go @@ -1,41 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package mastotypes - -// Source represents display or publishing preferences of user's own account. -// Returned as an additional entity when verifying and updated credentials, as an attribute of Account. -// See https://docs.joinmastodon.org/entities/source/ -type Source struct { - // The default post privacy to be used for new statuses. - // public = Public post - // unlisted = Unlisted post - // private = Followers-only post - // direct = Direct post - Privacy string `json:"privacy,omitempty"` - // Whether new statuses should be marked sensitive by default. - Sensitive bool `json:"sensitive,omitempty"` - // The default posting language for new statuses. - Language string `json:"language,omitempty"` - // Profile bio. - Note string `json:"note"` - // Metadata about the account. - Fields []Field `json:"fields"` - // The number of pending follow requests. - FollowRequestsCount int `json:"follow_requests_count,omitempty"` -} diff --git a/pkg/mastotypes/status.go b/pkg/mastotypes/status.go @@ -1,110 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package mastotypes - -// StatusRequest represents a mastodon-api status POST request, as defined here: https://docs.joinmastodon.org/methods/statuses/ -// It should be used at the path https://mastodon.example/api/v1/statuses -type StatusRequest struct { - // Text content of the status. If media_ids is provided, this becomes optional. Attaching a poll is optional while status is provided. - Status string `form:"status"` - // Array of Attachment ids to be attached as media. If provided, status becomes optional, and poll cannot be used. - MediaIDs []string `form:"media_ids"` - // Poll to include with this status. - Poll *PollRequest `form:"poll"` - // ID of the status being replied to, if status is a reply - InReplyToID string `form:"in_reply_to_id"` - // Mark status and attached media as sensitive? - Sensitive bool `form:"sensitive"` - // Text to be shown as a warning or subject before the actual content. Statuses are generally collapsed behind this field. - SpoilerText string `form:"spoiler_text"` - // Visibility of the posted status. Enumerable oneOf public, unlisted, private, direct. - Visibility string `form:"visibility"` - // ISO 8601 Datetime at which to schedule a status. Providing this paramter will cause ScheduledStatus to be returned instead of Status. Must be at least 5 minutes in the future. - ScheduledAt string `form:"scheduled_at"` - // ISO 639 language code for this status. - Language string `form:"language"` -} - -// Status represents a mastodon-api Status type, as defined here: https://docs.joinmastodon.org/entities/status/ -type Status struct { - // ID of the status in the database. - ID string `json:"id"` - // The date when this status was created (ISO 8601 Datetime) - CreatedAt string `json:"created_at"` - // ID of the status being replied. - InReplyToID string `json:"in_reply_to_id"` - // ID of the account being replied to. - InReplyToAccountID string `json:"in_reply_to_account_id"` - // Is this status marked as sensitive content? - Sensitive bool `json:"sensitive"` - // Subject or summary line, below which status content is collapsed until expanded. - SpoilerText string `json:"spoiler_text"` - // Visibility of this status. - // public = Visible to everyone, shown in public timelines. - // unlisted = Visible to public, but not included in public timelines. - // private = Visible to followers only, and to any mentioned users. - // direct = Visible only to mentioned users. - Visibility string `json:"visibility"` - // Primary language of this status. (ISO 639 Part 1 two-letter language code) - Language string `json:"language"` - // URI of the status used for federation. - URI string `json:"uri"` - // A link to the status's HTML representation. - URL string `json:"url"` - // How many replies this status has received. - RepliesCount int `json:"replies_count"` - // How many boosts this status has received. - ReblogsCount int `json:"reblogs_count"` - // How many favourites this status has received. - FavouritesCount int `json:"favourites_count"` - // Have you favourited this status? - Favourited bool `json:"favourited"` - // Have you boosted this status? - Reblogged bool `json:"reblogged"` - // Have you muted notifications for this status's conversation? - Muted bool `json:"muted"` - // Have you bookmarked this status? - Bookmarked bool `json:"bookmarked"` - // Have you pinned this status? Only appears if the status is pinnable. - Pinned bool `json:"pinned"` - // HTML-encoded status content. - Content string `json:"content"` - // The status being reblogged. - Reblog *Status `json:"reblog"` - // The application used to post this status. - Application *Application `json:"application"` - // The account that authored this status. - Account *Account `json:"account"` - // Media that is attached to this status. - MediaAttachments []Attachment `json:"media_attachments"` - // Mentions of users within the status content. - Mentions []Mention `json:"mentions"` - // Hashtags used within the status content. - Tags []Tag `json:"tags"` - // Custom emoji to be used when rendering status content. - Emojis []Emoji `json:"emojis"` - // Preview card for links included within status content. - Card *Card `json:"card"` - // The poll attached to the status. - Poll *Poll `json:"poll"` - // Plain-text source of a status. Returned instead of content when status is deleted, - // so the user may redraft from the source text without the client having to reverse-engineer - // the original text from the HTML content. - Text string `json:"text"` -} diff --git a/pkg/mastotypes/tag.go b/pkg/mastotypes/tag.go @@ -1,23 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package mastotypes - -// Tag represents a hashtag used within the content of a status. See https://docs.joinmastodon.org/entities/tag/ -type Tag struct { -} diff --git a/scripts/auth_flow.sh b/scripts/auth_flow.sh @@ -5,10 +5,9 @@ set -eux SERVER_URL="http://localhost:8080" REDIRECT_URI="${SERVER_URL}" CLIENT_NAME="Test Application Name" - REGISTRATION_REASON="Testing whether or not this dang diggity thing works!" -REGISTRATION_EMAIL="test@example.org" -REGISTRATION_USERNAME="test_user" +REGISTRATION_USERNAME="${1}" +REGISTRATION_EMAIL="${2}" REGISTRATION_PASSWORD="very safe password 123" REGISTRATION_AGREEMENT="true" REGISTRATION_LOCALE="en" diff --git a/testrig/actions.go b/testrig/actions.go @@ -0,0 +1,125 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package testrig + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/action" + "github.com/superseriousbusiness/gotosocial/internal/apimodule" + "github.com/superseriousbusiness/gotosocial/internal/apimodule/account" + "github.com/superseriousbusiness/gotosocial/internal/apimodule/admin" + "github.com/superseriousbusiness/gotosocial/internal/apimodule/app" + "github.com/superseriousbusiness/gotosocial/internal/apimodule/auth" + "github.com/superseriousbusiness/gotosocial/internal/apimodule/fileserver" + mediaModule "github.com/superseriousbusiness/gotosocial/internal/apimodule/media" + "github.com/superseriousbusiness/gotosocial/internal/apimodule/security" + "github.com/superseriousbusiness/gotosocial/internal/apimodule/status" + "github.com/superseriousbusiness/gotosocial/internal/cache" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/gotosocial" +) + +// Run creates and starts a gotosocial testrig server +var Run action.GTSAction = func(ctx context.Context, _ *config.Config, log *logrus.Logger) error { + dbService := NewTestDB() + router := NewTestRouter() + storageBackend := NewTestStorage() + mediaHandler := NewTestMediaHandler(dbService, storageBackend) + oauthServer := NewTestOauthServer(dbService) + distributor := NewTestDistributor() + if err := distributor.Start(); err != nil { + return fmt.Errorf("error starting distributor: %s", err) + } + mastoConverter := NewTestMastoConverter(dbService) + + c := NewTestConfig() + + StandardDBSetup(dbService) + StandardStorageSetup(storageBackend, "./testrig/media") + + // build client api modules + authModule := auth.New(oauthServer, dbService, log) + accountModule := account.New(c, dbService, oauthServer, mediaHandler, mastoConverter, log) + appsModule := app.New(oauthServer, dbService, mastoConverter, log) + mm := mediaModule.New(dbService, mediaHandler, mastoConverter, c, log) + fileServerModule := fileserver.New(c, dbService, storageBackend, log) + adminModule := admin.New(c, dbService, mediaHandler, mastoConverter, log) + statusModule := status.New(c, dbService, mediaHandler, mastoConverter, distributor, log) + securityModule := security.New(c, log) + + apiModules := []apimodule.ClientAPIModule{ + // modules with middleware go first + securityModule, + authModule, + + // now everything else + accountModule, + appsModule, + mm, + fileServerModule, + adminModule, + statusModule, + } + + for _, m := range apiModules { + if err := m.Route(router); err != nil { + return fmt.Errorf("routing error: %s", err) + } + if err := m.CreateTables(dbService); err != nil { + return fmt.Errorf("table creation error: %s", err) + } + } + + // if err := dbService.CreateInstanceAccount(); err != nil { + // return fmt.Errorf("error creating instance account: %s", err) + // } + + gts, err := gotosocial.New(dbService, &cache.MockCache{}, router, federation.New(dbService, log), c) + if err != nil { + return fmt.Errorf("error creating gotosocial service: %s", err) + } + + if err := gts.Start(ctx); err != nil { + return fmt.Errorf("error starting gotosocial service: %s", err) + } + + // catch shutdown signals from the operating system + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, os.Interrupt, syscall.SIGTERM) + sig := <-sigs + log.Infof("received signal %s, shutting down", sig) + + StandardDBTeardown(dbService) + StandardStorageTeardown(storageBackend) + + // close down all running services in order + if err := gts.Stop(ctx); err != nil { + return fmt.Errorf("error closing gotosocial service: %s", err) + } + + log.Info("done! exiting...") + return nil +} diff --git a/testrig/config.go b/testrig/config.go @@ -0,0 +1,26 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package testrig + +import "github.com/superseriousbusiness/gotosocial/internal/config" + +// NewTestConfig returns a config initialized with test defaults +func NewTestConfig() *config.Config { + return config.TestDefault() +} diff --git a/testrig/db.go b/testrig/db.go @@ -0,0 +1,144 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package testrig + +import ( + "context" + + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +var testModels []interface{} = []interface{}{ + &gtsmodel.Account{}, + &gtsmodel.Application{}, + &gtsmodel.Block{}, + &gtsmodel.DomainBlock{}, + &gtsmodel.EmailDomainBlock{}, + &gtsmodel.Follow{}, + &gtsmodel.FollowRequest{}, + &gtsmodel.MediaAttachment{}, + &gtsmodel.Mention{}, + &gtsmodel.Status{}, + &gtsmodel.StatusFave{}, + &gtsmodel.StatusBookmark{}, + &gtsmodel.StatusMute{}, + &gtsmodel.StatusPin{}, + &gtsmodel.Tag{}, + &gtsmodel.User{}, + &gtsmodel.Emoji{}, + &oauth.Token{}, + &oauth.Client{}, +} + +// NewTestDB returns a new initialized, empty database for testing +func NewTestDB() db.DB { + config := NewTestConfig() + l := logrus.New() + l.SetLevel(logrus.TraceLevel) + testDB, err := db.New(context.Background(), config, l) + if err != nil { + panic(err) + } + return testDB +} + +// StandardDBSetup populates a given db with all the necessary tables/models for perfoming tests. +func StandardDBSetup(db db.DB) { + for _, m := range testModels { + if err := db.CreateTable(m); err != nil { + panic(err) + } + } + + for _, v := range NewTestTokens() { + if err := db.Put(v); err != nil { + panic(err) + } + } + + for _, v := range NewTestClients() { + if err := db.Put(v); err != nil { + panic(err) + } + } + + for _, v := range NewTestApplications() { + if err := db.Put(v); err != nil { + panic(err) + } + } + + for _, v := range NewTestUsers() { + if err := db.Put(v); err != nil { + panic(err) + } + } + + for _, v := range NewTestAccounts() { + if err := db.Put(v); err != nil { + panic(err) + } + } + + for _, v := range NewTestAttachments() { + if err := db.Put(v); err != nil { + panic(err) + } + } + + for _, v := range NewTestStatuses() { + if err := db.Put(v); err != nil { + panic(err) + } + } + + for _, v := range NewTestEmojis() { + if err := db.Put(v); err != nil { + panic(err) + } + } + + for _, v := range NewTestTags() { + if err := db.Put(v); err != nil { + panic(err) + } + } + + for _, v := range NewTestFaves() { + if err := db.Put(v); err != nil { + panic(err) + } + } + + if err := db.CreateInstanceAccount(); err != nil { + panic(err) + } +} + +// StandardDBTeardown drops all the standard testing tables/models from the database to ensure it's clean for the next test. +func StandardDBTeardown(db db.DB) { + for _, m := range testModels { + if err := db.DropTable(m); err != nil { + panic(err) + } + } +} diff --git a/testrig/distributor.go b/testrig/distributor.go @@ -0,0 +1,25 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package testrig + +import "github.com/superseriousbusiness/gotosocial/internal/distributor" + +func NewTestDistributor() distributor.Distributor { + return distributor.New(NewTestLog()) +} diff --git a/testrig/log.go b/testrig/log.go @@ -0,0 +1,28 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package testrig + +import "github.com/sirupsen/logrus" + +// NewTestLog returns a trace level logger for testing +func NewTestLog() *logrus.Logger { + log := logrus.New() + log.SetLevel(logrus.TraceLevel) + return log +} diff --git a/testrig/mastoconverter.go b/testrig/mastoconverter.go @@ -0,0 +1,29 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package testrig + +import ( + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/mastotypes" +) + +// NewTestMastoConverter returned a mastotypes converter with the given db and the default test config +func NewTestMastoConverter(db db.DB) mastotypes.Converter { + return mastotypes.New(NewTestConfig(), db) +} diff --git a/testrig/media/ohyou-original.jpeg b/testrig/media/ohyou-original.jpeg Binary files differ. diff --git a/testrig/media/ohyou-small.jpeg b/testrig/media/ohyou-small.jpeg Binary files differ. diff --git a/testrig/media/rainbow-original.png b/testrig/media/rainbow-original.png Binary files differ. diff --git a/testrig/media/rainbow-static.png b/testrig/media/rainbow-static.png Binary files differ. diff --git a/testrig/media/trent-original.gif b/testrig/media/trent-original.gif Binary files differ. diff --git a/testrig/media/trent-small.jpeg b/testrig/media/trent-small.jpeg Binary files differ. diff --git a/testrig/media/welcome-original.jpeg b/testrig/media/welcome-original.jpeg Binary files differ. diff --git a/testrig/media/welcome-small.jpeg b/testrig/media/welcome-small.jpeg Binary files differ. diff --git a/testrig/media/zork-original.jpeg b/testrig/media/zork-original.jpeg Binary files differ. diff --git a/testrig/media/zork-small.jpeg b/testrig/media/zork-small.jpeg Binary files differ. diff --git a/testrig/mediahandler.go b/testrig/mediahandler.go @@ -0,0 +1,31 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package testrig + +import ( + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/storage" +) + +// NewTestMediaHandler returns a media handler with the default test config, the default test logger, +// and the given db and storage. +func NewTestMediaHandler(db db.DB, storage storage.Storage) media.MediaHandler { + return media.New(NewTestConfig(), db, storage, NewTestLog()) +} diff --git a/testrig/oauthserver.go b/testrig/oauthserver.go @@ -0,0 +1,29 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package testrig + +import ( + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// NewTestOauthServer returns an oauth server with the given db, and the default test logger. +func NewTestOauthServer(db db.DB) oauth.Server { + return oauth.New(db, NewTestLog()) +} diff --git a/testrig/router.go b/testrig/router.go @@ -0,0 +1,29 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package testrig + +import "github.com/superseriousbusiness/gotosocial/internal/router" + +func NewTestRouter() router.Router { + r, err := router.New(NewTestConfig(), NewTestLog()) + if err != nil { + panic(err) + } + return r +} diff --git a/testrig/storage.go b/testrig/storage.go @@ -0,0 +1,105 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package testrig + +import ( + "fmt" + "os" + + "github.com/superseriousbusiness/gotosocial/internal/storage" +) + +// NewTestStorage returns a new in memory storage with the default test config +func NewTestStorage() storage.Storage { + s, err := storage.NewInMem(NewTestConfig(), NewTestLog()) + if err != nil { + panic(err) + } + return s +} + +// StandardStorageSetup populates the storage with standard test entries from the given directory. +func StandardStorageSetup(s storage.Storage, relativePath string) { + storedA := NewTestStoredAttachments() + a := NewTestAttachments() + for k, paths := range storedA { + attachmentInfo, ok := a[k] + if !ok { + panic(fmt.Errorf("key %s not found in test attachments", k)) + } + filenameOriginal := paths.original + filenameSmall := paths.small + pathOriginal := attachmentInfo.File.Path + pathSmall := attachmentInfo.Thumbnail.Path + bOriginal, err := os.ReadFile(fmt.Sprintf("%s/%s", relativePath, filenameOriginal)) + if err != nil { + panic(err) + } + if err := s.StoreFileAt(pathOriginal, bOriginal); err != nil { + panic(err) + } + bSmall, err := os.ReadFile(fmt.Sprintf("%s/%s", relativePath, filenameSmall)) + if err != nil { + panic(err) + } + if err := s.StoreFileAt(pathSmall, bSmall); err != nil { + panic(err) + } + } + + storedE := NewTestStoredEmoji() + e := NewTestEmojis() + for k, paths := range storedE { + emojiInfo, ok := e[k] + if !ok { + panic(fmt.Errorf("key %s not found in test emojis", k)) + } + filenameOriginal := paths.original + filenameStatic := paths.static + pathOriginal := emojiInfo.ImagePath + pathStatic := emojiInfo.ImageStaticPath + bOriginal, err := os.ReadFile(fmt.Sprintf("%s/%s", relativePath, filenameOriginal)) + if err != nil { + panic(err) + } + if err := s.StoreFileAt(pathOriginal, bOriginal); err != nil { + panic(err) + } + bStatic, err := os.ReadFile(fmt.Sprintf("%s/%s", relativePath, filenameStatic)) + if err != nil { + panic(err) + } + if err := s.StoreFileAt(pathStatic, bStatic); err != nil { + panic(err) + } + } +} + +// StandardStorageTeardown deletes everything in storage so that it's clean for the next test +func StandardStorageTeardown(s storage.Storage) { + keys, err := s.ListKeys() + if err != nil { + panic(err) + } + for _, k := range keys { + if err := s.RemoveFileAt(k); err != nil { + panic(err) + } + } +} diff --git a/testrig/testmodels.go b/testrig/testmodels.go @@ -0,0 +1,995 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package testrig + +import ( + "crypto/rand" + "crypto/rsa" + "net" + "time" + + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// NewTestTokens returns a map of tokens keyed according to which account the token belongs to. +func NewTestTokens() map[string]*oauth.Token { + tokens := map[string]*oauth.Token{ + "local_account_1": { + ID: "64cf4214-33ab-4220-b5ca-4a6a12263b20", + ClientID: "73b48d42-029d-4487-80fc-329a5cf67869", + UserID: "44e36b79-44a4-4bd8-91e9-097f477fe97b", + RedirectURI: "http://localhost:8080", + Scope: "read write follow push", + Access: "NZAZOTC0OWITMDU0NC0ZODG4LWE4NJITMWUXM2M4MTRHZDEX", + AccessCreateAt: time.Now(), + AccessExpiresAt: time.Now().Add(72 * time.Hour), + }, + "local_account_2": { + ID: "b04cae99-39b5-4610-a425-dc6b91c78a72", + ClientID: "a4f6a2ea-a32b-4600-8853-72fc4ad98a1f", + UserID: "d120bd97-866f-4a05-9690-a1294b9934c3", + RedirectURI: "http://localhost:8080", + Scope: "read write follow push", + Access: "PIPINALKNNNFNF98717NAMNAMNFKIJKJ881818KJKJAKJJJA", + AccessCreateAt: time.Now(), + AccessExpiresAt: time.Now().Add(72 * time.Hour), + }, + } + return tokens +} + +// NewTestClients returns a map of Clients keyed according to which account they are used by. +func NewTestClients() map[string]*oauth.Client { + clients := map[string]*oauth.Client{ + "admin_account": { + ID: "1c5cefc8-f0c9-4307-8506-ca6e3888675e", + Secret: "dda8e835-2c9c-4bd2-9b8b-77c2e26d7a7a", + Domain: "http://localhost:8080", + UserID: "0fb02eae-2214-473f-9667-0a43f22d75ff", // admin_account + }, + "local_account_1": { + ID: "73b48d42-029d-4487-80fc-329a5cf67869", + Secret: "c3724c74-dc3b-41b2-a108-0ea3d8399830", + Domain: "http://localhost:8080", + UserID: "44e36b79-44a4-4bd8-91e9-097f477fe97b", // local_account_1 + }, + "local_account_2": { + ID: "a4f6a2ea-a32b-4600-8853-72fc4ad98a1f", + Secret: "8f5603a5-c721-46cd-8f1b-2e368f51379f", + Domain: "http://localhost:8080", + UserID: "d120bd97-866f-4a05-9690-a1294b9934c3", // local_account_2 + }, + } + return clients +} + +// NewTestApplications returns a map of applications keyed to which number application they are. +func NewTestApplications() map[string]*gtsmodel.Application { + apps := map[string]*gtsmodel.Application{ + "admin_account": { + ID: "9bf9e368-037f-444d-8ffd-1091d1c21c4c", + Name: "superseriousbusiness", + Website: "https://superserious.business", + RedirectURI: "http://localhost:8080", + ClientID: "1c5cefc8-f0c9-4307-8506-ca6e3888675e", // admin client + ClientSecret: "dda8e835-2c9c-4bd2-9b8b-77c2e26d7a7a", // admin client + Scopes: "read write follow push", + VapidKey: "76ae0095-8a10-438f-9f49-522d1985b190", + }, + "application_1": { + ID: "f88697b8-ee3d-46c2-ac3f-dbb85566c3cc", + Name: "really cool gts application", + Website: "https://reallycool.app", + RedirectURI: "http://localhost:8080", + ClientID: "73b48d42-029d-4487-80fc-329a5cf67869", // client_1 + ClientSecret: "c3724c74-dc3b-41b2-a108-0ea3d8399830", // client_1 + Scopes: "read write follow push", + VapidKey: "4738dfd7-ca73-4aa6-9aa9-80e946b7db36", + }, + "application_2": { + ID: "6b0cd164-8497-4cd5-bec9-957886fac5df", + Name: "kindaweird", + Website: "https://kindaweird.app", + RedirectURI: "http://localhost:8080", + ClientID: "a4f6a2ea-a32b-4600-8853-72fc4ad98a1f", // client_2 + ClientSecret: "8f5603a5-c721-46cd-8f1b-2e368f51379f", // client_2 + Scopes: "read write follow push", + VapidKey: "c040a5fc-e1e2-4859-bbea-0a3efbca1c4b", + }, + } + return apps +} + +// NewTestUsers returns a map of Users keyed by which account belongs to them. +func NewTestUsers() map[string]*gtsmodel.User { + users := map[string]*gtsmodel.User{ + "unconfirmed_account": { + ID: "0f7b1d24-1e49-4ee0-bc7e-fd87b7289eea", + Email: "", + AccountID: "59e197f5-87cd-4be8-ac7c-09082ccc4b4d", + EncryptedPassword: "$2y$10$ggWz5QWwnx6kzb9g0tnIJurFtE0dhr5Zfeaqs9iFuUIXzafQlJVZS", // 'password' + CreatedAt: time.Now(), + SignUpIP: net.ParseIP("199.222.111.89"), + UpdatedAt: time.Time{}, + CurrentSignInAt: time.Time{}, + CurrentSignInIP: nil, + LastSignInAt: time.Time{}, + LastSignInIP: nil, + SignInCount: 0, + InviteID: "", + ChosenLanguages: []string{}, + FilteredLanguages: []string{}, + Locale: "en", + CreatedByApplicationID: "", + LastEmailedAt: time.Time{}, + ConfirmationToken: "a5a280bd-34be-44a3-8330-a57eaf61b8dd", + ConfirmedAt: time.Time{}, + ConfirmationSentAt: time.Now(), + UnconfirmedEmail: "weed_lord420@example.org", + Moderator: false, + Admin: false, + Disabled: false, + Approved: false, + ResetPasswordToken: "", + ResetPasswordSentAt: time.Time{}, + }, + "admin_account": { + ID: "0fb02eae-2214-473f-9667-0a43f22d75ff", + Email: "admin@example.org", + AccountID: "8020dbb4-1e7b-4d99-a872-4cf94e64210f", + EncryptedPassword: "$2y$10$ggWz5QWwnx6kzb9g0tnIJurFtE0dhr5Zfeaqs9iFuUIXzafQlJVZS", // 'password' + CreatedAt: time.Now().Add(-72 * time.Hour), + SignUpIP: net.ParseIP("89.22.189.19"), + UpdatedAt: time.Now().Add(-72 * time.Hour), + CurrentSignInAt: time.Now().Add(-10 * time.Minute), + CurrentSignInIP: net.ParseIP("89.122.255.1"), + LastSignInAt: time.Now().Add(-2 * time.Hour), + LastSignInIP: net.ParseIP("89.122.255.1"), + SignInCount: 78, + InviteID: "", + ChosenLanguages: []string{"en"}, + FilteredLanguages: []string{}, + Locale: "en", + CreatedByApplicationID: "", + LastEmailedAt: time.Now().Add(-30 * time.Minute), + ConfirmationToken: "", + ConfirmedAt: time.Now().Add(-72 * time.Hour), + ConfirmationSentAt: time.Time{}, + UnconfirmedEmail: "", + Moderator: true, + Admin: true, + Disabled: false, + Approved: true, + ResetPasswordToken: "", + ResetPasswordSentAt: time.Time{}, + }, + "local_account_1": { + ID: "44e36b79-44a4-4bd8-91e9-097f477fe97b", + Email: "zork@example.org", + AccountID: "580072df-4d03-4684-a412-89fd6f7d77e6", + EncryptedPassword: "$2y$10$ggWz5QWwnx6kzb9g0tnIJurFtE0dhr5Zfeaqs9iFuUIXzafQlJVZS", // 'password' + CreatedAt: time.Now().Add(-36 * time.Hour), + SignUpIP: net.ParseIP("59.99.19.172"), + UpdatedAt: time.Now().Add(-72 * time.Hour), + CurrentSignInAt: time.Now().Add(-30 * time.Minute), + CurrentSignInIP: net.ParseIP("88.234.118.16"), + LastSignInAt: time.Now().Add(-2 * time.Hour), + LastSignInIP: net.ParseIP("147.111.231.154"), + SignInCount: 9, + InviteID: "", + ChosenLanguages: []string{"en"}, + FilteredLanguages: []string{}, + Locale: "en", + CreatedByApplicationID: "f88697b8-ee3d-46c2-ac3f-dbb85566c3cc", + LastEmailedAt: time.Now().Add(-55 * time.Minute), + ConfirmationToken: "", + ConfirmedAt: time.Now().Add(-34 * time.Hour), + ConfirmationSentAt: time.Now().Add(-36 * time.Hour), + UnconfirmedEmail: "", + Moderator: false, + Admin: false, + Disabled: false, + Approved: true, + ResetPasswordToken: "", + ResetPasswordSentAt: time.Time{}, + }, + "local_account_2": { + ID: "f8d6272e-2d71-4d0c-97d3-2ba7a0b75bf7", + Email: "tortle.dude@example.org", + AccountID: "eecaad73-5703-426d-9312-276641daa31e", + EncryptedPassword: "$2y$10$ggWz5QWwnx6kzb9g0tnIJurFtE0dhr5Zfeaqs9iFuUIXzafQlJVZS", // 'password' + CreatedAt: time.Now().Add(-36 * time.Hour), + SignUpIP: net.ParseIP("59.99.19.172"), + UpdatedAt: time.Now().Add(-72 * time.Hour), + CurrentSignInAt: time.Now().Add(-30 * time.Minute), + CurrentSignInIP: net.ParseIP("118.44.18.196"), + LastSignInAt: time.Now().Add(-2 * time.Hour), + LastSignInIP: net.ParseIP("198.98.21.15"), + SignInCount: 9, + InviteID: "", + ChosenLanguages: []string{"en"}, + FilteredLanguages: []string{}, + Locale: "en", + CreatedByApplicationID: "", + LastEmailedAt: time.Now().Add(-55 * time.Minute), + ConfirmationToken: "", + ConfirmedAt: time.Now().Add(-34 * time.Hour), + ConfirmationSentAt: time.Now().Add(-36 * time.Hour), + UnconfirmedEmail: "", + Moderator: false, + Admin: false, + Disabled: false, + Approved: true, + ResetPasswordToken: "", + ResetPasswordSentAt: time.Time{}, + }, + } + + return users +} + +// NewTestAccounts returns a map of accounts keyed by what type of account they are. +func NewTestAccounts() map[string]*gtsmodel.Account { + accounts := map[string]*gtsmodel.Account{ + "instance_account": { + ID: "39b745a3-774d-4b65-8bb2-b63d9e20a343", + Username: "localhost:8080", + }, + "unconfirmed_account": { + ID: "59e197f5-87cd-4be8-ac7c-09082ccc4b4d", + Username: "weed_lord420", + AvatarMediaAttachmentID: "", + HeaderMediaAttachmentID: "", + DisplayName: "", + Fields: []gtsmodel.Field{}, + Note: "", + Memorial: false, + MovedToAccountID: "", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Bot: false, + Reason: "hi, please let me in! I'm looking for somewhere neato bombeato to hang out.", + Locked: false, + Discoverable: false, + Privacy: gtsmodel.VisibilityPublic, + Sensitive: false, + Language: "en", + URI: "http://localhost:8080/users/weed_lord420", + URL: "http://localhost:8080/@weed_lord420", + LastWebfingeredAt: time.Time{}, + InboxURL: "http://localhost:8080/users/weed_lord420/inbox", + OutboxURL: "http://localhost:8080/users/weed_lord420/outbox", + SharedInboxURL: "", + FollowersURL: "http://localhost:8080/users/weed_lord420/followers", + FeaturedCollectionURL: "http://localhost:8080/users/weed_lord420/collections/featured", + ActorType: gtsmodel.ActivityStreamsPerson, + AlsoKnownAs: "", + PrivateKey: &rsa.PrivateKey{}, + PublicKey: &rsa.PublicKey{}, + SensitizedAt: time.Time{}, + SilencedAt: time.Time{}, + SuspendedAt: time.Time{}, + HideCollections: false, + SuspensionOrigin: "", + }, + "admin_account": { + ID: "8020dbb4-1e7b-4d99-a872-4cf94e64210f", + Username: "admin", + AvatarMediaAttachmentID: "", + HeaderMediaAttachmentID: "", + DisplayName: "", + Fields: []gtsmodel.Field{}, + Note: "", + Memorial: false, + MovedToAccountID: "", + CreatedAt: time.Now().Add(-72 * time.Hour), + UpdatedAt: time.Now().Add(-72 * time.Hour), + Bot: false, + Reason: "", + Locked: false, + Discoverable: true, + Privacy: gtsmodel.VisibilityPublic, + Sensitive: false, + Language: "en", + URI: "http://localhost:8080/users/admin", + URL: "http://localhost:8080/@admin", + LastWebfingeredAt: time.Time{}, + InboxURL: "http://localhost:8080/users/admin/inbox", + OutboxURL: "http://localhost:8080/users/admin/outbox", + SharedInboxURL: "", + FollowersURL: "http://localhost:8080/users/admin/followers", + FeaturedCollectionURL: "http://localhost:8080/users/admin/collections/featured", + ActorType: gtsmodel.ActivityStreamsPerson, + AlsoKnownAs: "", + PrivateKey: &rsa.PrivateKey{}, + PublicKey: &rsa.PublicKey{}, + SensitizedAt: time.Time{}, + SilencedAt: time.Time{}, + SuspendedAt: time.Time{}, + HideCollections: false, + SuspensionOrigin: "", + }, + "local_account_1": { + ID: "580072df-4d03-4684-a412-89fd6f7d77e6", + Username: "the_mighty_zork", + AvatarMediaAttachmentID: "a849906f-8b8e-4b43-ac2f-6979ccbcd442", + HeaderMediaAttachmentID: "", + DisplayName: "original zork (he/they)", + Fields: []gtsmodel.Field{}, + Note: "hey yo this is my profile!", + Memorial: false, + MovedToAccountID: "", + CreatedAt: time.Now().Add(-48 * time.Hour), + UpdatedAt: time.Now().Add(-48 * time.Hour), + Bot: false, + Reason: "I wanna be on this damned webbed site so bad! Please! Wow", + Locked: false, + Discoverable: true, + Privacy: gtsmodel.VisibilityPublic, + Sensitive: false, + Language: "en", + URI: "http://localhost:8080/users/the_mighty_zork", + URL: "http://localhost:8080/@the_mighty_zork", + LastWebfingeredAt: time.Time{}, + InboxURL: "http://localhost:8080/users/the_mighty_zork/inbox", + OutboxURL: "http://localhost:8080/users/the_mighty_zork/outbox", + SharedInboxURL: "", + FollowersURL: "http://localhost:8080/users/the_mighty_zork/followers", + FeaturedCollectionURL: "http://localhost:8080/users/the_mighty_zork/collections/featured", + ActorType: gtsmodel.ActivityStreamsPerson, + AlsoKnownAs: "", + PrivateKey: &rsa.PrivateKey{}, + PublicKey: &rsa.PublicKey{}, + SensitizedAt: time.Time{}, + SilencedAt: time.Time{}, + SuspendedAt: time.Time{}, + HideCollections: false, + SuspensionOrigin: "", + }, + "local_account_2": { + ID: "eecaad73-5703-426d-9312-276641daa31e", + Username: "1happyturtle", + AvatarMediaAttachmentID: "", + HeaderMediaAttachmentID: "", + DisplayName: "happy little turtle :3", + Fields: []gtsmodel.Field{}, + Note: "i post about things that concern me", + Memorial: false, + MovedToAccountID: "", + CreatedAt: time.Now().Add(-190 * time.Hour), + UpdatedAt: time.Now().Add(-36 * time.Hour), + Bot: false, + Reason: "", + Locked: true, + Discoverable: false, + Privacy: gtsmodel.VisibilityFollowersOnly, + Sensitive: false, + Language: "en", + URI: "http://localhost:8080/users/1happyturtle", + URL: "http://localhost:8080/@1happyturtle", + LastWebfingeredAt: time.Time{}, + InboxURL: "http://localhost:8080/users/1happyturtle/inbox", + OutboxURL: "http://localhost:8080/users/1happyturtle/outbox", + SharedInboxURL: "", + FollowersURL: "http://localhost:8080/users/1happyturtle/followers", + FeaturedCollectionURL: "http://localhost:8080/users/1happyturtle/collections/featured", + ActorType: gtsmodel.ActivityStreamsPerson, + AlsoKnownAs: "", + PrivateKey: &rsa.PrivateKey{}, + PublicKey: &rsa.PublicKey{}, + SensitizedAt: time.Time{}, + SilencedAt: time.Time{}, + SuspendedAt: time.Time{}, + HideCollections: false, + SuspensionOrigin: "", + }, + "remote_account_1": { + ID: "c2c6e647-e2a9-4286-883b-e4a188186664", + Username: "foss_satan", + Domain: "fossbros-anonymous.io", + // AvatarFileName: "http://localhost:8080/fileserver/media/eecaad73-5703-426d-9312-276641daa31e/avatar/original/d5e7c265-91a6-4d84-8c27-7e1efe5720da.jpeg", + // AvatarContentType: "image/jpeg", + // AvatarFileSize: 0, + // AvatarUpdatedAt: time.Time{}, + // AvatarRemoteURL: "", + // HeaderFileName: "http://localhost:8080/fileserver/media/eecaad73-5703-426d-9312-276641daa31e/header/original/e75d4117-21b6-4315-a428-eb3944235996.jpeg", + // HeaderContentType: "image/jpeg", + // HeaderFileSize: 0, + // HeaderUpdatedAt: time.Time{}, + // HeaderRemoteURL: "", + DisplayName: "big gerald", + Fields: []gtsmodel.Field{}, + Note: "i post about like, i dunno, stuff, or whatever!!!!", + Memorial: false, + MovedToAccountID: "", + CreatedAt: time.Now().Add(-190 * time.Hour), + UpdatedAt: time.Now().Add(-36 * time.Hour), + Bot: false, + Locked: false, + Discoverable: true, + Sensitive: false, + Language: "en", + URI: "https://fossbros-anonymous.io/users/foss_satan", + URL: "https://fossbros-anonymous.io/@foss_satan", + LastWebfingeredAt: time.Time{}, + InboxURL: "https://fossbros-anonymous.io/users/foss_satan/inbox", + OutboxURL: "https://fossbros-anonymous.io/users/foss_satan/outbox", + SharedInboxURL: "", + FollowersURL: "https://fossbros-anonymous.io/users/foss_satan/followers", + FeaturedCollectionURL: "https://fossbros-anonymous.io/users/foss_satan/collections/featured", + ActorType: gtsmodel.ActivityStreamsPerson, + AlsoKnownAs: "", + PrivateKey: &rsa.PrivateKey{}, + PublicKey: nil, + SensitizedAt: time.Time{}, + SilencedAt: time.Time{}, + SuspendedAt: time.Time{}, + HideCollections: false, + SuspensionOrigin: "", + }, + // "remote_account_2": { + // ID: "93287988-76c4-460f-9e68-a45b578bb6b2", + // Username: "dailycatpics", + // Domain: "uwu.social", + // }, + // "suspended_local_account": { + // ID: "e8a5cf4e-4b10-45a4-ad82-b6e37a09100d", + // Username: "jeffbadman", + // }, + // "suspended_remote_account": { + // ID: "17e6e09e-855d-4bf8-a1c3-7e780269f215", + // Username: "ipfreely", + // Domain: "a-very-bad-website.com", + // }, + } + + // generate keys for each account + for _, v := range accounts { + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + panic(err) + } + pub := &priv.PublicKey + + // only local accounts get a private key + if v.Domain == "" { + v.PrivateKey = priv + } + v.PublicKey = pub + } + return accounts +} + +// NewTestAttachments returns a map of attachments keyed according to which account +// and status they belong to, and which attachment number of that status they are. +func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { + return map[string]*gtsmodel.MediaAttachment{ + "admin_account_status_1_attachment_1": { + ID: "b052241b-f30f-4dc6-92fc-2bad0be1f8d8", + StatusID: "502ccd6f-0edf-48d7-9016-2dfa4d3714cd", + URL: "http://localhost:8080/fileserver/8020dbb4-1e7b-4d99-a872-4cf94e64210f/attachment/original/b052241b-f30f-4dc6-92fc-2bad0be1f8d8.jpeg", + RemoteURL: "", + CreatedAt: time.Now().Add(-71 * time.Hour), + UpdatedAt: time.Now().Add(-71 * time.Hour), + Type: gtsmodel.FileTypeImage, + FileMeta: gtsmodel.FileMeta{ + Original: gtsmodel.Original{ + Width: 1200, + Height: 630, + Size: 756000, + Aspect: 1.9047619047619047, + }, + Small: gtsmodel.Small{ + Width: 256, + Height: 134, + Size: 34304, + Aspect: 1.9104477611940298, + }, + }, + AccountID: "8020dbb4-1e7b-4d99-a872-4cf94e64210f", + Description: "Black and white image of some 50's style text saying: Welcome On Board", + ScheduledStatusID: "", + Blurhash: "LNJRdVM{00Rj%Mayt7j[4nWBofRj", + Processing: 2, + File: gtsmodel.File{ + Path: "/gotosocial/storage/8020dbb4-1e7b-4d99-a872-4cf94e64210f/attachment/original/b052241b-f30f-4dc6-92fc-2bad0be1f8d8.jpeg", + ContentType: "image/jpeg", + FileSize: 62529, + UpdatedAt: time.Now().Add(-71 * time.Hour), + }, + Thumbnail: gtsmodel.Thumbnail{ + Path: "/gotosocial/storage/8020dbb4-1e7b-4d99-a872-4cf94e64210f/attachment/small/b052241b-f30f-4dc6-92fc-2bad0be1f8d8.jpeg", + ContentType: "image/jpeg", + FileSize: 6872, + UpdatedAt: time.Now().Add(-71 * time.Hour), + URL: "http://localhost:8080/fileserver/8020dbb4-1e7b-4d99-a872-4cf94e64210f/attachment/small/b052241b-f30f-4dc6-92fc-2bad0be1f8d8.jpeg", + RemoteURL: "", + }, + Avatar: false, + Header: false, + }, + "local_account_1_status_4_attachment_1": { + ID: "510f6033-798b-4390-81b1-c38ca2205ad3", + StatusID: "18524c05-97dc-46d7-b474-c811bd9e1e32", + URL: "http://localhost:8080/fileserver/580072df-4d03-4684-a412-89fd6f7d77e6/attachment/original/510f6033-798b-4390-81b1-c38ca2205ad3.gif", + RemoteURL: "", + CreatedAt: time.Now().Add(-1 * time.Hour), + UpdatedAt: time.Now().Add(-1 * time.Hour), + Type: gtsmodel.FileTypeGif, + FileMeta: gtsmodel.FileMeta{ + Original: gtsmodel.Original{ + Width: 400, + Height: 280, + Size: 756000, + Aspect: 1.4285714285714286, + }, + Small: gtsmodel.Small{ + Width: 256, + Height: 179, + Size: 45824, + Aspect: 1.4301675977653632, + }, + Focus: gtsmodel.Focus{ + X: 0, + Y: 0, + }, + }, + AccountID: "580072df-4d03-4684-a412-89fd6f7d77e6", + Description: "90's Trent Reznor turning to the camera", + ScheduledStatusID: "", + Blurhash: "LEDara58O=t5EMSOENEN9]}?aK%0", + Processing: 2, + File: gtsmodel.File{ + Path: "/gotosocial/storage/580072df-4d03-4684-a412-89fd6f7d77e6/attachment/original/510f6033-798b-4390-81b1-c38ca2205ad3.gif", + ContentType: "image/gif", + FileSize: 1109138, + UpdatedAt: time.Now().Add(-1 * time.Hour), + }, + Thumbnail: gtsmodel.Thumbnail{ + Path: "/gotosocial/storage/580072df-4d03-4684-a412-89fd6f7d77e6/attachment/small/510f6033-798b-4390-81b1-c38ca2205ad3.jpeg", + ContentType: "image/jpeg", + FileSize: 8803, + UpdatedAt: time.Now().Add(-1 * time.Hour), + URL: "http://localhost:8080/fileserver/580072df-4d03-4684-a412-89fd6f7d77e6/attachment/small/510f6033-798b-4390-81b1-c38ca2205ad3.jpeg", + RemoteURL: "", + }, + Avatar: false, + Header: false, + }, + "local_account_1_unattached_1": { + ID: "7a3b9f77-ab30-461e-bdd8-e64bd1db3008", + StatusID: "", // this attachment isn't connected to a status YET + URL: "http://localhost:8080/fileserver/580072df-4d03-4684-a412-89fd6f7d77e6/attachment/original/7a3b9f77-ab30-461e-bdd8-e64bd1db3008.jpeg", + RemoteURL: "", + CreatedAt: time.Now().Add(30 * time.Second), + UpdatedAt: time.Now().Add(30 * time.Second), + Type: gtsmodel.FileTypeGif, + FileMeta: gtsmodel.FileMeta{ + Original: gtsmodel.Original{ + Width: 800, + Height: 450, + Size: 360000, + Aspect: 1.7777777777777777, + }, + Small: gtsmodel.Small{ + Width: 256, + Height: 144, + Size: 36864, + Aspect: 1.7777777777777777, + }, + Focus: gtsmodel.Focus{ + X: 0, + Y: 0, + }, + }, + AccountID: "580072df-4d03-4684-a412-89fd6f7d77e6", + Description: "the oh you meme", + ScheduledStatusID: "", + Blurhash: "LSAd]9ogDge-R:M|j=xWIto0xXWX", + Processing: 2, + File: gtsmodel.File{ + Path: "/gotosocial/storage/580072df-4d03-4684-a412-89fd6f7d77e6/attachment/original/7a3b9f77-ab30-461e-bdd8-e64bd1db3008.jpeg", + ContentType: "image/jpeg", + FileSize: 27759, + UpdatedAt: time.Now().Add(30 * time.Second), + }, + Thumbnail: gtsmodel.Thumbnail{ + Path: "/gotosocial/storage/580072df-4d03-4684-a412-89fd6f7d77e6/attachment/small/7a3b9f77-ab30-461e-bdd8-e64bd1db3008.jpeg", + ContentType: "image/jpeg", + FileSize: 6177, + UpdatedAt: time.Now().Add(30 * time.Second), + URL: "http://localhost:8080/fileserver/580072df-4d03-4684-a412-89fd6f7d77e6/attachment/small/7a3b9f77-ab30-461e-bdd8-e64bd1db3008.jpeg", + RemoteURL: "", + }, + Avatar: false, + Header: false, + }, + "local_account_1_avatar": { + ID: "a849906f-8b8e-4b43-ac2f-6979ccbcd442", + StatusID: "", // this attachment isn't connected to a status + URL: "http://localhost:8080/fileserver/580072df-4d03-4684-a412-89fd6f7d77e6/avatar/original/a849906f-8b8e-4b43-ac2f-6979ccbcd442.jpeg", + RemoteURL: "", + CreatedAt: time.Now().Add(47 * time.Hour), + UpdatedAt: time.Now().Add(47 * time.Hour), + Type: gtsmodel.FileTypeImage, + FileMeta: gtsmodel.FileMeta{ + Original: gtsmodel.Original{ + Width: 1092, + Height: 1800, + Size: 1965600, + Aspect: 0.6066666666666667, + }, + Small: gtsmodel.Small{ + Width: 155, + Height: 256, + Size: 39680, + Aspect: 0.60546875, + }, + Focus: gtsmodel.Focus{ + X: 0, + Y: 0, + }, + }, + AccountID: "580072df-4d03-4684-a412-89fd6f7d77e6", + Description: "a green goblin looking nasty", + ScheduledStatusID: "", + Blurhash: "LKK9MT,p|YSNDkJ-5rsmvnwcOoe:", + Processing: 2, + File: gtsmodel.File{ + Path: "/gotosocial/storage/580072df-4d03-4684-a412-89fd6f7d77e6/avatar/original/a849906f-8b8e-4b43-ac2f-6979ccbcd442.jpeg", + ContentType: "image/jpeg", + FileSize: 457680, + UpdatedAt: time.Now().Add(47 * time.Hour), + }, + Thumbnail: gtsmodel.Thumbnail{ + Path: "/gotosocial/storage/580072df-4d03-4684-a412-89fd6f7d77e6/avatar/small/a849906f-8b8e-4b43-ac2f-6979ccbcd442.jpeg", + ContentType: "image/jpeg", + FileSize: 15374, + UpdatedAt: time.Now().Add(47 * time.Hour), + URL: "http://localhost:8080/fileserver/580072df-4d03-4684-a412-89fd6f7d77e6/avatar/small/a849906f-8b8e-4b43-ac2f-6979ccbcd442.jpeg", + RemoteURL: "", + }, + Avatar: true, + Header: false, + }, + } +} + +// NewTestEmojis returns a map of gts emojis, keyed by the emoji shortcode +func NewTestEmojis() map[string]*gtsmodel.Emoji { + return map[string]*gtsmodel.Emoji{ + "rainbow": { + ID: "a96ec4f3-1cae-47e4-a508-f9d66a6b221b", + Shortcode: "rainbow", + Domain: "", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + ImageRemoteURL: "", + ImageStaticRemoteURL: "", + ImageURL: "http://localhost:8080/fileserver/39b745a3-774d-4b65-8bb2-b63d9e20a343/emoji/original/a96ec4f3-1cae-47e4-a508-f9d66a6b221b.png", + ImagePath: "/tmp/gotosocial/39b745a3-774d-4b65-8bb2-b63d9e20a343/emoji/original/a96ec4f3-1cae-47e4-a508-f9d66a6b221b.png", + ImageStaticURL: "http://localhost:8080/fileserver/39b745a3-774d-4b65-8bb2-b63d9e20a343/emoji/static/a96ec4f3-1cae-47e4-a508-f9d66a6b221b.png", + ImageStaticPath: "/tmp/gotosocial/39b745a3-774d-4b65-8bb2-b63d9e20a343/emoji/static/a96ec4f3-1cae-47e4-a508-f9d66a6b221b.png", + ImageContentType: "image/png", + ImageFileSize: 36702, + ImageStaticFileSize: 10413, + ImageUpdatedAt: time.Now(), + Disabled: false, + URI: "http://localhost:8080/emoji/a96ec4f3-1cae-47e4-a508-f9d66a6b221b", + VisibleInPicker: true, + CategoryID: "", + }, + } +} + +type filenames struct { + original string + small string + static string +} + +// NewTestStoredAttachments returns a map of filenames, keyed according to which attachment they pertain to. +func NewTestStoredAttachments() map[string]filenames { + return map[string]filenames{ + "admin_account_status_1_attachment_1": { + original: "welcome-original.jpeg", + small: "welcome-small.jpeg", + }, + "local_account_1_status_4_attachment_1": { + original: "trent-original.gif", + small: "trent-small.jpeg", + }, + "local_account_1_unattached_1": { + original: "ohyou-original.jpeg", + small: "ohyou-small.jpeg", + }, + "local_account_1_avatar": { + original: "zork-original.jpeg", + small: "zork-small.jpeg", + }, + } +} + +// NewtestStoredEmoji returns a map of filenames, keyed according to which emoji they pertain to +func NewTestStoredEmoji() map[string]filenames { + return map[string]filenames{ + "rainbow": { + original: "rainbow-original.png", + static: "rainbow-static.png", + }, + } +} + +// NewTestStatuses returns a map of statuses keyed according to which account +// and status they are. +func NewTestStatuses() map[string]*gtsmodel.Status { + return map[string]*gtsmodel.Status{ + "admin_account_status_1": { + ID: "502ccd6f-0edf-48d7-9016-2dfa4d3714cd", + URI: "http://localhost:8080/users/admin/statuses/502ccd6f-0edf-48d7-9016-2dfa4d3714cd", + URL: "http://localhost:8080/@admin/statuses/502ccd6f-0edf-48d7-9016-2dfa4d3714cd", + Content: "hello world! #welcome ! first post on the instance :rainbow: !", + Attachments: []string{"b052241b-f30f-4dc6-92fc-2bad0be1f8d8"}, + Tags: []string{"a7e8f5ca-88a1-4652-8079-a187eab8d56e"}, + Mentions: []string{}, + Emojis: []string{"a96ec4f3-1cae-47e4-a508-f9d66a6b221b"}, + CreatedAt: time.Now().Add(-71 * time.Hour), + UpdatedAt: time.Now().Add(-71 * time.Hour), + Local: true, + AccountID: "8020dbb4-1e7b-4d99-a872-4cf94e64210f", + InReplyToID: "", + BoostOfID: "", + ContentWarning: "", + Visibility: gtsmodel.VisibilityPublic, + Sensitive: false, + Language: "en", + CreatedWithApplicationID: "9bf9e368-037f-444d-8ffd-1091d1c21c4c", + VisibilityAdvanced: &gtsmodel.VisibilityAdvanced{ + Federated: true, + Boostable: true, + Replyable: true, + Likeable: true, + }, + ActivityStreamsType: gtsmodel.ActivityStreamsNote, + }, + "admin_account_status_2": { + ID: "0fb3f1ac-5cd8-48ac-9050-3d95dc7e44e9", + URI: "http://localhost:8080/users/admin/statuses/0fb3f1ac-5cd8-48ac-9050-3d95dc7e44e9", + URL: "http://localhost:8080/@admin/statuses/0fb3f1ac-5cd8-48ac-9050-3d95dc7e44e9", + Content: "🐕🐕🐕🐕🐕", + CreatedAt: time.Now().Add(-70 * time.Hour), + UpdatedAt: time.Now().Add(-70 * time.Hour), + Local: true, + AccountID: "8020dbb4-1e7b-4d99-a872-4cf94e64210f", + InReplyToID: "", + BoostOfID: "", + ContentWarning: "open to see some puppies", + Visibility: gtsmodel.VisibilityPublic, + Sensitive: true, + Language: "en", + CreatedWithApplicationID: "9bf9e368-037f-444d-8ffd-1091d1c21c4c", + VisibilityAdvanced: &gtsmodel.VisibilityAdvanced{ + Federated: true, + Boostable: true, + Replyable: true, + Likeable: true, + }, + ActivityStreamsType: gtsmodel.ActivityStreamsNote, + }, + "local_account_1_status_1": { + ID: "91b1e795-74ff-4672-a4c4-476616710e2d", + URI: "http://localhost:8080/users/the_mighty_zork/statuses/91b1e795-74ff-4672-a4c4-476616710e2d", + URL: "http://localhost:8080/@the_mighty_zork/statuses/91b1e795-74ff-4672-a4c4-476616710e2d", + Content: "hello everyone!", + CreatedAt: time.Now().Add(-47 * time.Hour), + UpdatedAt: time.Now().Add(-47 * time.Hour), + Local: true, + AccountID: "580072df-4d03-4684-a412-89fd6f7d77e6", + InReplyToID: "", + BoostOfID: "", + ContentWarning: "introduction post", + Visibility: gtsmodel.VisibilityPublic, + Sensitive: true, + Language: "en", + CreatedWithApplicationID: "f88697b8-ee3d-46c2-ac3f-dbb85566c3cc", + VisibilityAdvanced: &gtsmodel.VisibilityAdvanced{ + Federated: true, + Boostable: true, + Replyable: true, + Likeable: true, + }, + ActivityStreamsType: gtsmodel.ActivityStreamsNote, + }, + "local_account_1_status_2": { + ID: "3dd328d9-8bb1-48f5-bc96-5ccc1c696b4c", + URI: "http://localhost:8080/users/the_mighty_zork/statuses/3dd328d9-8bb1-48f5-bc96-5ccc1c696b4c", + URL: "http://localhost:8080/@the_mighty_zork/statuses/3dd328d9-8bb1-48f5-bc96-5ccc1c696b4c", + Content: "this is an unlocked local-only post that shouldn't federate, but it's still boostable, replyable, and likeable", + CreatedAt: time.Now().Add(-46 * time.Hour), + UpdatedAt: time.Now().Add(-46 * time.Hour), + Local: true, + AccountID: "580072df-4d03-4684-a412-89fd6f7d77e6", + InReplyToID: "", + BoostOfID: "", + ContentWarning: "", + Visibility: gtsmodel.VisibilityUnlocked, + Sensitive: false, + Language: "en", + CreatedWithApplicationID: "f88697b8-ee3d-46c2-ac3f-dbb85566c3cc", + VisibilityAdvanced: &gtsmodel.VisibilityAdvanced{ + Federated: false, + Boostable: true, + Replyable: true, + Likeable: true, + }, + ActivityStreamsType: gtsmodel.ActivityStreamsNote, + }, + "local_account_1_status_3": { + ID: "5e41963f-8ab9-4147-9f00-52d56e19da65", + URI: "http://localhost:8080/users/the_mighty_zork/statuses/5e41963f-8ab9-4147-9f00-52d56e19da65", + URL: "http://localhost:8080/@the_mighty_zork/statuses/5e41963f-8ab9-4147-9f00-52d56e19da65", + Content: "this is a very personal post that I don't want anyone to interact with at all, and i only want mutuals to see it", + CreatedAt: time.Now().Add(-45 * time.Hour), + UpdatedAt: time.Now().Add(-45 * time.Hour), + Local: true, + AccountID: "580072df-4d03-4684-a412-89fd6f7d77e6", + InReplyToID: "", + BoostOfID: "", + ContentWarning: "test: you shouldn't be able to interact with this post in any way", + Visibility: gtsmodel.VisibilityMutualsOnly, + Sensitive: false, + Language: "en", + CreatedWithApplicationID: "f88697b8-ee3d-46c2-ac3f-dbb85566c3cc", + VisibilityAdvanced: &gtsmodel.VisibilityAdvanced{ + Federated: true, + Boostable: false, + Replyable: false, + Likeable: false, + }, + ActivityStreamsType: gtsmodel.ActivityStreamsNote, + }, + "local_account_1_status_4": { + ID: "18524c05-97dc-46d7-b474-c811bd9e1e32", + URI: "http://localhost:8080/users/the_mighty_zork/statuses/18524c05-97dc-46d7-b474-c811bd9e1e32", + URL: "http://localhost:8080/@the_mighty_zork/statuses/18524c05-97dc-46d7-b474-c811bd9e1e32", + Content: "here's a little gif of trent", + Attachments: []string{"510f6033-798b-4390-81b1-c38ca2205ad3"}, + CreatedAt: time.Now().Add(-1 * time.Hour), + UpdatedAt: time.Now().Add(-1 * time.Hour), + Local: true, + AccountID: "580072df-4d03-4684-a412-89fd6f7d77e6", + InReplyToID: "", + BoostOfID: "", + ContentWarning: "eye contact, trent reznor gif", + Visibility: gtsmodel.VisibilityMutualsOnly, + Sensitive: false, + Language: "en", + CreatedWithApplicationID: "f88697b8-ee3d-46c2-ac3f-dbb85566c3cc", + VisibilityAdvanced: &gtsmodel.VisibilityAdvanced{ + Federated: true, + Boostable: true, + Replyable: true, + Likeable: true, + }, + ActivityStreamsType: gtsmodel.ActivityStreamsNote, + }, + "local_account_2_status_1": { + ID: "8945ccf2-3873-45e9-aa13-fd7163f19775", + URI: "http://localhost:8080/users/1happyturtle/statuses/8945ccf2-3873-45e9-aa13-fd7163f19775", + URL: "http://localhost:8080/@1happyturtle/statuses/8945ccf2-3873-45e9-aa13-fd7163f19775", + Content: "🐢 hi everyone i post about turtles 🐢", + CreatedAt: time.Now().Add(-189 * time.Hour), + UpdatedAt: time.Now().Add(-189 * time.Hour), + Local: true, + AccountID: "eecaad73-5703-426d-9312-276641daa31e", + InReplyToID: "", + BoostOfID: "", + ContentWarning: "introduction post", + Visibility: gtsmodel.VisibilityPublic, + Sensitive: true, + Language: "en", + CreatedWithApplicationID: "6b0cd164-8497-4cd5-bec9-957886fac5df", + VisibilityAdvanced: &gtsmodel.VisibilityAdvanced{ + Federated: true, + Boostable: true, + Replyable: true, + Likeable: true, + }, + ActivityStreamsType: gtsmodel.ActivityStreamsNote, + }, + "local_account_2_status_2": { + ID: "c7e25a86-f0d3-4705-a73c-c597f687d3dd", + URI: "http://localhost:8080/users/1happyturtle/statuses/c7e25a86-f0d3-4705-a73c-c597f687d3dd", + URL: "http://localhost:8080/@1happyturtle/statuses/c7e25a86-f0d3-4705-a73c-c597f687d3dd", + Content: "🐢 this one is federated, likeable, and boostable but not replyable 🐢", + CreatedAt: time.Now().Add(-1 * time.Minute), + UpdatedAt: time.Now().Add(-1 * time.Minute), + Local: true, + AccountID: "eecaad73-5703-426d-9312-276641daa31e", + InReplyToID: "", + BoostOfID: "", + ContentWarning: "", + Visibility: gtsmodel.VisibilityPublic, + Sensitive: true, + Language: "en", + CreatedWithApplicationID: "6b0cd164-8497-4cd5-bec9-957886fac5df", + VisibilityAdvanced: &gtsmodel.VisibilityAdvanced{ + Federated: true, + Boostable: true, + Replyable: false, + Likeable: true, + }, + ActivityStreamsType: gtsmodel.ActivityStreamsNote, + }, + "local_account_2_status_3": { + ID: "75960e30-7a8e-4f45-87fa-440a4d1c9572", + URI: "http://localhost:8080/users/1happyturtle/statuses/75960e30-7a8e-4f45-87fa-440a4d1c9572", + URL: "http://localhost:8080/@1happyturtle/statuses/75960e30-7a8e-4f45-87fa-440a4d1c9572", + Content: "🐢 i don't mind people sharing this one but I don't want likes or replies to it because cba🐢", + CreatedAt: time.Now().Add(-2 * time.Minute), + UpdatedAt: time.Now().Add(-2 * time.Minute), + Local: true, + AccountID: "eecaad73-5703-426d-9312-276641daa31e", + InReplyToID: "", + BoostOfID: "", + ContentWarning: "you won't be able to like or reply to this", + Visibility: gtsmodel.VisibilityUnlocked, + Sensitive: true, + Language: "en", + CreatedWithApplicationID: "6b0cd164-8497-4cd5-bec9-957886fac5df", + VisibilityAdvanced: &gtsmodel.VisibilityAdvanced{ + Federated: true, + Boostable: true, + Replyable: false, + Likeable: false, + }, + ActivityStreamsType: gtsmodel.ActivityStreamsNote, + }, + } +} + +// NewTestTags returns a map of gts model tags keyed by their name +func NewTestTags() map[string]*gtsmodel.Tag { + return map[string]*gtsmodel.Tag{ + "welcome": { + ID: "a7e8f5ca-88a1-4652-8079-a187eab8d56e", + Name: "welcome", + FirstSeenFromAccountID: "", + CreatedAt: time.Now().Add(-71 * time.Hour), + UpdatedAt: time.Now().Add(-71 * time.Hour), + Useable: true, + Listable: true, + LastStatusAt: time.Now().Add(-71 * time.Hour), + }, + } +} + +// NewTestFaves returns a map of gts model faves, keyed in the format [faving_account]_[target_status] +func NewTestFaves() map[string]*gtsmodel.StatusFave { + return map[string]*gtsmodel.StatusFave{ + "local_account_1_admin_account_status_1": { + ID: "fc4d42ef-631c-4125-bd9d-88695131284c", + CreatedAt: time.Now().Add(-47 * time.Hour), + AccountID: "580072df-4d03-4684-a412-89fd6f7d77e6", // local account 1 + TargetAccountID: "8020dbb4-1e7b-4d99-a872-4cf94e64210f", // admin account + StatusID: "502ccd6f-0edf-48d7-9016-2dfa4d3714cd", // admin account status 1 + }, + } +} diff --git a/testrig/util.go b/testrig/util.go @@ -0,0 +1,64 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package testrig + +import ( + "bytes" + "io" + "mime/multipart" + "os" +) + +// CreateMultipartFormData is a handy function for taking a fieldname and a filename, and creating a multipart form bytes buffer +// with the file contents set in the given fieldname. The extraFields param can be used to add extra FormFields to the request, as necessary. +// The returned bytes.Buffer b can be used like so: +// httptest.NewRequest(http.MethodPost, "https://example.org/whateverpath", bytes.NewReader(b.Bytes())) +// The returned *multipart.Writer w can be used to set the content type of the request, like so: +// req.Header.Set("Content-Type", w.FormDataContentType()) +func CreateMultipartFormData(fieldName string, fileName string, extraFields map[string]string) (bytes.Buffer, *multipart.Writer, error) { + var b bytes.Buffer + var err error + w := multipart.NewWriter(&b) + var fw io.Writer + file, err := os.Open(fileName) + if err != nil { + return b, nil, err + } + if fw, err = w.CreateFormFile(fieldName, file.Name()); err != nil { + return b, nil, err + } + if _, err = io.Copy(fw, file); err != nil { + return b, nil, err + } + + for k, v := range extraFields { + f, err := w.CreateFormField(k) + if err != nil { + return b, nil, err + } + if _, err := io.Copy(f, bytes.NewBufferString(v)); err != nil { + return b, nil, err + } + } + + if err := w.Close(); err != nil { + return b, nil, err + } + return b, w, nil +}