diff --git a/PROGRESS.md b/PROGRESS.md index 2d9c653a8..18bcedfa3 100644 --- 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 index 983d49d40..337e7b785 100644 --- 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 index 2d9ddbb72..f4a47f6a2 100644 --- 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{}, + >smodel.User{}, + >smodel.Account{}, + >smodel.Follow{}, + >smodel.FollowRequest{}, + >smodel.Status{}, + >smodel.Application{}, + >smodel.EmailDomainBlock{}, + >smodel.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 index 58b98c0e4..266d820af 100644 --- 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 index d14ae3852..8677e3573 100644 --- 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 = >smodel.Account{ ID: uuid.NewString(), Username: "test_user", } // can use this test application throughout - suite.testApplication = &model.Application{ + suite.testApplication = >smodel.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{}, + >smodel.User{}, + >smodel.Account{}, + >smodel.Follow{}, + >smodel.FollowRequest{}, + >smodel.Status{}, + >smodel.Application{}, + >smodel.EmailDomainBlock{}, + >smodel.MediaAttachment{}, } for _, m := range models { if err := suite.db.CreateTable(m); err != nil { @@ -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{}, + >smodel.User{}, + >smodel.Account{}, + >smodel.Follow{}, + >smodel.FollowRequest{}, + >smodel.Status{}, + >smodel.Application{}, + >smodel.EmailDomainBlock{}, + >smodel.MediaAttachment{}, } for _, m := range models { if err := suite.db.DropTable(m); err != nil { @@ -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 := >smodel.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 := >smodel.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 index 5ee93386d..cd4aed22e 100644 --- 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 := >smodel.Account{} if err := m.db.GetByID(targetAcctID, targetAccount); err != nil { if _, ok := err.(db.ErrNoEntries); ok { c.JSON(http.StatusNotFound, gin.H{"error": "Record not found"}) @@ -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 index 6686d3a50..15e9cf0d1 100644 --- 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, >smodel.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, >smodel.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, >smodel.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, >smodel.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, >smodel.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, >smodel.Account{}); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } } if form.Source.Sensitive != nil { - if err := m.db.UpdateOneByID(authed.Account.ID, "locked", *form.Locked, &model.Account{}); err != nil { + if err := m.db.UpdateOneByID(authed.Account.ID, "locked", *form.Locked, >smodel.Account{}); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -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, >smodel.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 := >smodel.Account{} if err := m.db.GetByID(authed.Account.ID, updatedAccount); err != nil { l.Debugf("could not fetch updated account %s: %s", authed.Account.ID, err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - acctSensitive, err := m.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 index 651b4d29d..7ca2190d8 100644 --- 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 = >smodel.Account{ ID: uuid.NewString(), Username: "test_user", } // can use this test application throughout - suite.testApplication = &model.Application{ + suite.testApplication = >smodel.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{}, + >smodel.User{}, + >smodel.Account{}, + >smodel.Follow{}, + >smodel.FollowRequest{}, + >smodel.Status{}, + >smodel.Application{}, + >smodel.EmailDomainBlock{}, + >smodel.MediaAttachment{}, } for _, m := range models { if err := suite.db.CreateTable(m); err != nil { @@ -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{}, + >smodel.User{}, + >smodel.Account{}, + >smodel.Follow{}, + >smodel.FollowRequest{}, + >smodel.Status{}, + >smodel.Application{}, + >smodel.EmailDomainBlock{}, + >smodel.MediaAttachment{}, } for _, m := range models { if err := suite.db.DropTable(m); err != nil { diff --git a/internal/apimodule/account/accountverify.go b/internal/apimodule/account/accountverify.go index fe8d24b22..584ab6122 100644 --- 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 new file mode 100644 index 000000000..34a0aa2c8 --- /dev/null +++ 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 . +*/ + +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{}{ + >smodel.User{}, + >smodel.Account{}, + >smodel.Follow{}, + >smodel.FollowRequest{}, + >smodel.Status{}, + >smodel.Application{}, + >smodel.EmailDomainBlock{}, + >smodel.MediaAttachment{}, + >smodel.Emoji{}, + } + + for _, m := range models { + if err := db.CreateTable(m); err != nil { + return fmt.Errorf("error creating table: %s", err) + } + } + return nil +} diff --git a/internal/apimodule/admin/emojicreate.go b/internal/apimodule/admin/emojicreate.go new file mode 100644 index 000000000..91457c07c --- /dev/null +++ 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 . +*/ + +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 index 52275c6df..6d7dbdb83 100644 --- 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 index 534f4cd3e..08292acd1 100644 --- 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{}, + >smodel.User{}, + >smodel.Account{}, + >smodel.Application{}, } for _, m := range models { diff --git a/internal/apimodule/app/appcreate.go b/internal/apimodule/app/appcreate.go index cd5aff701..ec52a9d37 100644 --- 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 := >smodel.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 index 3a85a4364..b70adeb43 100644 --- 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{}, + >smodel.User{}, + >smodel.Account{}, + >smodel.Application{}, } for _, m := range models { diff --git a/internal/apimodule/auth/auth_test.go b/internal/apimodule/auth/auth_test.go index 0ec9b4a41..2c272e985 100644 --- 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 = >smodel.Account{ ID: acctID, Username: "test_user", } - suite.testUser = &model.User{ + suite.testUser = >smodel.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 = >smodel.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{}, + >smodel.User{}, + >smodel.Account{}, + >smodel.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{}, + >smodel.User{}, + >smodel.Account{}, + >smodel.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 index 4a27cc20e..bf525e09e 100644 --- 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 := >smodel.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 := >smodel.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 := >smodel.Account{ ID: user.AccountID, } diff --git a/internal/apimodule/auth/middleware.go b/internal/apimodule/auth/middleware.go index 32fc24d52..4ca1f47a2 100644 --- 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 := >smodel.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 := >smodel.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 := >smodel.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 index 34146cbfc..a6994c90e 100644 --- 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 := >smodel.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 index bbafff76f..25f3be864 100644 --- 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 . +*/ + 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{}, + >smodel.MediaAttachment{}, } for _, m := range models { diff --git a/internal/apimodule/fileserver/servefile.go b/internal/apimodule/fileserver/servefile.go new file mode 100644 index 000000000..0421c5095 --- /dev/null +++ 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 . +*/ + +package fileserver + +import ( + "bytes" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/media" +) + +// ServeFile is for serving attachments, headers, and avatars to the requester from instance storage. +// +// Note: to mitigate scraping attempts, no information should be given out on a bad request except "404 page not found". +// Don't give away account ids or media ids or anything like that; callers shouldn't be able to infer anything. +func (m *FileServer) ServeFile(c *gin.Context) { + l := m.log.WithFields(logrus.Fields{ + "func": "ServeFile", + "request_uri": c.Request.RequestURI, + "user_agent": c.Request.UserAgent(), + "origin_ip": c.ClientIP(), + }) + l.Trace("received request") + + // We use request params to check what to pull out of the database/storage so check everything. A request URL should be formatted as follows: + // "https://example.org/fileserver/[ACCOUNT_ID]/[MEDIA_TYPE]/[MEDIA_SIZE]/[FILE_NAME]" + // "FILE_NAME" consists of two parts, the attachment's database id, a period, and the file extension. + accountID := c.Param(AccountIDKey) + if accountID == "" { + l.Debug("missing accountID from request") + c.String(http.StatusNotFound, "404 page not found") + return + } + + mediaType := c.Param(MediaTypeKey) + if mediaType == "" { + l.Debug("missing mediaType from request") + c.String(http.StatusNotFound, "404 page not found") + return + } + + mediaSize := c.Param(MediaSizeKey) + if mediaSize == "" { + l.Debug("missing mediaSize from request") + c.String(http.StatusNotFound, "404 page not found") + return + } + + fileName := c.Param(FileNameKey) + if fileName == "" { + l.Debug("missing fileName from request") + c.String(http.StatusNotFound, "404 page not found") + return + } + + // Only serve media types that are defined in our internal media module + switch mediaType { + case media.MediaHeader, media.MediaAvatar, media.MediaAttachment: + m.serveAttachment(c, accountID, mediaType, mediaSize, fileName) + return + case media.MediaEmoji: + m.serveEmoji(c, accountID, mediaType, mediaSize, fileName) + return + } + l.Debugf("mediatype %s not recognized", mediaType) + c.String(http.StatusNotFound, "404 page not found") +} + +func (m *FileServer) serveAttachment(c *gin.Context, accountID string, mediaType string, mediaSize string, fileName string) { + l := m.log.WithFields(logrus.Fields{ + "func": "serveAttachment", + "request_uri": c.Request.RequestURI, + "user_agent": c.Request.UserAgent(), + "origin_ip": c.ClientIP(), + }) + + // This corresponds to original-sized image as it was uploaded, small (which is the thumbnail), or static + switch mediaSize { + case media.MediaOriginal, media.MediaSmall, media.MediaStatic: + default: + l.Debugf("mediasize %s not recognized", mediaSize) + c.String(http.StatusNotFound, "404 page not found") + return + } + + // derive the media id and the file extension from the last part of the request + spl := strings.Split(fileName, ".") + if len(spl) != 2 { + l.Debugf("filename %s not parseable", fileName) + c.String(http.StatusNotFound, "404 page not found") + return + } + wantedMediaID := spl[0] + fileExtension := spl[1] + if wantedMediaID == "" || fileExtension == "" { + l.Debugf("filename %s not parseable", fileName) + c.String(http.StatusNotFound, "404 page not found") + return + } + + // now we know the attachment ID that the caller is asking for we can use it to pull the attachment out of the db + attachment := >smodel.MediaAttachment{} + if err := m.db.GetByID(wantedMediaID, attachment); err != nil { + l.Debugf("attachment with id %s not retrievable: %s", wantedMediaID, err) + c.String(http.StatusNotFound, "404 page not found") + return + } + + // make sure the given account id owns the requested attachment + if accountID != attachment.AccountID { + l.Debugf("account %s does not own attachment with id %s", accountID, wantedMediaID) + c.String(http.StatusNotFound, "404 page not found") + return + } + + // now we can start preparing the response depending on whether we're serving a thumbnail or a larger attachment + var storagePath string + var contentType string + var contentLength int + switch mediaSize { + case media.MediaOriginal: + storagePath = attachment.File.Path + contentType = attachment.File.ContentType + contentLength = attachment.File.FileSize + case media.MediaSmall: + storagePath = attachment.Thumbnail.Path + contentType = attachment.Thumbnail.ContentType + contentLength = attachment.Thumbnail.FileSize + } + + // use the path listed on the attachment we pulled out of the database to retrieve the object from storage + attachmentBytes, err := m.storage.RetrieveFileFrom(storagePath) + if err != nil { + l.Debugf("error retrieving from storage: %s", err) + c.String(http.StatusNotFound, "404 page not found") + return + } + + l.Errorf("about to serve content length: %d attachment bytes is: %d", int64(contentLength), int64(len(attachmentBytes))) + + // finally we can return with all the information we derived above + c.DataFromReader(http.StatusOK, int64(contentLength), contentType, bytes.NewReader(attachmentBytes), map[string]string{}) +} + +func (m *FileServer) serveEmoji(c *gin.Context, accountID string, mediaType string, mediaSize string, fileName string) { + l := m.log.WithFields(logrus.Fields{ + "func": "serveEmoji", + "request_uri": c.Request.RequestURI, + "user_agent": c.Request.UserAgent(), + "origin_ip": c.ClientIP(), + }) + + // This corresponds to original-sized emoji as it was uploaded, or static + switch mediaSize { + case media.MediaOriginal, media.MediaStatic: + default: + l.Debugf("mediasize %s not recognized", mediaSize) + c.String(http.StatusNotFound, "404 page not found") + return + } + + // derive the media id and the file extension from the last part of the request + spl := strings.Split(fileName, ".") + if len(spl) != 2 { + l.Debugf("filename %s not parseable", fileName) + c.String(http.StatusNotFound, "404 page not found") + return + } + wantedEmojiID := spl[0] + fileExtension := spl[1] + if wantedEmojiID == "" || fileExtension == "" { + l.Debugf("filename %s not parseable", fileName) + c.String(http.StatusNotFound, "404 page not found") + return + } + + // now we know the attachment ID that the caller is asking for we can use it to pull the attachment out of the db + emoji := >smodel.Emoji{} + if err := m.db.GetByID(wantedEmojiID, emoji); err != nil { + l.Debugf("emoji with id %s not retrievable: %s", wantedEmojiID, err) + c.String(http.StatusNotFound, "404 page not found") + return + } + + // make sure the instance account id owns the requested emoji + instanceAccount := >smodel.Account{} + if err := m.db.GetWhere("username", m.config.Host, instanceAccount); err != nil { + l.Debugf("error fetching instance account: %s", err) + c.String(http.StatusNotFound, "404 page not found") + return + } + if accountID != instanceAccount.ID { + l.Debugf("account %s does not own emoji with id %s", accountID, wantedEmojiID) + c.String(http.StatusNotFound, "404 page not found") + return + } + + // now we can start preparing the response depending on whether we're serving a thumbnail or a larger attachment + var storagePath string + var contentType string + var contentLength int + switch mediaSize { + case media.MediaOriginal: + storagePath = emoji.ImagePath + contentType = emoji.ImageContentType + contentLength = emoji.ImageFileSize + case media.MediaStatic: + storagePath = emoji.ImageStaticPath + contentType = "image/png" + contentLength = emoji.ImageStaticFileSize + } + + // use the path listed on the emoji we pulled out of the database to retrieve the object from storage + emojiBytes, err := m.storage.RetrieveFileFrom(storagePath) + if err != nil { + l.Debugf("error retrieving emoji from storage: %s", err) + c.String(http.StatusNotFound, "404 page not found") + return + } + + // finally we can return with all the information we derived above + c.DataFromReader(http.StatusOK, int64(contentLength), contentType, bytes.NewReader(emojiBytes), map[string]string{}) +} diff --git a/internal/apimodule/fileserver/test/servefile_test.go b/internal/apimodule/fileserver/test/servefile_test.go new file mode 100644 index 000000000..8af2b40b3 --- /dev/null +++ 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 . +*/ + +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 new file mode 100644 index 000000000..c8d3d7425 --- /dev/null +++ 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 . +*/ + +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{}{ + >smodel.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 new file mode 100644 index 000000000..06b6d5be6 --- /dev/null +++ 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 . +*/ + +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 new file mode 100644 index 000000000..01a0a6a31 --- /dev/null +++ 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 . +*/ + +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 index 85c7b6ac6..2d4293d0e 100644 --- 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 new file mode 100644 index 000000000..4bb011d4d --- /dev/null +++ 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 . +*/ + +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 new file mode 100644 index 000000000..cfac2ce1e --- /dev/null +++ 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 . +*/ + +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 new file mode 100644 index 000000000..e65293b62 --- /dev/null +++ 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 . +*/ + +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{}{ + >smodel.User{}, + >smodel.Account{}, + >smodel.Block{}, + >smodel.Follow{}, + >smodel.FollowRequest{}, + >smodel.Status{}, + >smodel.StatusFave{}, + >smodel.StatusBookmark{}, + >smodel.StatusMute{}, + >smodel.StatusPin{}, + >smodel.Application{}, + >smodel.EmailDomainBlock{}, + >smodel.MediaAttachment{}, + >smodel.Emoji{}, + >smodel.Tag{}, + >smodel.Mention{}, + } + + for _, m := range models { + if err := db.CreateTable(m); err != nil { + return fmt.Errorf("error creating table: %s", err) + } + } + return nil +} + +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 new file mode 100644 index 000000000..ce1cc6da7 --- /dev/null +++ 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 . +*/ + +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 := >smodel.Status{ + ID: thisStatusID, + URI: thisStatusURI, + URL: thisStatusURL, + Content: util.HTMLFormat(form.Status), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Local: true, + AccountID: authed.Account.ID, + ContentWarning: form.SpoilerText, + ActivityStreamsType: gtsmodel.ActivityStreamsNote, + Sensitive: form.Sensitive, + Language: form.Language, + CreatedWithApplicationID: authed.Application.ID, + Text: form.Status, + } + + // check if replyToID is ok + if err := m.parseReplyToID(form, authed.Account.ID, newStatus); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // check if mediaIDs are ok + if err := m.parseMediaIDs(form, authed.Account.ID, newStatus); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // check if visibility settings are ok + if err := parseVisibility(form, authed.Account.Privacy, newStatus); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // handle language settings + if err := parseLanguage(form, authed.Account.Language, newStatus); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // handle mentions + if err := m.parseMentions(form, authed.Account.ID, newStatus); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := m.parseTags(form, authed.Account.ID, newStatus); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := m.parseEmojis(form, authed.Account.ID, newStatus); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + /* + FROM THIS POINT ONWARDS WE ARE HAPPY WITH THE STATUS -- it is valid and we will try to create it + */ + + // put the new status in the database, generating an ID for it in the process + if err := m.db.Put(newStatus); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // change the status ID of the media attachments to the new status + for _, a := range newStatus.GTSMediaAttachments { + a.StatusID = newStatus.ID + a.UpdatedAt = time.Now() + if err := m.db.UpdateByID(a.ID, a); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } + + // pass to the distributor to take care of side effects asynchronously -- federation, mentions, updating metadata, etc, etc + m.distributor.FromClientAPI() <- distributor.FromClientAPI{ + APObjectType: gtsmodel.ActivityStreamsNote, + APActivityType: gtsmodel.ActivityStreamsCreate, + Activity: newStatus, + } + + // return the frontend representation of the new status to the submitter + mastoStatus, err := m.mastoConverter.StatusToMasto(newStatus, authed.Account, authed.Account, nil, newStatus.GTSReplyToAccount, nil) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, mastoStatus) +} + +func validateCreateStatus(form *advancedStatusCreateForm, config *config.StatusesConfig) error { + // validate that, structurally, we have a valid status/post + if form.Status == "" && form.MediaIDs == nil && form.Poll == nil { + return errors.New("no status, media, or poll provided") + } + + if form.MediaIDs != nil && form.Poll != nil { + return errors.New("can't post media + poll in same status") + } + + // validate status + if form.Status != "" { + if len(form.Status) > config.MaxChars { + return fmt.Errorf("status too long, %d characters provided but limit is %d", len(form.Status), config.MaxChars) + } + } + + // validate media attachments + if len(form.MediaIDs) > config.MaxMediaFiles { + return fmt.Errorf("too many media files attached to status, %d attached but limit is %d", len(form.MediaIDs), config.MaxMediaFiles) + } + + // validate poll + if form.Poll != nil { + if form.Poll.Options == nil { + return errors.New("poll with no options") + } + if len(form.Poll.Options) > config.PollMaxOptions { + return fmt.Errorf("too many poll options provided, %d provided but limit is %d", len(form.Poll.Options), config.PollMaxOptions) + } + for _, p := range form.Poll.Options { + if len(p) > config.PollOptionMaxChars { + return fmt.Errorf("poll option too long, %d characters provided but limit is %d", len(p), config.PollOptionMaxChars) + } + } + } + + // validate spoiler text/cw + if form.SpoilerText != "" { + if len(form.SpoilerText) > config.CWMaxChars { + return fmt.Errorf("content-warning/spoilertext too long, %d characters provided but limit is %d", len(form.SpoilerText), config.CWMaxChars) + } + } + + // validate post language + if form.Language != "" { + if err := util.ValidateLanguage(form.Language); err != nil { + return err + } + } + + return nil +} + +func parseVisibility(form *advancedStatusCreateForm, accountDefaultVis gtsmodel.Visibility, status *gtsmodel.Status) error { + // by default all flags are set to true + gtsAdvancedVis := >smodel.VisibilityAdvanced{ + Federated: true, + Boostable: true, + Replyable: true, + Likeable: true, + } + + var gtsBasicVis gtsmodel.Visibility + // Advanced takes priority if it's set. + // If it's not set, take whatever masto visibility is set. + // If *that's* not set either, then just take the account default. + // If that's also not set, take the default for the whole instance. + if form.VisibilityAdvanced != nil { + gtsBasicVis = *form.VisibilityAdvanced + } else if form.Visibility != "" { + gtsBasicVis = util.ParseGTSVisFromMastoVis(form.Visibility) + } else if accountDefaultVis != "" { + gtsBasicVis = accountDefaultVis + } else { + gtsBasicVis = gtsmodel.VisibilityDefault + } + + switch gtsBasicVis { + case gtsmodel.VisibilityPublic: + // for public, there's no need to change any of the advanced flags from true regardless of what the user filled out + break + case gtsmodel.VisibilityUnlocked: + // for unlocked the user can set any combination of flags they like so look at them all to see if they're set and then apply them + if form.Federated != nil { + gtsAdvancedVis.Federated = *form.Federated + } + + if form.Boostable != nil { + gtsAdvancedVis.Boostable = *form.Boostable + } + + if form.Replyable != nil { + gtsAdvancedVis.Replyable = *form.Replyable + } + + if form.Likeable != nil { + gtsAdvancedVis.Likeable = *form.Likeable + } + + case gtsmodel.VisibilityFollowersOnly, gtsmodel.VisibilityMutualsOnly: + // for followers or mutuals only, boostable will *always* be false, but the other fields can be set so check and apply them + gtsAdvancedVis.Boostable = false + + if form.Federated != nil { + gtsAdvancedVis.Federated = *form.Federated + } + + if form.Replyable != nil { + gtsAdvancedVis.Replyable = *form.Replyable + } + + if form.Likeable != nil { + gtsAdvancedVis.Likeable = *form.Likeable + } + + case gtsmodel.VisibilityDirect: + // direct is pretty easy: there's only one possible setting so return it + gtsAdvancedVis.Federated = true + gtsAdvancedVis.Boostable = false + gtsAdvancedVis.Federated = true + gtsAdvancedVis.Likeable = true + } + + status.Visibility = gtsBasicVis + status.VisibilityAdvanced = gtsAdvancedVis + return nil +} + +func (m *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 := >smodel.Status{} + repliedAccount := >smodel.Account{} + // check replied status exists + is replyable + if err := m.db.GetByID(form.InReplyToID, repliedStatus); err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + return fmt.Errorf("status with id %s not replyable because it doesn't exist", form.InReplyToID) + } 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 := >smodel.MediaAttachment{} + if err := m.db.GetByID(mediaID, a); err != nil { + return fmt.Errorf("invalid media type or media not found for media id %s", mediaID) + } + // check they belong to the requesting account id + if a.AccountID != thisAccountID { + return fmt.Errorf("media with id %s does not belong to account %s", mediaID, thisAccountID) + } + // check they're not already used in a status + if a.StatusID != "" || a.ScheduledStatusID != "" { + return fmt.Errorf("media with id %s is already attached to a status", mediaID) + } + gtsMediaAttachments = append(gtsMediaAttachments, a) + attachments = append(attachments, a.ID) + } + status.GTSMediaAttachments = gtsMediaAttachments + status.Attachments = attachments + return nil +} + +func parseLanguage(form *advancedStatusCreateForm, accountDefaultLanguage string, status *gtsmodel.Status) error { + if form.Language != "" { + status.Language = form.Language + } else { + status.Language = accountDefaultLanguage + } + if status.Language == "" { + return errors.New("no language given either in status create form or account default") + } + return nil +} + +func (m *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 new file mode 100644 index 000000000..f67d035d8 --- /dev/null +++ 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 . +*/ + +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 := >smodel.Status{} + if err := m.db.GetByID(targetStatusID, targetStatus); err != nil { + l.Errorf("error fetching status %s: %s", targetStatusID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + if targetStatus.AccountID != authed.Account.ID { + l.Debug("status doesn't belong to requesting account") + c.JSON(http.StatusForbidden, gin.H{"error": "not allowed"}) + return + } + + l.Trace("going to get relevant accounts") + relevantAccounts, err := m.db.PullRelevantAccountsFromStatus(targetStatus) + if err != nil { + l.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + var boostOfStatus *gtsmodel.Status + if targetStatus.BoostOfID != "" { + boostOfStatus = >smodel.Status{} + if err := m.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { + l.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + } + + mastoStatus, err := m.mastoConverter.StatusToMasto(targetStatus, authed.Account, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) + if err != nil { + l.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + if err := m.db.DeleteByID(targetStatus.ID, targetStatus); err != nil { + l.Errorf("error deleting status from the database: %s", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + m.distributor.FromClientAPI() <- distributor.FromClientAPI{ + APObjectType: gtsmodel.ActivityStreamsNote, + APActivityType: gtsmodel.ActivityStreamsDelete, + Activity: targetStatus, + } + + c.JSON(http.StatusOK, mastoStatus) +} diff --git a/internal/apimodule/status/statusfave.go b/internal/apimodule/status/statusfave.go new file mode 100644 index 000000000..de475b905 --- /dev/null +++ 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 . +*/ + +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 := >smodel.Status{} + if err := m.db.GetByID(targetStatusID, targetStatus); err != nil { + l.Errorf("error fetching status %s: %s", targetStatusID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + l.Tracef("going to search for target account %s", targetStatus.AccountID) + targetAccount := >smodel.Account{} + if err := m.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { + l.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + l.Trace("going to get relevant accounts") + relevantAccounts, err := m.db.PullRelevantAccountsFromStatus(targetStatus) + if err != nil { + l.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + l.Trace("going to see if status is visible") + visible, err := m.db.StatusVisible(targetStatus, targetAccount, authed.Account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that + if err != nil { + l.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + if !visible { + l.Trace("status is not visible") + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + // is the status faveable? + if !targetStatus.VisibilityAdvanced.Likeable { + l.Debug("status is not faveable") + c.JSON(http.StatusForbidden, gin.H{"error": fmt.Sprintf("status %s not faveable", targetStatusID)}) + return + } + + // it's visible! it's faveable! so let's fave the FUCK out of it + fave, err := m.db.FaveStatus(targetStatus, authed.Account.ID) + if err != nil { + l.Debugf("error faveing status: %s", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var boostOfStatus *gtsmodel.Status + if targetStatus.BoostOfID != "" { + boostOfStatus = >smodel.Status{} + if err := m.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { + l.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + } + + mastoStatus, err := m.mastoConverter.StatusToMasto(targetStatus, targetAccount, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) + if err != nil { + l.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + // if the targeted status was already faved, faved will be nil + // only put the fave in the distributor if something actually changed + if fave != nil { + fave.FavedStatus = targetStatus // attach the status pointer to the fave for easy retrieval in the distributor + m.distributor.FromClientAPI() <- distributor.FromClientAPI{ + APObjectType: gtsmodel.ActivityStreamsNote, // status is a note + APActivityType: gtsmodel.ActivityStreamsLike, // we're creating a like/fave on the note + Activity: fave, // pass the fave along for processing + } + } + + c.JSON(http.StatusOK, mastoStatus) +} diff --git a/internal/apimodule/status/statusfavedby.go b/internal/apimodule/status/statusfavedby.go new file mode 100644 index 000000000..76a50b2ca --- /dev/null +++ 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 . +*/ + +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 := >smodel.Status{} + if err := m.db.GetByID(targetStatusID, targetStatus); err != nil { + l.Errorf("error fetching status %s: %s", targetStatusID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + l.Tracef("going to search for target account %s", targetStatus.AccountID) + targetAccount := >smodel.Account{} + if err := m.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { + l.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + l.Trace("going to get relevant accounts") + relevantAccounts, err := m.db.PullRelevantAccountsFromStatus(targetStatus) + if err != nil { + l.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + l.Trace("going to see if status is visible") + visible, err := m.db.StatusVisible(targetStatus, targetAccount, requestingAccount, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that + if err != nil { + l.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + if !visible { + l.Trace("status is not visible") + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + // get ALL accounts that faved a status -- doesn't take account of blocks and mutes and stuff + favingAccounts, err := m.db.WhoFavedStatus(targetStatus) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // filter the list so the user doesn't see accounts they blocked or which blocked them + filteredAccounts := []*gtsmodel.Account{} + for _, acc := range favingAccounts { + blocked, err := m.db.Blocked(authed.Account.ID, acc.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if !blocked { + filteredAccounts = append(filteredAccounts, acc) + } + } + + // TODO: filter other things here? suspended? muted? silenced? + + // now we can return the masto representation of those accounts + mastoAccounts := []*mastotypes.Account{} + for _, acc := range filteredAccounts { + mastoAccount, err := m.mastoConverter.AccountToMastoPublic(acc) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + mastoAccounts = append(mastoAccounts, mastoAccount) + } + + c.JSON(http.StatusOK, mastoAccounts) +} diff --git a/internal/apimodule/status/statusget.go b/internal/apimodule/status/statusget.go new file mode 100644 index 000000000..ed2e89159 --- /dev/null +++ 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 . +*/ + +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 := >smodel.Status{} + if err := m.db.GetByID(targetStatusID, targetStatus); err != nil { + l.Errorf("error fetching status %s: %s", targetStatusID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + l.Tracef("going to search for target account %s", targetStatus.AccountID) + targetAccount := >smodel.Account{} + if err := m.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { + l.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + l.Trace("going to get relevant accounts") + relevantAccounts, err := m.db.PullRelevantAccountsFromStatus(targetStatus) + if err != nil { + l.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + l.Trace("going to see if status is visible") + visible, err := m.db.StatusVisible(targetStatus, targetAccount, requestingAccount, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that + if err != nil { + l.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + if !visible { + l.Trace("status is not visible") + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + var boostOfStatus *gtsmodel.Status + if targetStatus.BoostOfID != "" { + boostOfStatus = >smodel.Status{} + if err := m.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { + l.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + } + + mastoStatus, err := m.mastoConverter.StatusToMasto(targetStatus, targetAccount, requestingAccount, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) + if err != nil { + l.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + c.JSON(http.StatusOK, mastoStatus) +} diff --git a/internal/apimodule/status/statusunfave.go b/internal/apimodule/status/statusunfave.go new file mode 100644 index 000000000..61ffd8e4c --- /dev/null +++ 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 . +*/ + +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 := >smodel.Status{} + if err := m.db.GetByID(targetStatusID, targetStatus); err != nil { + l.Errorf("error fetching status %s: %s", targetStatusID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + l.Tracef("going to search for target account %s", targetStatus.AccountID) + targetAccount := >smodel.Account{} + if err := m.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { + l.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + l.Trace("going to get relevant accounts") + relevantAccounts, err := m.db.PullRelevantAccountsFromStatus(targetStatus) + if err != nil { + l.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + l.Trace("going to see if status is visible") + visible, err := m.db.StatusVisible(targetStatus, targetAccount, authed.Account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that + if err != nil { + l.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + if !visible { + l.Trace("status is not visible") + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + // is the status faveable? + if !targetStatus.VisibilityAdvanced.Likeable { + l.Debug("status is not faveable") + c.JSON(http.StatusForbidden, gin.H{"error": fmt.Sprintf("status %s not faveable so therefore not unfave-able", targetStatusID)}) + return + } + + // it's visible! it's faveable! so let's unfave the FUCK out of it + fave, err := m.db.UnfaveStatus(targetStatus, authed.Account.ID) + if err != nil { + l.Debugf("error unfaveing status: %s", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var boostOfStatus *gtsmodel.Status + if targetStatus.BoostOfID != "" { + boostOfStatus = >smodel.Status{} + if err := m.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { + l.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + } + + mastoStatus, err := m.mastoConverter.StatusToMasto(targetStatus, targetAccount, authed.Account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) + if err != nil { + l.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err) + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("status %s not found", targetStatusID)}) + return + } + + // fave might be nil if this status wasn't faved in the first place + // we only want to pass the message to the distributor if something actually changed + if fave != nil { + fave.FavedStatus = targetStatus // attach the status pointer to the fave for easy retrieval in the distributor + m.distributor.FromClientAPI() <- distributor.FromClientAPI{ + APObjectType: gtsmodel.ActivityStreamsNote, // status is a note + APActivityType: gtsmodel.ActivityStreamsUndo, // undo the fave + Activity: fave, // pass the undone fave along + } + } + + c.JSON(http.StatusOK, mastoStatus) +} diff --git a/internal/apimodule/status/test/statuscreate_test.go b/internal/apimodule/status/test/statuscreate_test.go new file mode 100644 index 000000000..6c5aa6b7d --- /dev/null +++ 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 . +*/ + +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 := >smodel.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 := >smodel.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 new file mode 100644 index 000000000..b15e57e77 --- /dev/null +++ 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 . +*/ + +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 new file mode 100644 index 000000000..83f66562b --- /dev/null +++ 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 . +*/ + +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 new file mode 100644 index 000000000..2c2e98acd --- /dev/null +++ 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 . +*/ + +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 := >smodel.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 new file mode 100644 index 000000000..81276a1ed --- /dev/null +++ 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 . +*/ + +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 index 811cf166d..a21eaa589 100644 --- 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 new file mode 100644 index 000000000..16a7ec46d --- /dev/null +++ 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 index 816e236b2..136dba528 100644 --- 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 new file mode 100644 index 000000000..fbb5225b4 --- /dev/null +++ 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 . +*/ + +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 index 4921270e7..69ad7b822 100644 --- 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) + // 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) - // 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) + // 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 similarity index 100% rename from internal/db/model/README.md rename to internal/db/gtsmodel/README.md diff --git a/internal/db/model/account.go b/internal/db/gtsmodel/account.go similarity index 81% rename from internal/db/model/account.go rename to internal/db/gtsmodel/account.go index 70ee92929..4bf5a9d33 100644 --- a/internal/db/model/account.go +++ b/internal/db/gtsmodel/account.go @@ -16,15 +16,14 @@ along with this program. If not, see . */ -// Package model contains types used *internally* by GoToSocial and added/removed/selected from the database. +// 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 model +package gtsmodel import ( "crypto/rsa" - "net/url" "time" ) @@ -38,33 +37,17 @@ type Account struct { 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 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 */ - // 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"` + // 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 @@ -74,13 +57,11 @@ type Account struct { // 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 + 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()"` - // 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? @@ -95,7 +76,7 @@ type Account struct { // Should this account be shown in the instance's profile directory? Discoverable bool // Default post privacy for this account - Privacy string + Privacy Visibility // Set posts from this account to sensitive by default? Sensitive bool // What language does this account post in? @@ -122,7 +103,7 @@ type Account struct { // URL for getting the featured collection list of this account FeaturedCollectionURL string `pg:",unique"` // What type of activitypub actor is this account? - ActorType string + ActorType ActivityStreamsActor // This account is associated with x account id AlsoKnownAs string @@ -130,7 +111,6 @@ type Account struct { 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 @@ -146,12 +126,10 @@ type Account struct { 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 + SuspensionOrigin string } // Field represents a key value field on an account, for things like pronouns, website, etc. diff --git a/internal/db/gtsmodel/activitystreams.go b/internal/db/gtsmodel/activitystreams.go new file mode 100644 index 000000000..059588a57 --- /dev/null +++ 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 . +*/ + +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/model/application.go b/internal/db/gtsmodel/application.go similarity index 78% rename from internal/db/model/application.go rename to internal/db/gtsmodel/application.go index c8eea6430..8e1398beb 100644 --- a/internal/db/model/application.go +++ b/internal/db/gtsmodel/application.go @@ -16,9 +16,7 @@ along with this program. If not, see . */ -package model - -import "github.com/superseriousbusiness/gotosocial/pkg/mastotypes" +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. @@ -40,16 +38,3 @@ type Application struct { // 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/gtsmodel/block.go b/internal/db/gtsmodel/block.go new file mode 100644 index 000000000..fae43fbef --- /dev/null +++ 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/model/domainblock.go b/internal/db/gtsmodel/domainblock.go similarity index 99% rename from internal/db/model/domainblock.go rename to internal/db/gtsmodel/domainblock.go index e6e89bc20..dcfb2acee 100644 --- a/internal/db/model/domainblock.go +++ b/internal/db/gtsmodel/domainblock.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package model +package gtsmodel import "time" diff --git a/internal/db/model/emaildomainblock.go b/internal/db/gtsmodel/emaildomainblock.go similarity index 98% rename from internal/db/model/emaildomainblock.go rename to internal/db/gtsmodel/emaildomainblock.go index 6610a2075..4cda68b02 100644 --- a/internal/db/model/emaildomainblock.go +++ b/internal/db/gtsmodel/emaildomainblock.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package model +package gtsmodel import "time" diff --git a/internal/db/gtsmodel/emoji.go b/internal/db/gtsmodel/emoji.go new file mode 100644 index 000000000..da1e2e02c --- /dev/null +++ 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 . +*/ + +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/model/follow.go b/internal/db/gtsmodel/follow.go similarity index 98% rename from internal/db/model/follow.go rename to internal/db/gtsmodel/follow.go index 36e19e72e..90080da6e 100644 --- a/internal/db/model/follow.go +++ b/internal/db/gtsmodel/follow.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package model +package gtsmodel import "time" diff --git a/internal/db/model/followrequest.go b/internal/db/gtsmodel/followrequest.go similarity index 99% rename from internal/db/model/followrequest.go rename to internal/db/gtsmodel/followrequest.go index 50d8a5f03..1401a26f1 100644 --- a/internal/db/model/followrequest.go +++ b/internal/db/gtsmodel/followrequest.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package model +package gtsmodel import "time" diff --git a/internal/db/model/mediaattachment.go b/internal/db/gtsmodel/mediaattachment.go similarity index 88% rename from internal/db/model/mediaattachment.go rename to internal/db/gtsmodel/mediaattachment.go index 3aff18d80..d2b028b18 100644 --- a/internal/db/model/mediaattachment.go +++ b/internal/db/gtsmodel/mediaattachment.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package model +package gtsmodel import ( "time" @@ -29,7 +29,9 @@ type MediaAttachment struct { 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 + // 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()"` @@ -81,7 +83,9 @@ type Thumbnail struct { 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 + // 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 } @@ -111,15 +115,18 @@ const ( 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 implements SmallMeta and can be used for a thumbnail of any media type +// Small can be used for a thumbnail of any media type type Small struct { Width int Height int @@ -127,10 +134,15 @@ type Small struct { Aspect float64 } -// ImageOriginal implements OriginalMeta for still images +// 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 new file mode 100644 index 000000000..18eb11082 --- /dev/null +++ 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 . +*/ + +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 new file mode 100644 index 000000000..c39497cdd --- /dev/null +++ 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 . +*/ + +package gtsmodel diff --git a/internal/db/gtsmodel/status.go b/internal/db/gtsmodel/status.go new file mode 100644 index 000000000..3b4b84405 --- /dev/null +++ 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 . +*/ + +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 new file mode 100644 index 000000000..6246334e3 --- /dev/null +++ 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 . +*/ + +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 new file mode 100644 index 000000000..9fb92b931 --- /dev/null +++ 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 . +*/ + +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 new file mode 100644 index 000000000..53c15e5b5 --- /dev/null +++ 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 . +*/ + +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 new file mode 100644 index 000000000..1df333387 --- /dev/null +++ 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 . +*/ + +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 new file mode 100644 index 000000000..83c471958 --- /dev/null +++ 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 . +*/ + +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/model/user.go b/internal/db/gtsmodel/user.go similarity index 99% rename from internal/db/model/user.go rename to internal/db/gtsmodel/user.go index 61e9954d5..a72569945 100644 --- a/internal/db/model/user.go +++ b/internal/db/gtsmodel/user.go @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package model +package gtsmodel import ( "net" diff --git a/internal/db/mock_DB.go b/internal/db/mock_DB.go index d4c25bb79..df2e41907 100644 --- 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/status.go b/internal/db/model/status.go deleted file mode 100644 index d15258727..000000000 --- a/internal/db/model/status.go +++ /dev/null @@ -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 . -*/ - -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/pg.go b/internal/db/pg.go index df01132c2..a12529d00 100644 --- 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 := >smodel.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 := >smodel.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(>smodel.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(>smodel.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(>smodel.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 := >smodel.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 := >smodel.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(>smodel.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 := >smodel.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 := >smodel.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 } +func (ps *postgresService) Blocked(account1 string, account2 string) (bool, error) { + var blocked bool + if err := ps.conn.Model(>smodel.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 +} + +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 + } + + // if the target user doesn't exist (anymore) then the status also shouldn't be visible + targetUser := >smodel.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 + } + } + + // 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 + } + + // 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 + } + + // check if we have a local account -- if so we can check the user for that account in the DB + if requestingAccount.Domain == "" { + requestingUser := >smodel.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 + } + } + + // 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 + } + } + + // 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 -_- + } + + 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(>smodel.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(>smodel.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 + } + } + + // make sure account 2 follows account 1 + f2, err := ps.conn.Model(>smodel.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 + } + } + + return f1 && f2, nil +} + +func (ps *postgresService) PullRelevantAccountsFromStatus(targetStatus *gtsmodel.Status) (*gtsmodel.RelevantAccounts, error) { + accounts := >smodel.RelevantAccounts{ + MentionedAccounts: []*gtsmodel.Account{}, + } + + // get the replied to account from the status and add it to the pile + if targetStatus.InReplyToAccountID != "" { + repliedToAccount := >smodel.Account{} + if err := ps.conn.Model(repliedToAccount).Where("id = ?", targetStatus.InReplyToAccountID).Select(); err != nil { + return accounts, err + } + accounts.ReplyToAccount = repliedToAccount + } + + // get the boosted account from the status and add it to the pile + if targetStatus.BoostOfID != "" { + // retrieve the boosted status first + boostedStatus := >smodel.Status{} + if err := ps.conn.Model(boostedStatus).Where("id = ?", targetStatus.BoostOfID).Select(); err != nil { + return accounts, err + } + boostedAccount := >smodel.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 := >smodel.Account{} + if err := ps.conn.Model(boostedStatusRepliedToAccount).Where("id = ?", boostedStatus.InReplyToAccountID).Select(); err != nil { + return accounts, err + } + accounts.BoostedReplyToAccount = boostedStatusRepliedToAccount + } + } + + // now get all accounts with IDs that are mentioned in the status + for _, mentionedAccountID := range targetStatus.Mentions { + mentionedAccount := >smodel.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(>smodel.Status{}).Where("in_reply_to_id = ?", status.ID).Count() +} + +func (ps *postgresService) GetReblogCountForStatus(status *gtsmodel.Status) (int, error) { + return ps.conn.Model(>smodel.Status{}).Where("boost_of_id = ?", status.ID).Count() +} + +func (ps *postgresService) GetFaveCountForStatus(status *gtsmodel.Status) (int, error) { + return ps.conn.Model(>smodel.StatusFave{}).Where("status_id = ?", status.ID).Count() +} + +func (ps *postgresService) StatusFavedBy(status *gtsmodel.Status, accountID string) (bool, error) { + return ps.conn.Model(>smodel.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(>smodel.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(>smodel.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(>smodel.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(>smodel.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 := >smodel.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 + } + + // it doesn't exist so create it + newFave := >smodel.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 := >smodel.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(>smodel.StatusFave{}).Where("status_id = ?", status.ID).Where("account_id = ?", accountID).Delete(); err != nil { + return nil, 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 + } + + for _, f := range faves { + acc := >smodel.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) + } + return accounts, nil +} + /* CONVERSION FUNCTIONS */ -// 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) 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). - // then adding the Source object to it... + // 1. trim off the first @ + t := strings.TrimPrefix(a, "@") - // 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) + // 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) } - } - var frc int - if fr != nil { - frc = len(fr) - } - mastoAccount.Source = &mastotypes.Source{ - Privacy: a.Privacy, - Sensitive: a.Sensitive, - Language: a.Language, - Note: a.Note, - Fields: mastoAccount.Fields, - FollowRequestsCount: frc, - } + var username, domain string + username = s[0] + if !local { + domain = s[1] + } - return mastoAccount, nil + // 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) + } + + // okay we're good now, we can start pulling accounts out of the database + mentionedAccount := >smodel.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, >smodel.Mention{ + StatusID: statusID, + OriginAccountID: originAccountID, + TargetAccountID: mentionedAccount.ID, + }) + } + return menchies, 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) +func (ps *postgresService) TagStringsToTags(tags []string, originAccountID string, statusID string) ([]*gtsmodel.Tag, error) { + newTags := []*gtsmodel.Tag{} + for _, t := range tags { + tag := >smodel.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) + } } - } - var followersCount int - if followers != nil { - followersCount = len(followers) - } - // 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) + // bail already if the tag isn't useable + if !tag.Useable { + continue } + tag.LastStatusAt = time.Now() + newTags = append(newTags, tag) } - var followingCount int - if following != nil { - followingCount = len(following) - } - - // 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) - } - } - var statusesCount int - if statuses != nil { - statusesCount = len(statuses) - } - - // 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) - } - } - var lastStatusAt string - if lastStatus != nil { - lastStatusAt = lastStatus.CreatedAt.Format(time.RFC3339) - } - - // 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) - } - } - 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) - } - } - headerURL := header.File.Path - headerURLStatic := header.Thumbnail.Path - - // 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 + return newTags, nil +} + +func (ps *postgresService) EmojiStringsToEmojis(emojis []string, originAccountID string, statusID string) ([]*gtsmodel.Emoji, error) { + newEmojis := []*gtsmodel.Emoji{} + for _, e := range emojis { + emoji := >smodel.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 index ab092907f..74b69c5b0 100644 --- 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 index 93d7dd8d2..42248c3f2 100644 --- a/internal/distributor/mock_Distributor.go +++ b/internal/distributor/mock_Distributor.go @@ -9,32 +9,16 @@ 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{}) - } - } - - return r0 -} - -// ClientAPIOut provides a mock function with given fields: -func (_m *MockDistributor) ClientAPIOut() chan interface{} { - ret := _m.Called() - - var r0 chan interface{} - if rf, ok := ret.Get(0).(func() chan interface{}); ok { - r0 = rf() - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(chan interface{}) + r0 = ret.Get(0).(chan FromClientAPI) } } @@ -68,3 +52,19 @@ func (_m *MockDistributor) Stop() error { return r0 } + +// ToClientAPI provides a mock function with given fields: +func (_m *MockDistributor) ToClientAPI() chan ToClientAPI { + ret := _m.Called() + + var r0 chan ToClientAPI + if rf, ok := ret.Get(0).(func() chan ToClientAPI); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(chan ToClientAPI) + } + } + + return r0 +} diff --git a/internal/gotosocial/actions.go b/internal/gotosocial/actions.go index 03d90217e..2f90858b4 100644 --- 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 index 8aca69bfc..66f776e5c 100644 --- 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 new file mode 100644 index 000000000..e689b62da --- /dev/null +++ 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 . +*/ + +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 := >smodel.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 := >smodel.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 := >smodel.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 := >smodel.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 := >smodel.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 := >smodel.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 := >smodel.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 := >smodel.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 := >smodel.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 similarity index 100% rename from pkg/mastotypes/README.md rename to internal/mastotypes/mastomodel/README.md diff --git a/pkg/mastotypes/account.go b/internal/mastotypes/mastomodel/account.go similarity index 99% rename from pkg/mastotypes/account.go rename to internal/mastotypes/mastomodel/account.go index 3ddd3c517..bbcf9c90f 100644 --- a/pkg/mastotypes/account.go +++ b/internal/mastotypes/mastomodel/account.go @@ -67,7 +67,7 @@ type Account struct { // 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"` + Source *Source `json:"source,omitempty"` } // AccountCreateRequest represents the form submitted during a POST request to /api/v1/accounts. diff --git a/pkg/mastotypes/activity.go b/internal/mastotypes/mastomodel/activity.go similarity index 100% rename from pkg/mastotypes/activity.go rename to internal/mastotypes/mastomodel/activity.go diff --git a/pkg/mastotypes/admin.go b/internal/mastotypes/mastomodel/admin.go similarity index 100% rename from pkg/mastotypes/admin.go rename to internal/mastotypes/mastomodel/admin.go diff --git a/pkg/mastotypes/announcement.go b/internal/mastotypes/mastomodel/announcement.go similarity index 100% rename from pkg/mastotypes/announcement.go rename to internal/mastotypes/mastomodel/announcement.go diff --git a/pkg/mastotypes/announcementreaction.go b/internal/mastotypes/mastomodel/announcementreaction.go similarity index 100% rename from pkg/mastotypes/announcementreaction.go rename to internal/mastotypes/mastomodel/announcementreaction.go diff --git a/pkg/mastotypes/application.go b/internal/mastotypes/mastomodel/application.go similarity index 98% rename from pkg/mastotypes/application.go rename to internal/mastotypes/mastomodel/application.go index 1984eff46..6140a0127 100644 --- a/pkg/mastotypes/application.go +++ b/internal/mastotypes/mastomodel/application.go @@ -35,7 +35,7 @@ type Application struct { // 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"` + VapidKey string `json:"vapid_key,omitempty"` } // ApplicationPOSTRequest represents a POST request to https://example.org/api/v1/apps. diff --git a/pkg/mastotypes/attachment.go b/internal/mastotypes/mastomodel/attachment.go similarity index 96% rename from pkg/mastotypes/attachment.go rename to internal/mastotypes/mastomodel/attachment.go index 4d4d0955a..bda79a8ee 100644 --- a/pkg/mastotypes/attachment.go +++ b/internal/mastotypes/mastomodel/attachment.go @@ -45,8 +45,10 @@ type Attachment struct { 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. + // 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. diff --git a/pkg/mastotypes/card.go b/internal/mastotypes/mastomodel/card.go similarity index 100% rename from pkg/mastotypes/card.go rename to internal/mastotypes/mastomodel/card.go diff --git a/pkg/mastotypes/context.go b/internal/mastotypes/mastomodel/context.go similarity index 100% rename from pkg/mastotypes/context.go rename to internal/mastotypes/mastomodel/context.go diff --git a/pkg/mastotypes/conversation.go b/internal/mastotypes/mastomodel/conversation.go similarity index 100% rename from pkg/mastotypes/conversation.go rename to internal/mastotypes/mastomodel/conversation.go diff --git a/pkg/mastotypes/emoji.go b/internal/mastotypes/mastomodel/emoji.go similarity index 74% rename from pkg/mastotypes/emoji.go rename to internal/mastotypes/mastomodel/emoji.go index e9ef95460..c50ca6343 100644 --- a/pkg/mastotypes/emoji.go +++ b/internal/mastotypes/mastomodel/emoji.go @@ -18,6 +18,8 @@ package mastotypes +import "mime/multipart" + // Emoji represents a custom emoji. See https://docs.joinmastodon.org/entities/emoji/ type Emoji struct { // REQUIRED @@ -36,3 +38,11 @@ type Emoji struct { // 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 similarity index 100% rename from pkg/mastotypes/error.go rename to internal/mastotypes/mastomodel/error.go diff --git a/pkg/mastotypes/featuredtag.go b/internal/mastotypes/mastomodel/featuredtag.go similarity index 100% rename from pkg/mastotypes/featuredtag.go rename to internal/mastotypes/mastomodel/featuredtag.go diff --git a/pkg/mastotypes/field.go b/internal/mastotypes/mastomodel/field.go similarity index 100% rename from pkg/mastotypes/field.go rename to internal/mastotypes/mastomodel/field.go diff --git a/pkg/mastotypes/filter.go b/internal/mastotypes/mastomodel/filter.go similarity index 100% rename from pkg/mastotypes/filter.go rename to internal/mastotypes/mastomodel/filter.go diff --git a/pkg/mastotypes/history.go b/internal/mastotypes/mastomodel/history.go similarity index 100% rename from pkg/mastotypes/history.go rename to internal/mastotypes/mastomodel/history.go diff --git a/pkg/mastotypes/identityproof.go b/internal/mastotypes/mastomodel/identityproof.go similarity index 100% rename from pkg/mastotypes/identityproof.go rename to internal/mastotypes/mastomodel/identityproof.go diff --git a/pkg/mastotypes/instance.go b/internal/mastotypes/mastomodel/instance.go similarity index 100% rename from pkg/mastotypes/instance.go rename to internal/mastotypes/mastomodel/instance.go diff --git a/pkg/mastotypes/list.go b/internal/mastotypes/mastomodel/list.go similarity index 100% rename from pkg/mastotypes/list.go rename to internal/mastotypes/mastomodel/list.go diff --git a/pkg/mastotypes/marker.go b/internal/mastotypes/mastomodel/marker.go similarity index 100% rename from pkg/mastotypes/marker.go rename to internal/mastotypes/mastomodel/marker.go diff --git a/pkg/mastotypes/mention.go b/internal/mastotypes/mastomodel/mention.go similarity index 100% rename from pkg/mastotypes/mention.go rename to internal/mastotypes/mastomodel/mention.go diff --git a/pkg/mastotypes/notification.go b/internal/mastotypes/mastomodel/notification.go similarity index 100% rename from pkg/mastotypes/notification.go rename to internal/mastotypes/mastomodel/notification.go diff --git a/pkg/mastotypes/oauth.go b/internal/mastotypes/mastomodel/oauth.go similarity index 100% rename from pkg/mastotypes/oauth.go rename to internal/mastotypes/mastomodel/oauth.go diff --git a/pkg/mastotypes/poll.go b/internal/mastotypes/mastomodel/poll.go similarity index 100% rename from pkg/mastotypes/poll.go rename to internal/mastotypes/mastomodel/poll.go diff --git a/pkg/mastotypes/preferences.go b/internal/mastotypes/mastomodel/preferences.go similarity index 100% rename from pkg/mastotypes/preferences.go rename to internal/mastotypes/mastomodel/preferences.go diff --git a/pkg/mastotypes/pushsubscription.go b/internal/mastotypes/mastomodel/pushsubscription.go similarity index 100% rename from pkg/mastotypes/pushsubscription.go rename to internal/mastotypes/mastomodel/pushsubscription.go diff --git a/pkg/mastotypes/relationship.go b/internal/mastotypes/mastomodel/relationship.go similarity index 100% rename from pkg/mastotypes/relationship.go rename to internal/mastotypes/mastomodel/relationship.go diff --git a/pkg/mastotypes/results.go b/internal/mastotypes/mastomodel/results.go similarity index 100% rename from pkg/mastotypes/results.go rename to internal/mastotypes/mastomodel/results.go diff --git a/pkg/mastotypes/scheduledstatus.go b/internal/mastotypes/mastomodel/scheduledstatus.go similarity index 100% rename from pkg/mastotypes/scheduledstatus.go rename to internal/mastotypes/mastomodel/scheduledstatus.go diff --git a/pkg/mastotypes/source.go b/internal/mastotypes/mastomodel/source.go similarity index 97% rename from pkg/mastotypes/source.go rename to internal/mastotypes/mastomodel/source.go index 4142540a7..0445a1ffb 100644 --- a/pkg/mastotypes/source.go +++ b/internal/mastotypes/mastomodel/source.go @@ -27,7 +27,7 @@ type Source struct { // unlisted = Unlisted post // private = Followers-only post // direct = Direct post - Privacy string `json:"privacy,omitempty"` + 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. diff --git a/pkg/mastotypes/status.go b/internal/mastotypes/mastomodel/status.go similarity index 84% rename from pkg/mastotypes/status.go rename to internal/mastotypes/mastomodel/status.go index e98504e27..a27a0e6a2 100644 --- a/pkg/mastotypes/status.go +++ b/internal/mastotypes/mastomodel/status.go @@ -18,29 +18,6 @@ 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. @@ -48,19 +25,15 @@ type Status struct { // 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"` + InReplyToID string `json:"in_reply_to_id,omitempty"` // ID of the account being replied to. - InReplyToAccountID string `json:"in_reply_to_account_id"` + 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"` + SpoilerText string `json:"spoiler_text,omitempty"` // 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"` + 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. @@ -86,7 +59,7 @@ type Status struct { // HTML-encoded status content. Content string `json:"content"` // The status being reblogged. - Reblog *Status `json:"reblog"` + Reblog *Status `json:"reblog,omitempty"` // The application used to post this status. Application *Application `json:"application"` // The account that authored this status. @@ -108,3 +81,39 @@ type Status struct { // 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/pkg/mastotypes/tag.go b/internal/mastotypes/mastomodel/tag.go similarity index 86% rename from pkg/mastotypes/tag.go rename to internal/mastotypes/mastomodel/tag.go index 4431ac3e9..82e6e6618 100644 --- a/pkg/mastotypes/tag.go +++ b/internal/mastotypes/mastomodel/tag.go @@ -20,4 +20,8 @@ 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 similarity index 100% rename from pkg/mastotypes/token.go rename to internal/mastotypes/mastomodel/token.go diff --git a/internal/mastotypes/mock_Converter.go b/internal/mastotypes/mock_Converter.go new file mode 100644 index 000000000..732d933ae --- /dev/null +++ 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 index d25fd258d..6546501ab 100644 --- 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 := >smodel.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 := >smodel.Emoji{ + ID: newEmojiID, + Shortcode: shortcode, + Domain: "", // empty because this is a local emoji + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + ImageRemoteURL: "", // empty because this is a local emoji + ImageStaticRemoteURL: "", // empty because this is a local emoji + ImageURL: emojiURL, + ImageStaticURL: emojiStaticURL, + ImagePath: emojiPath, + ImageStaticPath: emojiStaticPath, + ImageContentType: contentType, + ImageFileSize: len(original.image), + ImageStaticFileSize: len(static.image), + ImageUpdatedAt: time.Now(), + Disabled: false, + URI: emojiURI, + VisibleInPicker: true, + CategoryID: "", // empty because this is a new emoji -- no category yet + } + 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 := >smodel.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 := >smodel.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 index ae5896c38..58f2e029e 100644 --- a/internal/media/media_test.go +++ b/internal/media/media_test.go @@ -29,7 +29,7 @@ import ( "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/db/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{}, + >smodel.Account{}, + >smodel.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{}, + >smodel.Account{}, + >smodel.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 index 0299d307e..1f875557a 100644 --- a/internal/media/mock_MediaHandler.go +++ b/internal/media/mock_MediaHandler.go @@ -4,7 +4,7 @@ package media import ( mock "github.com/stretchr/testify/mock" - 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 new file mode 100644 index 000000000..fdbfaeec3 Binary files /dev/null and b/internal/media/test/rainbow-original.png differ diff --git a/internal/media/test/rainbow-static.png b/internal/media/test/rainbow-static.png new file mode 100644 index 000000000..d364b1171 Binary files /dev/null and b/internal/media/test/rainbow-static.png differ diff --git a/internal/media/util.go b/internal/media/util.go index 9ffb79a46..64d1ee770 100644 --- 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,23 +117,12 @@ func purgeExif(b []byte) ([]byte, error) { return clean, nil } -func deriveImage(b []byte, extension string) (*imageAndMeta, error) { - var i image.Image +func deriveGif(b []byte, extension string) (*imageAndMeta, error) { + var g *gif.GIF var err error - switch extension { - case "image/jpeg": - i, err = jpeg.Decode(bytes.NewReader(b)) - if err != nil { - return nil, err - } - 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)) + g, err = gif.DecodeAll(bytes.NewReader(b)) if err != nil { return nil, err } @@ -111,19 +130,22 @@ func deriveImage(b []byte, extension string) (*imageAndMeta, error) { return nil, fmt.Errorf("extension %s not recognised", extension) } - width := i.Bounds().Size().X - height := i.Bounds().Size().Y + // 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, i) - if err != nil { - return nil, fmt.Errorf("error generating blurhash: %s", err) + + bh, err := blurhash.Encode(4, 3, g.Image[0]) + if err != nil || bh == "" { + return nil, err } out := &bytes.Buffer{} - if err := jpeg.Encode(out, i, nil); err != nil { + if err := gif.EncodeAll(out, g); err != nil { return nil, err } + return &imageAndMeta{ image: out.Bytes(), width: width, @@ -134,16 +156,60 @@ func deriveImage(b []byte, extension string) (*imageAndMeta, error) { }, nil } -// deriveThumbnailFromImage returns a byte slice and metadata for a 256-pixel-width thumbnail -// 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) { +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 { + return nil, err + } + case "image/png": + i, err = png.Decode(bytes.NewReader(b)) + if err != nil { + return nil, err + } + default: + 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, err + } + + out := &bytes.Buffer{} + if err := jpeg.Encode(out, i, nil); err != nil { + return nil, err + } + + return &imageAndMeta{ + image: out.Bytes(), + width: width, + height: height, + size: size, + aspect: aspect, + blurhash: bh, + }, nil +} + +// 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, 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 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 index f24c1660f..be617a256 100644 --- 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 index 8bac8fc2f..538288922 100644 --- 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 index c4c9ff1d5..14caa6581 100644 --- 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 index ce924b26d..7ab208ef6 100644 --- 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 index 25432fbaa..2d88189db 100644 --- 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 index 29461d5d4..3b64524f6 100644 --- 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 index 865d52205..2444f030a 100644 --- 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 index fa884ed07..409c90b37 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -16,9 +16,15 @@ along with this program. If not, see . */ +// 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 index 375ab97f2..f0bcff5dc 100644 --- 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 . +*/ + 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 new file mode 100644 index 000000000..60b397d86 --- /dev/null +++ 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 . +*/ + +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 new file mode 100644 index 000000000..e4b3ec6a5 --- /dev/null +++ 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 . +*/ + +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

