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
+}