+// - 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 new file mode 100644 index 000000000..72bd3e885 --- /dev/null +++ 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 . +*/ + +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 index 88a56875c..8102bc35d 100644 --- 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/scripts/auth_flow.sh b/scripts/auth_flow.sh index 8bba39532..5552349a5 100755 --- 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 new file mode 100644 index 000000000..1caa18581 --- /dev/null +++ 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 . +*/ + +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 new file mode 100644 index 000000000..f7028b1b5 --- /dev/null +++ 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 . +*/ + +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 new file mode 100644 index 000000000..5974eae69 --- /dev/null +++ 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 . +*/ + +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{}{ + >smodel.Account{}, + >smodel.Application{}, + >smodel.Block{}, + >smodel.DomainBlock{}, + >smodel.EmailDomainBlock{}, + >smodel.Follow{}, + >smodel.FollowRequest{}, + >smodel.MediaAttachment{}, + >smodel.Mention{}, + >smodel.Status{}, + >smodel.StatusFave{}, + >smodel.StatusBookmark{}, + >smodel.StatusMute{}, + >smodel.StatusPin{}, + >smodel.Tag{}, + >smodel.User{}, + >smodel.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 new file mode 100644 index 000000000..e21321d53 --- /dev/null +++ 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 . +*/ + +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 new file mode 100644 index 000000000..0bafc96f7 --- /dev/null +++ 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 . +*/ + +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 new file mode 100644 index 000000000..10bdbdc95 --- /dev/null +++ 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 . +*/ + +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 new file mode 100755 index 000000000..349965160 Binary files /dev/null and b/testrig/media/ohyou-original.jpeg differ diff --git a/testrig/media/ohyou-small.jpeg b/testrig/media/ohyou-small.jpeg new file mode 100755 index 000000000..f561884d1 Binary files /dev/null and b/testrig/media/ohyou-small.jpeg differ diff --git a/testrig/media/rainbow-original.png b/testrig/media/rainbow-original.png new file mode 100755 index 000000000..fdbfaeec3 Binary files /dev/null and b/testrig/media/rainbow-original.png differ diff --git a/testrig/media/rainbow-static.png b/testrig/media/rainbow-static.png new file mode 100755 index 000000000..79ed5c03a Binary files /dev/null and b/testrig/media/rainbow-static.png differ diff --git a/testrig/media/trent-original.gif b/testrig/media/trent-original.gif new file mode 100755 index 000000000..2ba145c1a Binary files /dev/null and b/testrig/media/trent-original.gif differ diff --git a/testrig/media/trent-small.jpeg b/testrig/media/trent-small.jpeg new file mode 100755 index 000000000..726c1aed0 Binary files /dev/null and b/testrig/media/trent-small.jpeg differ diff --git a/testrig/media/welcome-original.jpeg b/testrig/media/welcome-original.jpeg new file mode 100755 index 000000000..1a54437ea Binary files /dev/null and b/testrig/media/welcome-original.jpeg differ diff --git a/testrig/media/welcome-small.jpeg b/testrig/media/welcome-small.jpeg new file mode 100755 index 000000000..b1a585169 Binary files /dev/null and b/testrig/media/welcome-small.jpeg differ diff --git a/testrig/media/zork-original.jpeg b/testrig/media/zork-original.jpeg new file mode 100644 index 000000000..7d8bc1fc7 Binary files /dev/null and b/testrig/media/zork-original.jpeg differ diff --git a/testrig/media/zork-small.jpeg b/testrig/media/zork-small.jpeg new file mode 100644 index 000000000..60be12564 Binary files /dev/null and b/testrig/media/zork-small.jpeg differ diff --git a/testrig/mediahandler.go b/testrig/mediahandler.go new file mode 100644 index 000000000..fd7986689 --- /dev/null +++ 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 . +*/ + +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 new file mode 100644 index 000000000..49615cadc --- /dev/null +++ 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 . +*/ + +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 new file mode 100644 index 000000000..abd168724 --- /dev/null +++ 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 . +*/ + +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 new file mode 100644 index 000000000..3b520364b --- /dev/null +++ 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 . +*/ + +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 new file mode 100644 index 000000000..f028bbd8d --- /dev/null +++ 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 . +*/ + +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: >smodel.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: >smodel.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: >smodel.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: >smodel.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: >smodel.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: >smodel.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: >smodel.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: >smodel.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: >smodel.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 new file mode 100644 index 000000000..96a979342 --- /dev/null +++ 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 . +*/ + +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 +}