Api/v1/statuses (#11)
This PR adds: Statuses New status creation. View existing status Delete a status Fave a status Unfave a status See who's faved a status Media Upload media attachment and store/retrieve it Upload custom emoji and store/retrieve it Fileserver Serve files from storage Testing Test models, testrig -- run a GTS test instance and play around with it.
This commit is contained in:
parent
71a49e2b43
commit
32c5fd987a
19
PROGRESS.md
19
PROGRESS.md
|
@ -69,14 +69,14 @@
|
||||||
* [ ] /api/v1/suggestions GET (Get suggested accounts to follow)
|
* [ ] /api/v1/suggestions GET (Get suggested accounts to follow)
|
||||||
* [ ] /api/v1/suggestions/:account_id DELETE (Delete a suggestion)
|
* [ ] /api/v1/suggestions/:account_id DELETE (Delete a suggestion)
|
||||||
* [ ] Statuses
|
* [ ] Statuses
|
||||||
* [ ] /api/v1/statuses POST (Create a new status)
|
* [x] /api/v1/statuses POST (Create a new status)
|
||||||
* [ ] /api/v1/statuses/:id GET (View an existing status)
|
* [x] /api/v1/statuses/:id GET (View an existing status)
|
||||||
* [ ] /api/v1/statuses/:id DELETE (Delete a 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/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/reblogged_by GET (See who has reblogged a status)
|
||||||
* [ ] /api/v1/statuses/:id/favourited_by GET (See who has faved a status)
|
* [x] /api/v1/statuses/:id/favourited_by GET (See who has faved a status)
|
||||||
* [ ] /api/v1/statuses/:id/favourite POST (Fave a status)
|
* [x] /api/v1/statuses/:id/favourite POST (Fave a status)
|
||||||
* [ ] /api/v1/statuses/:id/favourite POST (Unfave 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/reblog POST (Reblog a status)
|
||||||
* [ ] /api/v1/statuses/:id/unreblog POST (Undo a reblog)
|
* [ ] /api/v1/statuses/:id/unreblog POST (Undo a reblog)
|
||||||
* [ ] /api/v1/statuses/:id/bookmark POST (Bookmark a status)
|
* [ ] /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/pin POST (Pin a status to profile)
|
||||||
* [ ] /api/v1/statuses/:id/unpin POST (Unpin a status from profile)
|
* [ ] /api/v1/statuses/:id/unpin POST (Unpin a status from profile)
|
||||||
* [ ] Media
|
* [ ] 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 GET (Get a media attachment)
|
||||||
* [ ] /api/v1/media/:id PUT (Update an attachment)
|
* [ ] /api/v1/media/:id PUT (Update an attachment)
|
||||||
* [ ] Polls
|
* [ ] Polls
|
||||||
|
@ -144,6 +144,7 @@
|
||||||
* [ ] Custom Emojis
|
* [ ] Custom Emojis
|
||||||
* [ ] /api/v1/custom_emojis GET (Show this server's custom emoji)
|
* [ ] /api/v1/custom_emojis GET (Show this server's custom emoji)
|
||||||
* [ ] Admin
|
* [ ] 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 GET (View accounts filtered by criteria)
|
||||||
* [ ] /api/v1/admin/accounts/:id GET (View admin level info about an account)
|
* [ ] /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)
|
* [ ] /api/v1/admin/accounts/:id/action POST (Perform an admin action on account)
|
||||||
|
@ -178,8 +179,8 @@
|
||||||
* [ ] Storage
|
* [ ] Storage
|
||||||
* [x] Internal/statuses/preferences etc
|
* [x] Internal/statuses/preferences etc
|
||||||
* [x] Postgres interface
|
* [x] Postgres interface
|
||||||
* [ ] Media storage
|
* [x] Media storage
|
||||||
* [ ] Local storage interface
|
* [x] Local storage interface
|
||||||
* [ ] S3 storage interface
|
* [ ] S3 storage interface
|
||||||
* [ ] Cache
|
* [ ] Cache
|
||||||
* [ ] In-memory cache
|
* [ ] In-memory cache
|
||||||
|
|
|
@ -28,6 +28,7 @@ import (
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gotosocial"
|
"github.com/superseriousbusiness/gotosocial/internal/gotosocial"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||||
|
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v2"
|
||||||
)
|
)
|
||||||
|
@ -35,6 +36,7 @@ import (
|
||||||
func main() {
|
func main() {
|
||||||
flagNames := config.GetFlagNames()
|
flagNames := config.GetFlagNames()
|
||||||
envNames := config.GetEnvNames()
|
envNames := config.GetEnvNames()
|
||||||
|
defaults := config.GetDefaults()
|
||||||
app := &cli.App{
|
app := &cli.App{
|
||||||
Usage: "a fediverse social media server",
|
Usage: "a fediverse social media server",
|
||||||
Flags: []cli.Flag{
|
Flags: []cli.Flag{
|
||||||
|
@ -42,32 +44,32 @@ func main() {
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: flagNames.LogLevel,
|
Name: flagNames.LogLevel,
|
||||||
Usage: "Log level to run at: debug, info, warn, fatal",
|
Usage: "Log level to run at: debug, info, warn, fatal",
|
||||||
Value: "info",
|
Value: defaults.LogLevel,
|
||||||
EnvVars: []string{"GTS_LOG_LEVEL"},
|
EnvVars: []string{envNames.LogLevel},
|
||||||
},
|
},
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: flagNames.ApplicationName,
|
Name: flagNames.ApplicationName,
|
||||||
Usage: "Name of the application, used in various places internally",
|
Usage: "Name of the application, used in various places internally",
|
||||||
Value: "gotosocial",
|
Value: defaults.ApplicationName,
|
||||||
EnvVars: []string{envNames.ApplicationName},
|
EnvVars: []string{envNames.ApplicationName},
|
||||||
Hidden: true,
|
Hidden: true,
|
||||||
},
|
},
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: flagNames.ConfigPath,
|
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",
|
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},
|
EnvVars: []string{envNames.ConfigPath},
|
||||||
},
|
},
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: flagNames.Host,
|
Name: flagNames.Host,
|
||||||
Usage: "Hostname to use for the server (eg., example.org, gotosocial.whatever.com)",
|
Usage: "Hostname to use for the server (eg., example.org, gotosocial.whatever.com)",
|
||||||
Value: "localhost",
|
Value: defaults.Host,
|
||||||
EnvVars: []string{envNames.Host},
|
EnvVars: []string{envNames.Host},
|
||||||
},
|
},
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: flagNames.Protocol,
|
Name: flagNames.Protocol,
|
||||||
Usage: "Protocol to use for the REST api of the server (only use http for debugging and tests!)",
|
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},
|
EnvVars: []string{envNames.Protocol},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -75,36 +77,37 @@ func main() {
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: flagNames.DbType,
|
Name: flagNames.DbType,
|
||||||
Usage: "Database type: eg., postgres",
|
Usage: "Database type: eg., postgres",
|
||||||
Value: "postgres",
|
Value: defaults.DbType,
|
||||||
EnvVars: []string{envNames.DbType},
|
EnvVars: []string{envNames.DbType},
|
||||||
},
|
},
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: flagNames.DbAddress,
|
Name: flagNames.DbAddress,
|
||||||
Usage: "Database ipv4 address or hostname",
|
Usage: "Database ipv4 address or hostname",
|
||||||
Value: "localhost",
|
Value: defaults.DbAddress,
|
||||||
EnvVars: []string{envNames.DbAddress},
|
EnvVars: []string{envNames.DbAddress},
|
||||||
},
|
},
|
||||||
&cli.IntFlag{
|
&cli.IntFlag{
|
||||||
Name: flagNames.DbPort,
|
Name: flagNames.DbPort,
|
||||||
Usage: "Database port",
|
Usage: "Database port",
|
||||||
Value: 5432,
|
Value: defaults.DbPort,
|
||||||
EnvVars: []string{envNames.DbPort},
|
EnvVars: []string{envNames.DbPort},
|
||||||
},
|
},
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: flagNames.DbUser,
|
Name: flagNames.DbUser,
|
||||||
Usage: "Database username",
|
Usage: "Database username",
|
||||||
Value: "postgres",
|
Value: defaults.DbUser,
|
||||||
EnvVars: []string{envNames.DbUser},
|
EnvVars: []string{envNames.DbUser},
|
||||||
},
|
},
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: flagNames.DbPassword,
|
Name: flagNames.DbPassword,
|
||||||
Usage: "Database password",
|
Usage: "Database password",
|
||||||
|
Value: defaults.DbPassword,
|
||||||
EnvVars: []string{envNames.DbPassword},
|
EnvVars: []string{envNames.DbPassword},
|
||||||
},
|
},
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: flagNames.DbDatabase,
|
Name: flagNames.DbDatabase,
|
||||||
Usage: "Database name",
|
Usage: "Database name",
|
||||||
Value: "postgres",
|
Value: defaults.DbDatabase,
|
||||||
EnvVars: []string{envNames.DbDatabase},
|
EnvVars: []string{envNames.DbDatabase},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -112,7 +115,7 @@ func main() {
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: flagNames.TemplateBaseDir,
|
Name: flagNames.TemplateBaseDir,
|
||||||
Usage: "Basedir for html templating files for rendering pages and composing emails.",
|
Usage: "Basedir for html templating files for rendering pages and composing emails.",
|
||||||
Value: "./web/template/",
|
Value: defaults.TemplateBaseDir,
|
||||||
EnvVars: []string{envNames.TemplateBaseDir},
|
EnvVars: []string{envNames.TemplateBaseDir},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -120,61 +123,111 @@ func main() {
|
||||||
&cli.BoolFlag{
|
&cli.BoolFlag{
|
||||||
Name: flagNames.AccountsOpenRegistration,
|
Name: flagNames.AccountsOpenRegistration,
|
||||||
Usage: "Allow anyone to submit an account signup request. If false, server will be invite-only.",
|
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},
|
EnvVars: []string{envNames.AccountsOpenRegistration},
|
||||||
},
|
},
|
||||||
&cli.BoolFlag{
|
&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.",
|
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,
|
Value: defaults.AccountsRequireApproval,
|
||||||
EnvVars: []string{envNames.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
|
// MEDIA FLAGS
|
||||||
&cli.IntFlag{
|
&cli.IntFlag{
|
||||||
Name: flagNames.MediaMaxImageSize,
|
Name: flagNames.MediaMaxImageSize,
|
||||||
Usage: "Max size of accepted images in bytes",
|
Usage: "Max size of accepted images in bytes",
|
||||||
Value: 1048576, // 1mb
|
Value: defaults.MediaMaxImageSize,
|
||||||
EnvVars: []string{envNames.MediaMaxImageSize},
|
EnvVars: []string{envNames.MediaMaxImageSize},
|
||||||
},
|
},
|
||||||
&cli.IntFlag{
|
&cli.IntFlag{
|
||||||
Name: flagNames.MediaMaxVideoSize,
|
Name: flagNames.MediaMaxVideoSize,
|
||||||
Usage: "Max size of accepted videos in bytes",
|
Usage: "Max size of accepted videos in bytes",
|
||||||
Value: 5242880, // 5mb
|
Value: defaults.MediaMaxVideoSize,
|
||||||
EnvVars: []string{envNames.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
|
// STORAGE FLAGS
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: flagNames.StorageBackend,
|
Name: flagNames.StorageBackend,
|
||||||
Usage: "Storage backend to use for media attachments",
|
Usage: "Storage backend to use for media attachments",
|
||||||
Value: "local",
|
Value: defaults.StorageBackend,
|
||||||
EnvVars: []string{envNames.StorageBackend},
|
EnvVars: []string{envNames.StorageBackend},
|
||||||
},
|
},
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: flagNames.StorageBasePath,
|
Name: flagNames.StorageBasePath,
|
||||||
Usage: "Full path to an already-created directory where gts should store/retrieve media files",
|
Usage: "Full path to an already-created directory where gts should store/retrieve media files. Subfolders will be created within this dir.",
|
||||||
Value: "/opt/gotosocial",
|
Value: defaults.StorageBasePath,
|
||||||
EnvVars: []string{envNames.StorageBasePath},
|
EnvVars: []string{envNames.StorageBasePath},
|
||||||
},
|
},
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: flagNames.StorageServeProtocol,
|
Name: flagNames.StorageServeProtocol,
|
||||||
Usage: "Protocol to use for serving media attachments (use https if storage is local)",
|
Usage: "Protocol to use for serving media attachments (use https if storage is local)",
|
||||||
Value: "https",
|
Value: defaults.StorageServeProtocol,
|
||||||
EnvVars: []string{envNames.StorageServeProtocol},
|
EnvVars: []string{envNames.StorageServeProtocol},
|
||||||
},
|
},
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: flagNames.StorageServeHost,
|
Name: flagNames.StorageServeHost,
|
||||||
Usage: "Hostname to serve media attachments from (use the same value as host if storage is local)",
|
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},
|
EnvVars: []string{envNames.StorageServeHost},
|
||||||
},
|
},
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: flagNames.StorageServeBasePath,
|
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)",
|
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},
|
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{
|
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)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -28,7 +28,9 @@ import (
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/apimodule"
|
"github.com/superseriousbusiness/gotosocial/internal/apimodule"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
"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/media"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/router"
|
"github.com/superseriousbusiness/gotosocial/internal/router"
|
||||||
|
@ -47,16 +49,18 @@ type accountModule struct {
|
||||||
db db.DB
|
db db.DB
|
||||||
oauthServer oauth.Server
|
oauthServer oauth.Server
|
||||||
mediaHandler media.MediaHandler
|
mediaHandler media.MediaHandler
|
||||||
|
mastoConverter mastotypes.Converter
|
||||||
log *logrus.Logger
|
log *logrus.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns a new account module
|
// 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{
|
return &accountModule{
|
||||||
config: config,
|
config: config,
|
||||||
db: db,
|
db: db,
|
||||||
oauthServer: oauthServer,
|
oauthServer: oauthServer,
|
||||||
mediaHandler: mediaHandler,
|
mediaHandler: mediaHandler,
|
||||||
|
mastoConverter: mastoConverter,
|
||||||
log: log,
|
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 {
|
func (m *accountModule) Route(r router.Router) error {
|
||||||
r.AttachHandler(http.MethodPost, basePath, m.accountCreatePOSTHandler)
|
r.AttachHandler(http.MethodPost, basePath, m.accountCreatePOSTHandler)
|
||||||
r.AttachHandler(http.MethodGet, basePathWithID, m.muxHandler)
|
r.AttachHandler(http.MethodGet, basePathWithID, m.muxHandler)
|
||||||
|
r.AttachHandler(http.MethodPatch, basePathWithID, m.muxHandler)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *accountModule) CreateTables(db db.DB) error {
|
func (m *accountModule) CreateTables(db db.DB) error {
|
||||||
models := []interface{}{
|
models := []interface{}{
|
||||||
&model.User{},
|
>smodel.User{},
|
||||||
&model.Account{},
|
>smodel.Account{},
|
||||||
&model.Follow{},
|
>smodel.Follow{},
|
||||||
&model.FollowRequest{},
|
>smodel.FollowRequest{},
|
||||||
&model.Status{},
|
>smodel.Status{},
|
||||||
&model.Application{},
|
>smodel.Application{},
|
||||||
&model.EmailDomainBlock{},
|
>smodel.EmailDomainBlock{},
|
||||||
&model.MediaAttachment{},
|
>smodel.MediaAttachment{},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, m := range models {
|
for _, m := range models {
|
||||||
|
@ -90,11 +95,16 @@ func (m *accountModule) CreateTables(db db.DB) error {
|
||||||
|
|
||||||
func (m *accountModule) muxHandler(c *gin.Context) {
|
func (m *accountModule) muxHandler(c *gin.Context) {
|
||||||
ru := c.Request.RequestURI
|
ru := c.Request.RequestURI
|
||||||
|
switch c.Request.Method {
|
||||||
|
case http.MethodGet:
|
||||||
if strings.HasPrefix(ru, verifyPath) {
|
if strings.HasPrefix(ru, verifyPath) {
|
||||||
m.accountVerifyGETHandler(c)
|
m.accountVerifyGETHandler(c)
|
||||||
} else if strings.HasPrefix(ru, updateCredentialsPath) {
|
|
||||||
m.accountUpdateCredentialsPATCHHandler(c)
|
|
||||||
} else {
|
} else {
|
||||||
m.accountGETHandler(c)
|
m.accountGETHandler(c)
|
||||||
}
|
}
|
||||||
|
case http.MethodPatch:
|
||||||
|
if strings.HasPrefix(ru, updateCredentialsPath) {
|
||||||
|
m.accountUpdateCredentialsPATCHHandler(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,10 +27,10 @@ import (
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
"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/oauth"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||||
"github.com/superseriousbusiness/gotosocial/pkg/mastotypes"
|
|
||||||
"github.com/superseriousbusiness/oauth2/v4"
|
"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.
|
// 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
|
// 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/
|
// 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")
|
l := m.log.WithField("func", "accountCreate")
|
||||||
|
|
||||||
// don't store a reason if we don't require one
|
// don't store a reason if we don't require one
|
||||||
|
|
|
@ -41,11 +41,13 @@ import (
|
||||||
"github.com/stretchr/testify/suite"
|
"github.com/stretchr/testify/suite"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
"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/media"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
||||||
"github.com/superseriousbusiness/gotosocial/pkg/mastotypes"
|
|
||||||
"github.com/superseriousbusiness/oauth2/v4"
|
"github.com/superseriousbusiness/oauth2/v4"
|
||||||
"github.com/superseriousbusiness/oauth2/v4/models"
|
"github.com/superseriousbusiness/oauth2/v4/models"
|
||||||
oauthmodels "github.com/superseriousbusiness/oauth2/v4/models"
|
oauthmodels "github.com/superseriousbusiness/oauth2/v4/models"
|
||||||
|
@ -56,12 +58,13 @@ type AccountCreateTestSuite struct {
|
||||||
suite.Suite
|
suite.Suite
|
||||||
config *config.Config
|
config *config.Config
|
||||||
log *logrus.Logger
|
log *logrus.Logger
|
||||||
testAccountLocal *model.Account
|
testAccountLocal *gtsmodel.Account
|
||||||
testApplication *model.Application
|
testApplication *gtsmodel.Application
|
||||||
testToken oauth2.TokenInfo
|
testToken oauth2.TokenInfo
|
||||||
mockOauthServer *oauth.MockServer
|
mockOauthServer *oauth.MockServer
|
||||||
mockStorage *storage.MockStorage
|
mockStorage *storage.MockStorage
|
||||||
mediaHandler media.MediaHandler
|
mediaHandler media.MediaHandler
|
||||||
|
mastoConverter mastotypes.Converter
|
||||||
db db.DB
|
db db.DB
|
||||||
accountModule *accountModule
|
accountModule *accountModule
|
||||||
newUserFormHappyPath url.Values
|
newUserFormHappyPath url.Values
|
||||||
|
@ -78,13 +81,13 @@ func (suite *AccountCreateTestSuite) SetupSuite() {
|
||||||
log.SetLevel(logrus.TraceLevel)
|
log.SetLevel(logrus.TraceLevel)
|
||||||
suite.log = log
|
suite.log = log
|
||||||
|
|
||||||
suite.testAccountLocal = &model.Account{
|
suite.testAccountLocal = >smodel.Account{
|
||||||
ID: uuid.NewString(),
|
ID: uuid.NewString(),
|
||||||
Username: "test_user",
|
Username: "test_user",
|
||||||
}
|
}
|
||||||
|
|
||||||
// can use this test application throughout
|
// can use this test application throughout
|
||||||
suite.testApplication = &model.Application{
|
suite.testApplication = >smodel.Application{
|
||||||
ID: "weeweeeeeeeeeeeeee",
|
ID: "weeweeeeeeeeeeeeee",
|
||||||
Name: "a test application",
|
Name: "a test application",
|
||||||
Website: "https://some-application-website.com",
|
Website: "https://some-application-website.com",
|
||||||
|
@ -148,7 +151,7 @@ func (suite *AccountCreateTestSuite) SetupSuite() {
|
||||||
userID := args.Get(2).(string)
|
userID := args.Get(2).(string)
|
||||||
l.Infof("received userID %+v", userID)
|
l.Infof("received userID %+v", userID)
|
||||||
}).Return(&models.Token{
|
}).Return(&models.Token{
|
||||||
Code: "we're authorized now!",
|
Access: "we're authorized now!",
|
||||||
}, nil)
|
}, nil)
|
||||||
|
|
||||||
suite.mockStorage = &storage.MockStorage{}
|
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)
|
// 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.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!
|
// 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() {
|
func (suite *AccountCreateTestSuite) TearDownSuite() {
|
||||||
|
@ -172,14 +177,14 @@ func (suite *AccountCreateTestSuite) TearDownSuite() {
|
||||||
func (suite *AccountCreateTestSuite) SetupTest() {
|
func (suite *AccountCreateTestSuite) SetupTest() {
|
||||||
// create all the tables we might need in thie suite
|
// create all the tables we might need in thie suite
|
||||||
models := []interface{}{
|
models := []interface{}{
|
||||||
&model.User{},
|
>smodel.User{},
|
||||||
&model.Account{},
|
>smodel.Account{},
|
||||||
&model.Follow{},
|
>smodel.Follow{},
|
||||||
&model.FollowRequest{},
|
>smodel.FollowRequest{},
|
||||||
&model.Status{},
|
>smodel.Status{},
|
||||||
&model.Application{},
|
>smodel.Application{},
|
||||||
&model.EmailDomainBlock{},
|
>smodel.EmailDomainBlock{},
|
||||||
&model.MediaAttachment{},
|
>smodel.MediaAttachment{},
|
||||||
}
|
}
|
||||||
for _, m := range models {
|
for _, m := range models {
|
||||||
if err := suite.db.CreateTable(m); err != nil {
|
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
|
// remove all the tables we might have used so it's clear for the next test
|
||||||
models := []interface{}{
|
models := []interface{}{
|
||||||
&model.User{},
|
>smodel.User{},
|
||||||
&model.Account{},
|
>smodel.Account{},
|
||||||
&model.Follow{},
|
>smodel.Follow{},
|
||||||
&model.FollowRequest{},
|
>smodel.FollowRequest{},
|
||||||
&model.Status{},
|
>smodel.Status{},
|
||||||
&model.Application{},
|
>smodel.Application{},
|
||||||
&model.EmailDomainBlock{},
|
>smodel.EmailDomainBlock{},
|
||||||
&model.MediaAttachment{},
|
>smodel.MediaAttachment{},
|
||||||
}
|
}
|
||||||
for _, m := range models {
|
for _, m := range models {
|
||||||
if err := suite.db.DropTable(m); err != nil {
|
if err := suite.db.DropTable(m); err != nil {
|
||||||
|
@ -259,7 +264,7 @@ func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerSuccessful() {
|
||||||
defer result.Body.Close()
|
defer result.Body.Close()
|
||||||
b, err := ioutil.ReadAll(result.Body)
|
b, err := ioutil.ReadAll(result.Body)
|
||||||
assert.NoError(suite.T(), err)
|
assert.NoError(suite.T(), err)
|
||||||
t := &mastotypes.Token{}
|
t := &mastomodel.Token{}
|
||||||
err = json.Unmarshal(b, t)
|
err = json.Unmarshal(b, t)
|
||||||
assert.NoError(suite.T(), err)
|
assert.NoError(suite.T(), err)
|
||||||
assert.Equal(suite.T(), "we're authorized now!", t.AccessToken)
|
assert.Equal(suite.T(), "we're authorized now!", t.AccessToken)
|
||||||
|
@ -267,7 +272,7 @@ func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerSuccessful() {
|
||||||
// check new account
|
// check new account
|
||||||
|
|
||||||
// 1. we should be able to get the new account from the db
|
// 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)
|
err = suite.db.GetWhere("username", "test_user", acct)
|
||||||
assert.NoError(suite.T(), err)
|
assert.NoError(suite.T(), err)
|
||||||
assert.NotNil(suite.T(), acct)
|
assert.NotNil(suite.T(), acct)
|
||||||
|
@ -288,7 +293,7 @@ func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerSuccessful() {
|
||||||
// check new user
|
// check new user
|
||||||
|
|
||||||
// 1. we should be able to get the new user from the db
|
// 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)
|
err = suite.db.GetWhere("unconfirmed_email", suite.newUserFormHappyPath.Get("email"), usr)
|
||||||
assert.Nil(suite.T(), err)
|
assert.Nil(suite.T(), err)
|
||||||
assert.NotNil(suite.T(), usr)
|
assert.NotNil(suite.T(), usr)
|
||||||
|
|
|
@ -23,7 +23,7 @@ import (
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
"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
|
// 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
targetAccount := &model.Account{}
|
targetAccount := >smodel.Account{}
|
||||||
if err := m.db.GetByID(targetAcctID, targetAccount); err != nil {
|
if err := m.db.GetByID(targetAcctID, targetAccount); err != nil {
|
||||||
if _, ok := err.(db.ErrNoEntries); ok {
|
if _, ok := err.(db.ErrNoEntries); ok {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Record not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Record not found"})
|
||||||
|
@ -47,7 +47,7 @@ func (m *accountModule) accountGETHandler(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
acctInfo, err := m.db.AccountToMastoPublic(targetAccount)
|
acctInfo, err := m.mastoConverter.AccountToMastoPublic(targetAccount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
|
|
|
@ -27,10 +27,11 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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/oauth"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||||
"github.com/superseriousbusiness/gotosocial/pkg/mastotypes"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// accountUpdateCredentialsPATCHHandler allows a user to modify their account/profile settings.
|
// 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 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)
|
l.Debugf("error updating discoverable: %s", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
|
@ -75,7 +76,7 @@ func (m *accountModule) accountUpdateCredentialsPATCHHandler(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if form.Bot != nil {
|
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)
|
l.Debugf("error updating bot: %s", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
|
@ -87,7 +88,7 @@ func (m *accountModule) accountUpdateCredentialsPATCHHandler(c *gin.Context) {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
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()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -98,7 +99,7 @@ func (m *accountModule) accountUpdateCredentialsPATCHHandler(c *gin.Context) {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
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)
|
l.Debugf("error updating note: %s", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
|
@ -126,7 +127,7 @@ func (m *accountModule) accountUpdateCredentialsPATCHHandler(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if form.Locked != nil {
|
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()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -138,14 +139,14 @@ func (m *accountModule) accountUpdateCredentialsPATCHHandler(c *gin.Context) {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
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()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if form.Source.Sensitive != nil {
|
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()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -156,7 +157,7 @@ func (m *accountModule) accountUpdateCredentialsPATCHHandler(c *gin.Context) {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
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()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -168,14 +169,14 @@ func (m *accountModule) accountUpdateCredentialsPATCHHandler(c *gin.Context) {
|
||||||
// }
|
// }
|
||||||
|
|
||||||
// fetch the account with all updated values set
|
// 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 {
|
if err := m.db.GetByID(authed.Account.ID, updatedAccount); err != nil {
|
||||||
l.Debugf("could not fetch updated account %s: %s", authed.Account.ID, err)
|
l.Debugf("could not fetch updated account %s: %s", authed.Account.ID, err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
acctSensitive, err := m.db.AccountToMastoSensitive(updatedAccount)
|
acctSensitive, err := m.mastoConverter.AccountToMastoSensitive(updatedAccount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Tracef("could not convert account into mastosensitive account: %s", err)
|
l.Tracef("could not convert account into mastosensitive account: %s", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
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,
|
// 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
|
// parsing and checking the image, and doing the necessary updates in the database for this to become
|
||||||
// the account's new avatar image.
|
// 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
|
var err error
|
||||||
if int(avatar.Size) > m.config.MediaConfig.MaxImageSize {
|
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)
|
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
|
// 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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error processing avatar: %s", err)
|
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,
|
// 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
|
// parsing and checking the image, and doing the necessary updates in the database for this to become
|
||||||
// the account's new header image.
|
// 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
|
var err error
|
||||||
if int(header.Size) > m.config.MediaConfig.MaxImageSize {
|
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)
|
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
|
// 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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error processing header: %s", err)
|
return nil, fmt.Errorf("error processing header: %s", err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -39,7 +39,8 @@ import (
|
||||||
"github.com/stretchr/testify/suite"
|
"github.com/stretchr/testify/suite"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
"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/media"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
||||||
|
@ -52,12 +53,13 @@ type AccountUpdateTestSuite struct {
|
||||||
suite.Suite
|
suite.Suite
|
||||||
config *config.Config
|
config *config.Config
|
||||||
log *logrus.Logger
|
log *logrus.Logger
|
||||||
testAccountLocal *model.Account
|
testAccountLocal *gtsmodel.Account
|
||||||
testApplication *model.Application
|
testApplication *gtsmodel.Application
|
||||||
testToken oauth2.TokenInfo
|
testToken oauth2.TokenInfo
|
||||||
mockOauthServer *oauth.MockServer
|
mockOauthServer *oauth.MockServer
|
||||||
mockStorage *storage.MockStorage
|
mockStorage *storage.MockStorage
|
||||||
mediaHandler media.MediaHandler
|
mediaHandler media.MediaHandler
|
||||||
|
mastoConverter mastotypes.Converter
|
||||||
db db.DB
|
db db.DB
|
||||||
accountModule *accountModule
|
accountModule *accountModule
|
||||||
newUserFormHappyPath url.Values
|
newUserFormHappyPath url.Values
|
||||||
|
@ -74,13 +76,13 @@ func (suite *AccountUpdateTestSuite) SetupSuite() {
|
||||||
log.SetLevel(logrus.TraceLevel)
|
log.SetLevel(logrus.TraceLevel)
|
||||||
suite.log = log
|
suite.log = log
|
||||||
|
|
||||||
suite.testAccountLocal = &model.Account{
|
suite.testAccountLocal = >smodel.Account{
|
||||||
ID: uuid.NewString(),
|
ID: uuid.NewString(),
|
||||||
Username: "test_user",
|
Username: "test_user",
|
||||||
}
|
}
|
||||||
|
|
||||||
// can use this test application throughout
|
// can use this test application throughout
|
||||||
suite.testApplication = &model.Application{
|
suite.testApplication = >smodel.Application{
|
||||||
ID: "weeweeeeeeeeeeeeee",
|
ID: "weeweeeeeeeeeeeeee",
|
||||||
Name: "a test application",
|
Name: "a test application",
|
||||||
Website: "https://some-application-website.com",
|
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)
|
// 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.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!
|
// 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() {
|
func (suite *AccountUpdateTestSuite) TearDownSuite() {
|
||||||
|
@ -168,14 +172,14 @@ func (suite *AccountUpdateTestSuite) TearDownSuite() {
|
||||||
func (suite *AccountUpdateTestSuite) SetupTest() {
|
func (suite *AccountUpdateTestSuite) SetupTest() {
|
||||||
// create all the tables we might need in thie suite
|
// create all the tables we might need in thie suite
|
||||||
models := []interface{}{
|
models := []interface{}{
|
||||||
&model.User{},
|
>smodel.User{},
|
||||||
&model.Account{},
|
>smodel.Account{},
|
||||||
&model.Follow{},
|
>smodel.Follow{},
|
||||||
&model.FollowRequest{},
|
>smodel.FollowRequest{},
|
||||||
&model.Status{},
|
>smodel.Status{},
|
||||||
&model.Application{},
|
>smodel.Application{},
|
||||||
&model.EmailDomainBlock{},
|
>smodel.EmailDomainBlock{},
|
||||||
&model.MediaAttachment{},
|
>smodel.MediaAttachment{},
|
||||||
}
|
}
|
||||||
for _, m := range models {
|
for _, m := range models {
|
||||||
if err := suite.db.CreateTable(m); err != nil {
|
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
|
// remove all the tables we might have used so it's clear for the next test
|
||||||
models := []interface{}{
|
models := []interface{}{
|
||||||
&model.User{},
|
>smodel.User{},
|
||||||
&model.Account{},
|
>smodel.Account{},
|
||||||
&model.Follow{},
|
>smodel.Follow{},
|
||||||
&model.FollowRequest{},
|
>smodel.FollowRequest{},
|
||||||
&model.Status{},
|
>smodel.Status{},
|
||||||
&model.Application{},
|
>smodel.Application{},
|
||||||
&model.EmailDomainBlock{},
|
>smodel.EmailDomainBlock{},
|
||||||
&model.MediaAttachment{},
|
>smodel.MediaAttachment{},
|
||||||
}
|
}
|
||||||
for _, m := range models {
|
for _, m := range models {
|
||||||
if err := suite.db.DropTable(m); err != nil {
|
if err := suite.db.DropTable(m); err != nil {
|
||||||
|
|
|
@ -38,7 +38,7 @@ func (m *accountModule) accountVerifyGETHandler(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
l.Tracef("retrieved account %+v, converting to mastosensitive...", authed.Account.ID)
|
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 {
|
if err != nil {
|
||||||
l.Tracef("could not convert account into mastosensitive account: %s", err)
|
l.Tracef("could not convert account into mastosensitive account: %s", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
|
|
@ -0,0 +1,84 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/apimodule"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/mastotypes"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
basePath = "/api/v1/admin"
|
||||||
|
emojiPath = basePath + "/custom_emojis"
|
||||||
|
)
|
||||||
|
|
||||||
|
type adminModule struct {
|
||||||
|
config *config.Config
|
||||||
|
db db.DB
|
||||||
|
mediaHandler media.MediaHandler
|
||||||
|
mastoConverter mastotypes.Converter
|
||||||
|
log *logrus.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a new account module
|
||||||
|
func New(config *config.Config, db db.DB, mediaHandler media.MediaHandler, mastoConverter mastotypes.Converter, log *logrus.Logger) apimodule.ClientAPIModule {
|
||||||
|
return &adminModule{
|
||||||
|
config: config,
|
||||||
|
db: db,
|
||||||
|
mediaHandler: mediaHandler,
|
||||||
|
mastoConverter: mastoConverter,
|
||||||
|
log: log,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route attaches all routes from this module to the given router
|
||||||
|
func (m *adminModule) Route(r router.Router) error {
|
||||||
|
r.AttachHandler(http.MethodPost, emojiPath, m.emojiCreatePOSTHandler)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *adminModule) CreateTables(db db.DB) error {
|
||||||
|
models := []interface{}{
|
||||||
|
>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
|
||||||
|
}
|
|
@ -0,0 +1,130 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m *adminModule) emojiCreatePOSTHandler(c *gin.Context) {
|
||||||
|
l := m.log.WithFields(logrus.Fields{
|
||||||
|
"func": "emojiCreatePOSTHandler",
|
||||||
|
"request_uri": c.Request.RequestURI,
|
||||||
|
"user_agent": c.Request.UserAgent(),
|
||||||
|
"origin_ip": c.ClientIP(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// make sure we're authed with an admin account
|
||||||
|
authed, err := oauth.MustAuth(c, true, true, true, true) // posting a status is serious business so we want *everything*
|
||||||
|
if err != nil {
|
||||||
|
l.Debugf("couldn't auth: %s", err)
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !authed.User.Admin {
|
||||||
|
l.Debugf("user %s not an admin", authed.User.ID)
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "not an admin"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// extract the media create form from the request context
|
||||||
|
l.Tracef("parsing request form: %+v", c.Request.Form)
|
||||||
|
form := &mastotypes.EmojiCreateRequest{}
|
||||||
|
if err := c.ShouldBind(form); err != nil {
|
||||||
|
l.Debugf("error parsing form %+v: %s", c.Request.Form, err)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not parse form: %s", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give the fields on the request form a first pass to make sure the request is superficially valid.
|
||||||
|
l.Tracef("validating form %+v", form)
|
||||||
|
if err := validateCreateEmoji(form); err != nil {
|
||||||
|
l.Debugf("error validating form: %s", err)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// open the emoji and extract the bytes from it
|
||||||
|
f, err := form.Image.Open()
|
||||||
|
if err != nil {
|
||||||
|
l.Debugf("error opening emoji: %s", err)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not open provided emoji: %s", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
size, err := io.Copy(buf, f)
|
||||||
|
if err != nil {
|
||||||
|
l.Debugf("error reading emoji: %s", err)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not read provided emoji: %s", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if size == 0 {
|
||||||
|
l.Debug("could not read provided emoji: size 0 bytes")
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "could not read provided emoji: size 0 bytes"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// allow the mediaHandler to work its magic of processing the emoji bytes, and putting them in whatever storage backend we're using
|
||||||
|
emoji, err := m.mediaHandler.ProcessLocalEmoji(buf.Bytes(), form.Shortcode)
|
||||||
|
if err != nil {
|
||||||
|
l.Debugf("error reading emoji: %s", err)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not process emoji: %s", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mastoEmoji, err := m.mastoConverter.EmojiToMasto(emoji)
|
||||||
|
if err != nil {
|
||||||
|
l.Debugf("error converting emoji to mastotype: %s", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("could not convert emoji: %s", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.db.Put(emoji); err != nil {
|
||||||
|
l.Debugf("database error while processing emoji: %s", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("database error while processing emoji: %s", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, mastoEmoji)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCreateEmoji(form *mastotypes.EmojiCreateRequest) error {
|
||||||
|
// check there actually is an image attached and it's not size 0
|
||||||
|
if form.Image == nil || form.Image.Size == 0 {
|
||||||
|
return errors.New("no emoji given")
|
||||||
|
}
|
||||||
|
|
||||||
|
// a very superficial check to see if the media size limit is exceeded
|
||||||
|
if form.Image.Size > media.EmojiMaxBytes {
|
||||||
|
return fmt.Errorf("file size limit exceeded: limit is %d bytes but emoji was %d bytes", media.EmojiMaxBytes, form.Image.Size)
|
||||||
|
}
|
||||||
|
|
||||||
|
return util.ValidateEmojiShortcode(form.Shortcode)
|
||||||
|
}
|
|
@ -25,8 +25,8 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// ClientAPIModule represents a chunk of code (usually contained in a single package) that adds a set
|
// 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 ;)
|
// 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 corresponds roughly to one main path of the gotosocial REST api, for example /api/v1/accounts/ or /oauth/
|
// 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 {
|
type ClientAPIModule interface {
|
||||||
Route(s router.Router) error
|
Route(s router.Router) error
|
||||||
CreateTables(db db.DB) error
|
CreateTables(db db.DB) error
|
||||||
|
|
|
@ -25,7 +25,8 @@ import (
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/apimodule"
|
"github.com/superseriousbusiness/gotosocial/internal/apimodule"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
"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/oauth"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/router"
|
"github.com/superseriousbusiness/gotosocial/internal/router"
|
||||||
)
|
)
|
||||||
|
@ -35,14 +36,16 @@ const appsPath = "/api/v1/apps"
|
||||||
type appModule struct {
|
type appModule struct {
|
||||||
server oauth.Server
|
server oauth.Server
|
||||||
db db.DB
|
db db.DB
|
||||||
|
mastoConverter mastotypes.Converter
|
||||||
log *logrus.Logger
|
log *logrus.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns a new auth module
|
// 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{
|
return &appModule{
|
||||||
server: srv,
|
server: srv,
|
||||||
db: db,
|
db: db,
|
||||||
|
mastoConverter: mastoConverter,
|
||||||
log: log,
|
log: log,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -57,9 +60,9 @@ func (m *appModule) CreateTables(db db.DB) error {
|
||||||
models := []interface{}{
|
models := []interface{}{
|
||||||
&oauth.Client{},
|
&oauth.Client{},
|
||||||
&oauth.Token{},
|
&oauth.Token{},
|
||||||
&model.User{},
|
>smodel.User{},
|
||||||
&model.Account{},
|
>smodel.Account{},
|
||||||
&model.Application{},
|
>smodel.Application{},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, m := range models {
|
for _, m := range models {
|
||||||
|
|
|
@ -24,9 +24,9 @@ import (
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"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/internal/oauth"
|
||||||
"github.com/superseriousbusiness/gotosocial/pkg/mastotypes"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// appsPOSTHandler should be served at https://example.org/api/v1/apps
|
// 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()
|
vapidKey := uuid.NewString()
|
||||||
|
|
||||||
// generate the application to put in the database
|
// generate the application to put in the database
|
||||||
app := &model.Application{
|
app := >smodel.Application{
|
||||||
Name: form.ClientName,
|
Name: form.ClientName,
|
||||||
Website: form.Website,
|
Website: form.Website,
|
||||||
RedirectURI: form.RedirectURIs,
|
RedirectURI: form.RedirectURIs,
|
||||||
|
@ -108,6 +108,12 @@ func (m *appModule) appsPOSTHandler(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// done, return the new app information per the spec here: https://docs.joinmastodon.org/methods/apps/
|
mastoApp, err := m.mastoConverter.AppToMastoSensitive(app)
|
||||||
c.JSON(http.StatusOK, app.ToMasto())
|
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, mastoApp)
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,7 +31,7 @@ import (
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/apimodule"
|
"github.com/superseriousbusiness/gotosocial/internal/apimodule"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
"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/oauth"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/router"
|
"github.com/superseriousbusiness/gotosocial/internal/router"
|
||||||
)
|
)
|
||||||
|
@ -75,9 +75,9 @@ func (m *authModule) CreateTables(db db.DB) error {
|
||||||
models := []interface{}{
|
models := []interface{}{
|
||||||
&oauth.Client{},
|
&oauth.Client{},
|
||||||
&oauth.Token{},
|
&oauth.Token{},
|
||||||
&model.User{},
|
>smodel.User{},
|
||||||
&model.Account{},
|
>smodel.Account{},
|
||||||
&model.Application{},
|
>smodel.Application{},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, m := range models {
|
for _, m := range models {
|
||||||
|
|
|
@ -22,16 +22,14 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
"github.com/stretchr/testify/suite"
|
"github.com/stretchr/testify/suite"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
"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/oauth"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/router"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -39,9 +37,9 @@ type AuthTestSuite struct {
|
||||||
suite.Suite
|
suite.Suite
|
||||||
oauthServer oauth.Server
|
oauthServer oauth.Server
|
||||||
db db.DB
|
db db.DB
|
||||||
testAccount *model.Account
|
testAccount *gtsmodel.Account
|
||||||
testApplication *model.Application
|
testApplication *gtsmodel.Application
|
||||||
testUser *model.User
|
testUser *gtsmodel.User
|
||||||
testClient *oauth.Client
|
testClient *oauth.Client
|
||||||
config *config.Config
|
config *config.Config
|
||||||
}
|
}
|
||||||
|
@ -75,11 +73,11 @@ func (suite *AuthTestSuite) SetupSuite() {
|
||||||
|
|
||||||
acctID := uuid.NewString()
|
acctID := uuid.NewString()
|
||||||
|
|
||||||
suite.testAccount = &model.Account{
|
suite.testAccount = >smodel.Account{
|
||||||
ID: acctID,
|
ID: acctID,
|
||||||
Username: "test_user",
|
Username: "test_user",
|
||||||
}
|
}
|
||||||
suite.testUser = &model.User{
|
suite.testUser = >smodel.User{
|
||||||
EncryptedPassword: string(encryptedPassword),
|
EncryptedPassword: string(encryptedPassword),
|
||||||
Email: "user@example.org",
|
Email: "user@example.org",
|
||||||
AccountID: acctID,
|
AccountID: acctID,
|
||||||
|
@ -89,7 +87,7 @@ func (suite *AuthTestSuite) SetupSuite() {
|
||||||
Secret: "some-secret",
|
Secret: "some-secret",
|
||||||
Domain: fmt.Sprintf("%s://%s", c.Protocol, c.Host),
|
Domain: fmt.Sprintf("%s://%s", c.Protocol, c.Host),
|
||||||
}
|
}
|
||||||
suite.testApplication = &model.Application{
|
suite.testApplication = >smodel.Application{
|
||||||
Name: "a test application",
|
Name: "a test application",
|
||||||
Website: "https://some-application-website.com",
|
Website: "https://some-application-website.com",
|
||||||
RedirectURI: "http://localhost:8080",
|
RedirectURI: "http://localhost:8080",
|
||||||
|
@ -115,9 +113,9 @@ func (suite *AuthTestSuite) SetupTest() {
|
||||||
models := []interface{}{
|
models := []interface{}{
|
||||||
&oauth.Client{},
|
&oauth.Client{},
|
||||||
&oauth.Token{},
|
&oauth.Token{},
|
||||||
&model.User{},
|
>smodel.User{},
|
||||||
&model.Account{},
|
>smodel.Account{},
|
||||||
&model.Application{},
|
>smodel.Application{},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, m := range models {
|
for _, m := range models {
|
||||||
|
@ -148,9 +146,9 @@ func (suite *AuthTestSuite) TearDownTest() {
|
||||||
models := []interface{}{
|
models := []interface{}{
|
||||||
&oauth.Client{},
|
&oauth.Client{},
|
||||||
&oauth.Token{},
|
&oauth.Token{},
|
||||||
&model.User{},
|
>smodel.User{},
|
||||||
&model.Account{},
|
>smodel.Account{},
|
||||||
&model.Application{},
|
>smodel.Application{},
|
||||||
}
|
}
|
||||||
for _, m := range models {
|
for _, m := range models {
|
||||||
if err := suite.db.DropTable(m); err != nil {
|
if err := suite.db.DropTable(m); err != nil {
|
||||||
|
@ -163,27 +161,6 @@ func (suite *AuthTestSuite) TearDownTest() {
|
||||||
suite.db = nil
|
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) {
|
func TestAuthTestSuite(t *testing.T) {
|
||||||
suite.Run(t, new(AuthTestSuite))
|
suite.Run(t, new(AuthTestSuite))
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,8 +27,8 @@ import (
|
||||||
"github.com/gin-contrib/sessions"
|
"github.com/gin-contrib/sessions"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/db/model"
|
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||||
"github.com/superseriousbusiness/gotosocial/pkg/mastotypes"
|
"github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
|
||||||
)
|
)
|
||||||
|
|
||||||
// authorizeGETHandler should be served as GET at https://example.org/oauth/authorize
|
// 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"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "no client_id found in session"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
app := &model.Application{
|
app := >smodel.Application{
|
||||||
ClientID: clientID,
|
ClientID: clientID,
|
||||||
}
|
}
|
||||||
if err := m.db.GetWhere("client_id", app.ClientID, app); err != nil {
|
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
|
// 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,
|
ID: userID,
|
||||||
}
|
}
|
||||||
if err := m.db.GetByID(user.ID, user); err != nil {
|
if err := m.db.GetByID(user.ID, user); err != nil {
|
||||||
|
@ -74,7 +74,7 @@ func (m *authModule) authorizeGETHandler(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
acct := &model.Account{
|
acct := >smodel.Account{
|
||||||
ID: user.AccountID,
|
ID: user.AccountID,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -20,7 +20,7 @@ package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/gin-gonic/gin"
|
"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"
|
"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())
|
l.Tracef("authenticated user %s with bearer token, scope is %s", uid, ti.GetScope())
|
||||||
|
|
||||||
// fetch user's and account for this user id
|
// 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 {
|
if err := m.db.GetByID(uid, user); err != nil || user == nil {
|
||||||
l.Warnf("no user found for validated uid %s", uid)
|
l.Warnf("no user found for validated uid %s", uid)
|
||||||
return
|
return
|
||||||
|
@ -54,7 +54,7 @@ func (m *authModule) oauthTokenMiddleware(c *gin.Context) {
|
||||||
c.Set(oauth.SessionAuthorizedUser, user)
|
c.Set(oauth.SessionAuthorizedUser, user)
|
||||||
l.Tracef("set gin context %s to %+v", 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 {
|
if err := m.db.GetByID(user.AccountID, acct); err != nil || acct == nil {
|
||||||
l.Warnf("no account found for validated user %s", uid)
|
l.Warnf("no account found for validated user %s", uid)
|
||||||
return
|
return
|
||||||
|
@ -66,7 +66,7 @@ func (m *authModule) oauthTokenMiddleware(c *gin.Context) {
|
||||||
// check for application token
|
// check for application token
|
||||||
if cid := ti.GetClientID(); cid != "" {
|
if cid := ti.GetClientID(); cid != "" {
|
||||||
l.Tracef("authenticated client %s with bearer token, scope is %s", cid, ti.GetScope())
|
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 {
|
if err := m.db.GetWhere("client_id", cid, app); err != nil {
|
||||||
l.Tracef("no app found for client %s", cid)
|
l.Tracef("no app found for client %s", cid)
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,7 +24,7 @@ import (
|
||||||
|
|
||||||
"github.com/gin-contrib/sessions"
|
"github.com/gin-contrib/sessions"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/db/model"
|
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"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
|
// 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 {
|
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)
|
l.Debugf("user %s was not retrievable from db during oauth authorization attempt: %s", email, err)
|
||||||
|
|
|
@ -1,20 +1,48 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
package fileserver
|
package fileserver
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/apimodule"
|
"github.com/superseriousbusiness/gotosocial/internal/apimodule"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
"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/router"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
"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.
|
// 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
|
config *config.Config
|
||||||
db db.DB
|
db db.DB
|
||||||
storage storage.Storage
|
storage storage.Storage
|
||||||
|
@ -24,34 +52,24 @@ type fileServer struct {
|
||||||
|
|
||||||
// New returns a new fileServer module
|
// New returns a new fileServer module
|
||||||
func New(config *config.Config, db db.DB, storage storage.Storage, log *logrus.Logger) apimodule.ClientAPIModule {
|
func New(config *config.Config, db db.DB, storage storage.Storage, log *logrus.Logger) apimodule.ClientAPIModule {
|
||||||
|
return &FileServer{
|
||||||
storageBase := config.StorageConfig.BasePath // TODO: do this properly
|
|
||||||
|
|
||||||
return &fileServer{
|
|
||||||
config: config,
|
config: config,
|
||||||
db: db,
|
db: db,
|
||||||
storage: storage,
|
storage: storage,
|
||||||
log: log,
|
log: log,
|
||||||
storageBase: storageBase,
|
storageBase: config.StorageConfig.ServeBasePath,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route satisfies the RESTAPIModule interface
|
// Route satisfies the RESTAPIModule interface
|
||||||
func (m *fileServer) Route(s router.Router) error {
|
func (m *FileServer) Route(s router.Router) error {
|
||||||
// s.AttachHandler(http.MethodPost, appsPath, m.appsPOSTHandler)
|
s.AttachHandler(http.MethodGet, fmt.Sprintf("%s/:%s/:%s/:%s/:%s", m.storageBase, AccountIDKey, MediaTypeKey, MediaSizeKey, FileNameKey), m.ServeFile)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *fileServer) CreateTables(db db.DB) error {
|
func (m *FileServer) CreateTables(db db.DB) error {
|
||||||
models := []interface{}{
|
models := []interface{}{
|
||||||
&model.User{},
|
>smodel.MediaAttachment{},
|
||||||
&model.Account{},
|
|
||||||
&model.Follow{},
|
|
||||||
&model.FollowRequest{},
|
|
||||||
&model.Status{},
|
|
||||||
&model.Application{},
|
|
||||||
&model.EmailDomainBlock{},
|
|
||||||
&model.MediaAttachment{},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, m := range models {
|
for _, m := range models {
|
||||||
|
|
|
@ -0,0 +1,243 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package fileserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServeFile is for serving attachments, headers, and avatars to the requester from instance storage.
|
||||||
|
//
|
||||||
|
// Note: to mitigate scraping attempts, no information should be given out on a bad request except "404 page not found".
|
||||||
|
// Don't give away account ids or media ids or anything like that; callers shouldn't be able to infer anything.
|
||||||
|
func (m *FileServer) ServeFile(c *gin.Context) {
|
||||||
|
l := m.log.WithFields(logrus.Fields{
|
||||||
|
"func": "ServeFile",
|
||||||
|
"request_uri": c.Request.RequestURI,
|
||||||
|
"user_agent": c.Request.UserAgent(),
|
||||||
|
"origin_ip": c.ClientIP(),
|
||||||
|
})
|
||||||
|
l.Trace("received request")
|
||||||
|
|
||||||
|
// We use request params to check what to pull out of the database/storage so check everything. A request URL should be formatted as follows:
|
||||||
|
// "https://example.org/fileserver/[ACCOUNT_ID]/[MEDIA_TYPE]/[MEDIA_SIZE]/[FILE_NAME]"
|
||||||
|
// "FILE_NAME" consists of two parts, the attachment's database id, a period, and the file extension.
|
||||||
|
accountID := c.Param(AccountIDKey)
|
||||||
|
if accountID == "" {
|
||||||
|
l.Debug("missing accountID from request")
|
||||||
|
c.String(http.StatusNotFound, "404 page not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaType := c.Param(MediaTypeKey)
|
||||||
|
if mediaType == "" {
|
||||||
|
l.Debug("missing mediaType from request")
|
||||||
|
c.String(http.StatusNotFound, "404 page not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaSize := c.Param(MediaSizeKey)
|
||||||
|
if mediaSize == "" {
|
||||||
|
l.Debug("missing mediaSize from request")
|
||||||
|
c.String(http.StatusNotFound, "404 page not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fileName := c.Param(FileNameKey)
|
||||||
|
if fileName == "" {
|
||||||
|
l.Debug("missing fileName from request")
|
||||||
|
c.String(http.StatusNotFound, "404 page not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only serve media types that are defined in our internal media module
|
||||||
|
switch mediaType {
|
||||||
|
case media.MediaHeader, media.MediaAvatar, media.MediaAttachment:
|
||||||
|
m.serveAttachment(c, accountID, mediaType, mediaSize, fileName)
|
||||||
|
return
|
||||||
|
case media.MediaEmoji:
|
||||||
|
m.serveEmoji(c, accountID, mediaType, mediaSize, fileName)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l.Debugf("mediatype %s not recognized", mediaType)
|
||||||
|
c.String(http.StatusNotFound, "404 page not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *FileServer) serveAttachment(c *gin.Context, accountID string, mediaType string, mediaSize string, fileName string) {
|
||||||
|
l := m.log.WithFields(logrus.Fields{
|
||||||
|
"func": "serveAttachment",
|
||||||
|
"request_uri": c.Request.RequestURI,
|
||||||
|
"user_agent": c.Request.UserAgent(),
|
||||||
|
"origin_ip": c.ClientIP(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// This corresponds to original-sized image as it was uploaded, small (which is the thumbnail), or static
|
||||||
|
switch mediaSize {
|
||||||
|
case media.MediaOriginal, media.MediaSmall, media.MediaStatic:
|
||||||
|
default:
|
||||||
|
l.Debugf("mediasize %s not recognized", mediaSize)
|
||||||
|
c.String(http.StatusNotFound, "404 page not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// derive the media id and the file extension from the last part of the request
|
||||||
|
spl := strings.Split(fileName, ".")
|
||||||
|
if len(spl) != 2 {
|
||||||
|
l.Debugf("filename %s not parseable", fileName)
|
||||||
|
c.String(http.StatusNotFound, "404 page not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wantedMediaID := spl[0]
|
||||||
|
fileExtension := spl[1]
|
||||||
|
if wantedMediaID == "" || fileExtension == "" {
|
||||||
|
l.Debugf("filename %s not parseable", fileName)
|
||||||
|
c.String(http.StatusNotFound, "404 page not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// now we know the attachment ID that the caller is asking for we can use it to pull the attachment out of the db
|
||||||
|
attachment := >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{})
|
||||||
|
}
|
|
@ -0,0 +1,157 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/apimodule/fileserver"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/mastotypes"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ServeFileTestSuite struct {
|
||||||
|
// standard suite interfaces
|
||||||
|
suite.Suite
|
||||||
|
config *config.Config
|
||||||
|
db db.DB
|
||||||
|
log *logrus.Logger
|
||||||
|
storage storage.Storage
|
||||||
|
mastoConverter mastotypes.Converter
|
||||||
|
mediaHandler media.MediaHandler
|
||||||
|
oauthServer oauth.Server
|
||||||
|
|
||||||
|
// standard suite models
|
||||||
|
testTokens map[string]*oauth.Token
|
||||||
|
testClients map[string]*oauth.Client
|
||||||
|
testApplications map[string]*gtsmodel.Application
|
||||||
|
testUsers map[string]*gtsmodel.User
|
||||||
|
testAccounts map[string]*gtsmodel.Account
|
||||||
|
testAttachments map[string]*gtsmodel.MediaAttachment
|
||||||
|
|
||||||
|
// item being tested
|
||||||
|
fileServer *fileserver.FileServer
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
TEST INFRASTRUCTURE
|
||||||
|
*/
|
||||||
|
|
||||||
|
func (suite *ServeFileTestSuite) SetupSuite() {
|
||||||
|
// setup standard items
|
||||||
|
suite.config = testrig.NewTestConfig()
|
||||||
|
suite.db = testrig.NewTestDB()
|
||||||
|
suite.log = testrig.NewTestLog()
|
||||||
|
suite.storage = testrig.NewTestStorage()
|
||||||
|
suite.mastoConverter = testrig.NewTestMastoConverter(suite.db)
|
||||||
|
suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage)
|
||||||
|
suite.oauthServer = testrig.NewTestOauthServer(suite.db)
|
||||||
|
|
||||||
|
// setup module being tested
|
||||||
|
suite.fileServer = fileserver.New(suite.config, suite.db, suite.storage, suite.log).(*fileserver.FileServer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *ServeFileTestSuite) TearDownSuite() {
|
||||||
|
if err := suite.db.Stop(context.Background()); err != nil {
|
||||||
|
logrus.Panicf("error closing db connection: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *ServeFileTestSuite) SetupTest() {
|
||||||
|
testrig.StandardDBSetup(suite.db)
|
||||||
|
testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
|
||||||
|
suite.testTokens = testrig.NewTestTokens()
|
||||||
|
suite.testClients = testrig.NewTestClients()
|
||||||
|
suite.testApplications = testrig.NewTestApplications()
|
||||||
|
suite.testUsers = testrig.NewTestUsers()
|
||||||
|
suite.testAccounts = testrig.NewTestAccounts()
|
||||||
|
suite.testAttachments = testrig.NewTestAttachments()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *ServeFileTestSuite) TearDownTest() {
|
||||||
|
testrig.StandardDBTeardown(suite.db)
|
||||||
|
testrig.StandardStorageTeardown(suite.storage)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
ACTUAL TESTS
|
||||||
|
*/
|
||||||
|
|
||||||
|
func (suite *ServeFileTestSuite) TestServeOriginalFileSuccessful() {
|
||||||
|
targetAttachment, ok := suite.testAttachments["admin_account_status_1_attachment_1"]
|
||||||
|
assert.True(suite.T(), ok)
|
||||||
|
assert.NotNil(suite.T(), targetAttachment)
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
ctx, _ := gin.CreateTestContext(recorder)
|
||||||
|
ctx.Request = httptest.NewRequest(http.MethodGet, targetAttachment.URL, nil)
|
||||||
|
|
||||||
|
// normally the router would populate these params from the path values,
|
||||||
|
// but because we're calling the ServeFile function directly, we need to set them manually.
|
||||||
|
ctx.Params = gin.Params{
|
||||||
|
gin.Param{
|
||||||
|
Key: fileserver.AccountIDKey,
|
||||||
|
Value: targetAttachment.AccountID,
|
||||||
|
},
|
||||||
|
gin.Param{
|
||||||
|
Key: fileserver.MediaTypeKey,
|
||||||
|
Value: media.MediaAttachment,
|
||||||
|
},
|
||||||
|
gin.Param{
|
||||||
|
Key: fileserver.MediaSizeKey,
|
||||||
|
Value: media.MediaOriginal,
|
||||||
|
},
|
||||||
|
gin.Param{
|
||||||
|
Key: fileserver.FileNameKey,
|
||||||
|
Value: fmt.Sprintf("%s.jpeg", targetAttachment.ID),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// call the function we're testing and check status code
|
||||||
|
suite.fileServer.ServeFile(ctx)
|
||||||
|
suite.EqualValues(http.StatusOK, recorder.Code)
|
||||||
|
|
||||||
|
b, err := ioutil.ReadAll(recorder.Body)
|
||||||
|
assert.NoError(suite.T(), err)
|
||||||
|
assert.NotNil(suite.T(), b)
|
||||||
|
|
||||||
|
fileInStorage, err := suite.storage.RetrieveFileFrom(targetAttachment.File.Path)
|
||||||
|
assert.NoError(suite.T(), err)
|
||||||
|
assert.NotNil(suite.T(), fileInStorage)
|
||||||
|
assert.Equal(suite.T(), b, fileInStorage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeFileTestSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(ServeFileTestSuite))
|
||||||
|
}
|
|
@ -0,0 +1,73 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/apimodule"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/mastotypes"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
const BasePath = "/api/v1/media"
|
||||||
|
|
||||||
|
type MediaModule struct {
|
||||||
|
mediaHandler media.MediaHandler
|
||||||
|
config *config.Config
|
||||||
|
db db.DB
|
||||||
|
mastoConverter mastotypes.Converter
|
||||||
|
log *logrus.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a new auth module
|
||||||
|
func New(db db.DB, mediaHandler media.MediaHandler, mastoConverter mastotypes.Converter, config *config.Config, log *logrus.Logger) apimodule.ClientAPIModule {
|
||||||
|
return &MediaModule{
|
||||||
|
mediaHandler: mediaHandler,
|
||||||
|
config: config,
|
||||||
|
db: db,
|
||||||
|
mastoConverter: mastoConverter,
|
||||||
|
log: log,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route satisfies the RESTAPIModule interface
|
||||||
|
func (m *MediaModule) Route(s router.Router) error {
|
||||||
|
s.AttachHandler(http.MethodPost, BasePath, m.MediaCreatePOSTHandler)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MediaModule) CreateTables(db db.DB) error {
|
||||||
|
models := []interface{}{
|
||||||
|
>smodel.MediaAttachment{},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range models {
|
||||||
|
if err := db.CreateTable(m); err != nil {
|
||||||
|
return fmt.Errorf("error creating table: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -0,0 +1,192 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||||
|
mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m *MediaModule) MediaCreatePOSTHandler(c *gin.Context) {
|
||||||
|
l := m.log.WithField("func", "statusCreatePOSTHandler")
|
||||||
|
authed, err := oauth.MustAuth(c, true, true, true, true) // posting new media is serious business so we want *everything*
|
||||||
|
if err != nil {
|
||||||
|
l.Debugf("couldn't auth: %s", err)
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// First check this user/account is permitted to create media
|
||||||
|
// There's no point continuing otherwise.
|
||||||
|
if authed.User.Disabled || !authed.User.Approved || !authed.Account.SuspendedAt.IsZero() {
|
||||||
|
l.Debugf("couldn't auth: %s", err)
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "account is disabled, not yet approved, or suspended"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// extract the media create form from the request context
|
||||||
|
l.Tracef("parsing request form: %s", c.Request.Form)
|
||||||
|
form := &mastotypes.AttachmentRequest{}
|
||||||
|
if err := c.ShouldBind(form); err != nil || form == nil {
|
||||||
|
l.Debugf("could not parse form from request: %s", err)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give the fields on the request form a first pass to make sure the request is superficially valid.
|
||||||
|
l.Tracef("validating form %+v", form)
|
||||||
|
if err := validateCreateMedia(form, m.config.MediaConfig); err != nil {
|
||||||
|
l.Debugf("error validating form: %s", err)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// open the attachment and extract the bytes from it
|
||||||
|
f, err := form.File.Open()
|
||||||
|
if err != nil {
|
||||||
|
l.Debugf("error opening attachment: %s", err)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not open provided attachment: %s", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
size, err := io.Copy(buf, f)
|
||||||
|
if err != nil {
|
||||||
|
l.Debugf("error reading attachment: %s", err)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not read provided attachment: %s", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if size == 0 {
|
||||||
|
l.Debug("could not read provided attachment: size 0 bytes")
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "could not read provided attachment: size 0 bytes"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// allow the mediaHandler to work its magic of processing the attachment bytes, and putting them in whatever storage backend we're using
|
||||||
|
attachment, err := m.mediaHandler.ProcessLocalAttachment(buf.Bytes(), authed.Account.ID)
|
||||||
|
if err != nil {
|
||||||
|
l.Debugf("error reading attachment: %s", err)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not process attachment: %s", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// now we need to add extra fields that the attachment processor doesn't know (from the form)
|
||||||
|
// TODO: handle this inside mediaHandler.ProcessAttachment (just pass more params to it)
|
||||||
|
|
||||||
|
// first description
|
||||||
|
attachment.Description = form.Description
|
||||||
|
|
||||||
|
// now parse the focus parameter
|
||||||
|
// TODO: tidy this up into a separate function and just return an error so all the c.JSON and return calls are obviated
|
||||||
|
var focusx, focusy float32
|
||||||
|
if form.Focus != "" {
|
||||||
|
spl := strings.Split(form.Focus, ",")
|
||||||
|
if len(spl) != 2 {
|
||||||
|
l.Debugf("improperly formatted focus %s", form.Focus)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
xStr := spl[0]
|
||||||
|
yStr := spl[1]
|
||||||
|
if xStr == "" || yStr == "" {
|
||||||
|
l.Debugf("improperly formatted focus %s", form.Focus)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fx, err := strconv.ParseFloat(xStr, 32)
|
||||||
|
if err != nil {
|
||||||
|
l.Debugf("improperly formatted focus %s: %s", form.Focus, err)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if fx > 1 || fx < -1 {
|
||||||
|
l.Debugf("improperly formatted focus %s", form.Focus)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
focusx = float32(fx)
|
||||||
|
fy, err := strconv.ParseFloat(yStr, 32)
|
||||||
|
if err != nil {
|
||||||
|
l.Debugf("improperly formatted focus %s: %s", form.Focus, err)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if fy > 1 || fy < -1 {
|
||||||
|
l.Debugf("improperly formatted focus %s", form.Focus)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("improperly formatted focus %s", form.Focus)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
focusy = float32(fy)
|
||||||
|
}
|
||||||
|
attachment.FileMeta.Focus.X = focusx
|
||||||
|
attachment.FileMeta.Focus.Y = focusy
|
||||||
|
|
||||||
|
// prepare the frontend representation now -- if there are any errors here at least we can bail without
|
||||||
|
// having already put something in the database and then having to clean it up again (eugh)
|
||||||
|
mastoAttachment, err := m.mastoConverter.AttachmentToMasto(attachment)
|
||||||
|
if err != nil {
|
||||||
|
l.Debugf("error parsing media attachment to frontend type: %s", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("error parsing media attachment to frontend type: %s", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// now we can confidently put the attachment in the database
|
||||||
|
if err := m.db.Put(attachment); err != nil {
|
||||||
|
l.Debugf("error storing media attachment in db: %s", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("error storing media attachment in db: %s", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// and return its frontend representation
|
||||||
|
c.JSON(http.StatusAccepted, mastoAttachment)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCreateMedia(form *mastotypes.AttachmentRequest, config *config.MediaConfig) error {
|
||||||
|
// check there actually is a file attached and it's not size 0
|
||||||
|
if form.File == nil || form.File.Size == 0 {
|
||||||
|
return errors.New("no attachment given")
|
||||||
|
}
|
||||||
|
|
||||||
|
// a very superficial check to see if no size limits are exceeded
|
||||||
|
// we still don't actually know which media types we're dealing with but the other handlers will go into more detail there
|
||||||
|
maxSize := config.MaxVideoSize
|
||||||
|
if config.MaxImageSize > maxSize {
|
||||||
|
maxSize = config.MaxImageSize
|
||||||
|
}
|
||||||
|
if form.File.Size > int64(maxSize) {
|
||||||
|
return fmt.Errorf("file size limit exceeded: limit is %d bytes but attachment was %d bytes", maxSize, form.File.Size)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(form.Description) < config.MinDescriptionChars || len(form.Description) > config.MaxDescriptionChars {
|
||||||
|
return fmt.Errorf("image description length must be between %d and %d characters (inclusive), but provided image description was %d chars", config.MinDescriptionChars, config.MaxDescriptionChars, len(form.Description))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: validate focus here
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -0,0 +1,194 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
mediamodule "github.com/superseriousbusiness/gotosocial/internal/apimodule/media"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/mastotypes"
|
||||||
|
mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MediaCreateTestSuite struct {
|
||||||
|
// standard suite interfaces
|
||||||
|
suite.Suite
|
||||||
|
config *config.Config
|
||||||
|
db db.DB
|
||||||
|
log *logrus.Logger
|
||||||
|
storage storage.Storage
|
||||||
|
mastoConverter mastotypes.Converter
|
||||||
|
mediaHandler media.MediaHandler
|
||||||
|
oauthServer oauth.Server
|
||||||
|
|
||||||
|
// standard suite models
|
||||||
|
testTokens map[string]*oauth.Token
|
||||||
|
testClients map[string]*oauth.Client
|
||||||
|
testApplications map[string]*gtsmodel.Application
|
||||||
|
testUsers map[string]*gtsmodel.User
|
||||||
|
testAccounts map[string]*gtsmodel.Account
|
||||||
|
testAttachments map[string]*gtsmodel.MediaAttachment
|
||||||
|
|
||||||
|
// item being tested
|
||||||
|
mediaModule *mediamodule.MediaModule
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
TEST INFRASTRUCTURE
|
||||||
|
*/
|
||||||
|
|
||||||
|
func (suite *MediaCreateTestSuite) SetupSuite() {
|
||||||
|
// setup standard items
|
||||||
|
suite.config = testrig.NewTestConfig()
|
||||||
|
suite.db = testrig.NewTestDB()
|
||||||
|
suite.log = testrig.NewTestLog()
|
||||||
|
suite.storage = testrig.NewTestStorage()
|
||||||
|
suite.mastoConverter = testrig.NewTestMastoConverter(suite.db)
|
||||||
|
suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage)
|
||||||
|
suite.oauthServer = testrig.NewTestOauthServer(suite.db)
|
||||||
|
|
||||||
|
// setup module being tested
|
||||||
|
suite.mediaModule = mediamodule.New(suite.db, suite.mediaHandler, suite.mastoConverter, suite.config, suite.log).(*mediamodule.MediaModule)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *MediaCreateTestSuite) TearDownSuite() {
|
||||||
|
if err := suite.db.Stop(context.Background()); err != nil {
|
||||||
|
logrus.Panicf("error closing db connection: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *MediaCreateTestSuite) SetupTest() {
|
||||||
|
testrig.StandardDBSetup(suite.db)
|
||||||
|
testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
|
||||||
|
suite.testTokens = testrig.NewTestTokens()
|
||||||
|
suite.testClients = testrig.NewTestClients()
|
||||||
|
suite.testApplications = testrig.NewTestApplications()
|
||||||
|
suite.testUsers = testrig.NewTestUsers()
|
||||||
|
suite.testAccounts = testrig.NewTestAccounts()
|
||||||
|
suite.testAttachments = testrig.NewTestAttachments()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *MediaCreateTestSuite) TearDownTest() {
|
||||||
|
testrig.StandardDBTeardown(suite.db)
|
||||||
|
testrig.StandardStorageTeardown(suite.storage)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
ACTUAL TESTS
|
||||||
|
*/
|
||||||
|
|
||||||
|
func (suite *MediaCreateTestSuite) TestStatusCreatePOSTImageHandlerSuccessful() {
|
||||||
|
|
||||||
|
// set up the context for the request
|
||||||
|
t := suite.testTokens["local_account_1"]
|
||||||
|
oauthToken := oauth.PGTokenToOauthToken(t)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
ctx, _ := gin.CreateTestContext(recorder)
|
||||||
|
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
|
||||||
|
ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
|
||||||
|
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
|
||||||
|
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
|
||||||
|
|
||||||
|
// see what's in storage *before* the request
|
||||||
|
storageKeysBeforeRequest, err := suite.storage.ListKeys()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// create the request
|
||||||
|
buf, w, err := testrig.CreateMultipartFormData("file", "../../../../testrig/media/test-jpeg.jpg", map[string]string{
|
||||||
|
"description": "this is a test image -- a cool background from somewhere",
|
||||||
|
"focus": "-0.5,0.5",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", mediamodule.BasePath), bytes.NewReader(buf.Bytes())) // the endpoint we're hitting
|
||||||
|
ctx.Request.Header.Set("Content-Type", w.FormDataContentType())
|
||||||
|
|
||||||
|
// do the actual request
|
||||||
|
suite.mediaModule.MediaCreatePOSTHandler(ctx)
|
||||||
|
|
||||||
|
// check what's in storage *after* the request
|
||||||
|
storageKeysAfterRequest, err := suite.storage.ListKeys()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// check response
|
||||||
|
suite.EqualValues(http.StatusAccepted, recorder.Code)
|
||||||
|
|
||||||
|
result := recorder.Result()
|
||||||
|
defer result.Body.Close()
|
||||||
|
b, err := ioutil.ReadAll(result.Body)
|
||||||
|
assert.NoError(suite.T(), err)
|
||||||
|
fmt.Println(string(b))
|
||||||
|
|
||||||
|
attachmentReply := &mastomodel.Attachment{}
|
||||||
|
err = json.Unmarshal(b, attachmentReply)
|
||||||
|
assert.NoError(suite.T(), err)
|
||||||
|
|
||||||
|
assert.Equal(suite.T(), "this is a test image -- a cool background from somewhere", attachmentReply.Description)
|
||||||
|
assert.Equal(suite.T(), "image", attachmentReply.Type)
|
||||||
|
assert.EqualValues(suite.T(), mastomodel.MediaMeta{
|
||||||
|
Original: mastomodel.MediaDimensions{
|
||||||
|
Width: 1920,
|
||||||
|
Height: 1080,
|
||||||
|
Size: "1920x1080",
|
||||||
|
Aspect: 1.7777778,
|
||||||
|
},
|
||||||
|
Small: mastomodel.MediaDimensions{
|
||||||
|
Width: 256,
|
||||||
|
Height: 144,
|
||||||
|
Size: "256x144",
|
||||||
|
Aspect: 1.7777778,
|
||||||
|
},
|
||||||
|
Focus: mastomodel.MediaFocus{
|
||||||
|
X: -0.5,
|
||||||
|
Y: 0.5,
|
||||||
|
},
|
||||||
|
}, attachmentReply.Meta)
|
||||||
|
assert.Equal(suite.T(), "LjCZnlvyRkRn_NvzRjWF?urqV@f9", attachmentReply.Blurhash)
|
||||||
|
assert.NotEmpty(suite.T(), attachmentReply.ID)
|
||||||
|
assert.NotEmpty(suite.T(), attachmentReply.URL)
|
||||||
|
assert.NotEmpty(suite.T(), attachmentReply.PreviewURL)
|
||||||
|
assert.Equal(suite.T(), len(storageKeysBeforeRequest)+2, len(storageKeysAfterRequest)) // 2 images should be added to storage: the original and the thumbnail
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMediaCreateTestSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(MediaCreateTestSuite))
|
||||||
|
}
|
|
@ -4,6 +4,8 @@ package apimodule
|
||||||
|
|
||||||
import (
|
import (
|
||||||
mock "github.com/stretchr/testify/mock"
|
mock "github.com/stretchr/testify/mock"
|
||||||
|
db "github.com/superseriousbusiness/gotosocial/internal/db"
|
||||||
|
|
||||||
router "github.com/superseriousbusiness/gotosocial/internal/router"
|
router "github.com/superseriousbusiness/gotosocial/internal/router"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -12,6 +14,20 @@ type MockClientAPIModule struct {
|
||||||
mock.Mock
|
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
|
// Route provides a mock function with given fields: s
|
||||||
func (_m *MockClientAPIModule) Route(s router.Router) error {
|
func (_m *MockClientAPIModule) Route(s router.Router) error {
|
||||||
ret := _m.Called(s)
|
ret := _m.Called(s)
|
||||||
|
|
|
@ -0,0 +1,27 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package security
|
||||||
|
|
||||||
|
import "github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
// flocBlock prevents google chrome cohort tracking by writing the Permissions-Policy header after all other parts of the request have been completed.
|
||||||
|
// See: https://plausible.io/blog/google-floc
|
||||||
|
func (m *module) flocBlock(c *gin.Context) {
|
||||||
|
c.Header("Permissions-Policy", "interest-cohort=()")
|
||||||
|
}
|
|
@ -0,0 +1,50 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/apimodule"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// module implements the apiclient interface
|
||||||
|
type module struct {
|
||||||
|
config *config.Config
|
||||||
|
log *logrus.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a new security module
|
||||||
|
func New(config *config.Config, log *logrus.Logger) apimodule.ClientAPIModule {
|
||||||
|
return &module{
|
||||||
|
config: config,
|
||||||
|
log: log,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *module) Route(s router.Router) error {
|
||||||
|
s.AttachMiddleware(m.flocBlock)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *module) CreateTables(db db.DB) error {
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -0,0 +1,138 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package status
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/apimodule"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/distributor"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/mastotypes"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
IDKey = "id"
|
||||||
|
BasePath = "/api/v1/statuses"
|
||||||
|
BasePathWithID = BasePath + "/:" + IDKey
|
||||||
|
|
||||||
|
ContextPath = BasePathWithID + "/context"
|
||||||
|
|
||||||
|
FavouritedPath = BasePathWithID + "/favourited_by"
|
||||||
|
FavouritePath = BasePathWithID + "/favourite"
|
||||||
|
UnfavouritePath = BasePathWithID + "/unfavourite"
|
||||||
|
|
||||||
|
RebloggedPath = BasePathWithID + "/reblogged_by"
|
||||||
|
ReblogPath = BasePathWithID + "/reblog"
|
||||||
|
UnreblogPath = BasePathWithID + "/unreblog"
|
||||||
|
|
||||||
|
BookmarkPath = BasePathWithID + "/bookmark"
|
||||||
|
UnbookmarkPath = BasePathWithID + "/unbookmark"
|
||||||
|
|
||||||
|
MutePath = BasePathWithID + "/mute"
|
||||||
|
UnmutePath = BasePathWithID + "/unmute"
|
||||||
|
|
||||||
|
PinPath = BasePathWithID + "/pin"
|
||||||
|
UnpinPath = BasePathWithID + "/unpin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type StatusModule struct {
|
||||||
|
config *config.Config
|
||||||
|
db db.DB
|
||||||
|
mediaHandler media.MediaHandler
|
||||||
|
mastoConverter mastotypes.Converter
|
||||||
|
distributor distributor.Distributor
|
||||||
|
log *logrus.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a new account module
|
||||||
|
func New(config *config.Config, db db.DB, mediaHandler media.MediaHandler, mastoConverter mastotypes.Converter, distributor distributor.Distributor, log *logrus.Logger) apimodule.ClientAPIModule {
|
||||||
|
return &StatusModule{
|
||||||
|
config: config,
|
||||||
|
db: db,
|
||||||
|
mediaHandler: mediaHandler,
|
||||||
|
mastoConverter: mastoConverter,
|
||||||
|
distributor: distributor,
|
||||||
|
log: log,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route attaches all routes from this module to the given router
|
||||||
|
func (m *StatusModule) Route(r router.Router) error {
|
||||||
|
r.AttachHandler(http.MethodPost, BasePath, m.StatusCreatePOSTHandler)
|
||||||
|
r.AttachHandler(http.MethodDelete, BasePathWithID, m.StatusDELETEHandler)
|
||||||
|
|
||||||
|
r.AttachHandler(http.MethodPost, FavouritePath, m.StatusFavePOSTHandler)
|
||||||
|
r.AttachHandler(http.MethodPost, UnfavouritePath, m.StatusFavePOSTHandler)
|
||||||
|
|
||||||
|
r.AttachHandler(http.MethodGet, BasePathWithID, m.muxHandler)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *StatusModule) CreateTables(db db.DB) error {
|
||||||
|
models := []interface{}{
|
||||||
|
>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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,463 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package status
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/distributor"
|
||||||
|
mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
type advancedStatusCreateForm struct {
|
||||||
|
mastotypes.StatusCreateRequest
|
||||||
|
advancedVisibilityFlagsForm
|
||||||
|
}
|
||||||
|
|
||||||
|
type advancedVisibilityFlagsForm struct {
|
||||||
|
// The gotosocial visibility model
|
||||||
|
VisibilityAdvanced *gtsmodel.Visibility `form:"visibility_advanced"`
|
||||||
|
// This status will be federated beyond the local timeline(s)
|
||||||
|
Federated *bool `form:"federated"`
|
||||||
|
// This status can be boosted/reblogged
|
||||||
|
Boostable *bool `form:"boostable"`
|
||||||
|
// This status can be replied to
|
||||||
|
Replyable *bool `form:"replyable"`
|
||||||
|
// This status can be liked/faved
|
||||||
|
Likeable *bool `form:"likeable"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *StatusModule) StatusCreatePOSTHandler(c *gin.Context) {
|
||||||
|
l := m.log.WithField("func", "statusCreatePOSTHandler")
|
||||||
|
authed, err := oauth.MustAuth(c, true, true, true, true) // posting a status is serious business so we want *everything*
|
||||||
|
if err != nil {
|
||||||
|
l.Debugf("couldn't auth: %s", err)
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// First check this user/account is permitted to post new statuses.
|
||||||
|
// There's no point continuing otherwise.
|
||||||
|
if authed.User.Disabled || !authed.User.Approved || !authed.Account.SuspendedAt.IsZero() {
|
||||||
|
l.Debugf("couldn't auth: %s", err)
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "account is disabled, not yet approved, or suspended"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// extract the status create form from the request context
|
||||||
|
l.Tracef("parsing request form: %s", c.Request.Form)
|
||||||
|
form := &advancedStatusCreateForm{}
|
||||||
|
if err := c.ShouldBind(form); err != nil || form == nil {
|
||||||
|
l.Debugf("could not parse form from request: %s", err)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give the fields on the request form a first pass to make sure the request is superficially valid.
|
||||||
|
l.Tracef("validating form %+v", form)
|
||||||
|
if err := validateCreateStatus(form, m.config.StatusesConfig); err != nil {
|
||||||
|
l.Debugf("error validating form: %s", err)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// At this point we know the account is permitted to post, and we know the request form
|
||||||
|
// is valid (at least according to the API specifications and the instance configuration).
|
||||||
|
// So now we can start digging a bit deeper into the form and building up the new status from it.
|
||||||
|
|
||||||
|
// first we create a new status and add some basic info to it
|
||||||
|
uris := util.GenerateURIs(authed.Account.Username, m.config.Protocol, m.config.Host)
|
||||||
|
thisStatusID := uuid.NewString()
|
||||||
|
thisStatusURI := fmt.Sprintf("%s/%s", uris.StatusesURI, thisStatusID)
|
||||||
|
thisStatusURL := fmt.Sprintf("%s/%s", uris.StatusesURL, thisStatusID)
|
||||||
|
newStatus := >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
|
||||||
|
}
|
|
@ -0,0 +1,106 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package status
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/distributor"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m *StatusModule) StatusDELETEHandler(c *gin.Context) {
|
||||||
|
l := m.log.WithFields(logrus.Fields{
|
||||||
|
"func": "StatusDELETEHandler",
|
||||||
|
"request_uri": c.Request.RequestURI,
|
||||||
|
"user_agent": c.Request.UserAgent(),
|
||||||
|
"origin_ip": c.ClientIP(),
|
||||||
|
})
|
||||||
|
l.Debugf("entering function")
|
||||||
|
|
||||||
|
authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else
|
||||||
|
if err != nil {
|
||||||
|
l.Debug("not authed so can't delete status")
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targetStatusID := c.Param(IDKey)
|
||||||
|
if targetStatusID == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l.Tracef("going to search for target status %s", targetStatusID)
|
||||||
|
targetStatus := >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)
|
||||||
|
}
|
|
@ -0,0 +1,136 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package status
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/distributor"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m *StatusModule) StatusFavePOSTHandler(c *gin.Context) {
|
||||||
|
l := m.log.WithFields(logrus.Fields{
|
||||||
|
"func": "StatusFavePOSTHandler",
|
||||||
|
"request_uri": c.Request.RequestURI,
|
||||||
|
"user_agent": c.Request.UserAgent(),
|
||||||
|
"origin_ip": c.ClientIP(),
|
||||||
|
})
|
||||||
|
l.Debugf("entering function")
|
||||||
|
|
||||||
|
authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else
|
||||||
|
if err != nil {
|
||||||
|
l.Debug("not authed so can't fave status")
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targetStatusID := c.Param(IDKey)
|
||||||
|
if targetStatusID == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l.Tracef("going to search for target status %s", targetStatusID)
|
||||||
|
targetStatus := >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)
|
||||||
|
}
|
|
@ -0,0 +1,128 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package status
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||||
|
mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m *StatusModule) StatusFavedByGETHandler(c *gin.Context) {
|
||||||
|
l := m.log.WithFields(logrus.Fields{
|
||||||
|
"func": "statusGETHandler",
|
||||||
|
"request_uri": c.Request.RequestURI,
|
||||||
|
"user_agent": c.Request.UserAgent(),
|
||||||
|
"origin_ip": c.ClientIP(),
|
||||||
|
})
|
||||||
|
l.Debugf("entering function")
|
||||||
|
|
||||||
|
var requestingAccount *gtsmodel.Account
|
||||||
|
authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else
|
||||||
|
if err != nil {
|
||||||
|
l.Debug("not authed but will continue to serve anyway if public status")
|
||||||
|
requestingAccount = nil
|
||||||
|
} else {
|
||||||
|
requestingAccount = authed.Account
|
||||||
|
}
|
||||||
|
|
||||||
|
targetStatusID := c.Param(IDKey)
|
||||||
|
if targetStatusID == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l.Tracef("going to search for target status %s", targetStatusID)
|
||||||
|
targetStatus := >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)
|
||||||
|
}
|
|
@ -0,0 +1,111 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package status
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m *StatusModule) StatusGETHandler(c *gin.Context) {
|
||||||
|
l := m.log.WithFields(logrus.Fields{
|
||||||
|
"func": "statusGETHandler",
|
||||||
|
"request_uri": c.Request.RequestURI,
|
||||||
|
"user_agent": c.Request.UserAgent(),
|
||||||
|
"origin_ip": c.ClientIP(),
|
||||||
|
})
|
||||||
|
l.Debugf("entering function")
|
||||||
|
|
||||||
|
var requestingAccount *gtsmodel.Account
|
||||||
|
authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else
|
||||||
|
if err != nil {
|
||||||
|
l.Debug("not authed but will continue to serve anyway if public status")
|
||||||
|
requestingAccount = nil
|
||||||
|
} else {
|
||||||
|
requestingAccount = authed.Account
|
||||||
|
}
|
||||||
|
|
||||||
|
targetStatusID := c.Param(IDKey)
|
||||||
|
if targetStatusID == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l.Tracef("going to search for target status %s", targetStatusID)
|
||||||
|
targetStatus := >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)
|
||||||
|
}
|
|
@ -0,0 +1,136 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package status
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/distributor"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m *StatusModule) StatusUnfavePOSTHandler(c *gin.Context) {
|
||||||
|
l := m.log.WithFields(logrus.Fields{
|
||||||
|
"func": "StatusUnfavePOSTHandler",
|
||||||
|
"request_uri": c.Request.RequestURI,
|
||||||
|
"user_agent": c.Request.UserAgent(),
|
||||||
|
"origin_ip": c.ClientIP(),
|
||||||
|
})
|
||||||
|
l.Debugf("entering function")
|
||||||
|
|
||||||
|
authed, err := oauth.MustAuth(c, true, false, true, true) // we don't really need an app here but we want everything else
|
||||||
|
if err != nil {
|
||||||
|
l.Debug("not authed so can't unfave status")
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targetStatusID := c.Param(IDKey)
|
||||||
|
if targetStatusID == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l.Tracef("going to search for target status %s", targetStatusID)
|
||||||
|
targetStatus := >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)
|
||||||
|
}
|
|
@ -0,0 +1,346 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package status
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/apimodule/status"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/distributor"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/mastotypes"
|
||||||
|
mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||||
|
)
|
||||||
|
|
||||||
|
type StatusCreateTestSuite struct {
|
||||||
|
// standard suite interfaces
|
||||||
|
suite.Suite
|
||||||
|
config *config.Config
|
||||||
|
db db.DB
|
||||||
|
log *logrus.Logger
|
||||||
|
storage storage.Storage
|
||||||
|
mastoConverter mastotypes.Converter
|
||||||
|
mediaHandler media.MediaHandler
|
||||||
|
oauthServer oauth.Server
|
||||||
|
distributor distributor.Distributor
|
||||||
|
|
||||||
|
// standard suite models
|
||||||
|
testTokens map[string]*oauth.Token
|
||||||
|
testClients map[string]*oauth.Client
|
||||||
|
testApplications map[string]*gtsmodel.Application
|
||||||
|
testUsers map[string]*gtsmodel.User
|
||||||
|
testAccounts map[string]*gtsmodel.Account
|
||||||
|
testAttachments map[string]*gtsmodel.MediaAttachment
|
||||||
|
|
||||||
|
// module being tested
|
||||||
|
statusModule *status.StatusModule
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
TEST INFRASTRUCTURE
|
||||||
|
*/
|
||||||
|
|
||||||
|
// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
|
||||||
|
func (suite *StatusCreateTestSuite) SetupSuite() {
|
||||||
|
// setup standard items
|
||||||
|
suite.config = testrig.NewTestConfig()
|
||||||
|
suite.db = testrig.NewTestDB()
|
||||||
|
suite.log = testrig.NewTestLog()
|
||||||
|
suite.storage = testrig.NewTestStorage()
|
||||||
|
suite.mastoConverter = testrig.NewTestMastoConverter(suite.db)
|
||||||
|
suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage)
|
||||||
|
suite.oauthServer = testrig.NewTestOauthServer(suite.db)
|
||||||
|
suite.distributor = testrig.NewTestDistributor()
|
||||||
|
|
||||||
|
// setup module being tested
|
||||||
|
suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.StatusModule)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *StatusCreateTestSuite) TearDownSuite() {
|
||||||
|
testrig.StandardDBTeardown(suite.db)
|
||||||
|
testrig.StandardStorageTeardown(suite.storage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *StatusCreateTestSuite) SetupTest() {
|
||||||
|
testrig.StandardDBSetup(suite.db)
|
||||||
|
testrig.StandardStorageSetup(suite.storage, "../../../testrig/media")
|
||||||
|
suite.testTokens = testrig.NewTestTokens()
|
||||||
|
suite.testClients = testrig.NewTestClients()
|
||||||
|
suite.testApplications = testrig.NewTestApplications()
|
||||||
|
suite.testUsers = testrig.NewTestUsers()
|
||||||
|
suite.testAccounts = testrig.NewTestAccounts()
|
||||||
|
suite.testAttachments = testrig.NewTestAttachments()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TearDownTest drops tables to make sure there's no data in the db
|
||||||
|
func (suite *StatusCreateTestSuite) TearDownTest() {
|
||||||
|
testrig.StandardDBTeardown(suite.db)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
ACTUAL TESTS
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
TESTING: StatusCreatePOSTHandler
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Post a new status with some custom visibility settings
|
||||||
|
func (suite *StatusCreateTestSuite) TestPostNewStatus() {
|
||||||
|
|
||||||
|
t := suite.testTokens["local_account_1"]
|
||||||
|
oauthToken := oauth.PGTokenToOauthToken(t)
|
||||||
|
|
||||||
|
// setup
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
ctx, _ := gin.CreateTestContext(recorder)
|
||||||
|
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
|
||||||
|
ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
|
||||||
|
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
|
||||||
|
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
|
||||||
|
ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", status.BasePath), nil) // the endpoint we're hitting
|
||||||
|
ctx.Request.Form = url.Values{
|
||||||
|
"status": {"this is a brand new status! #helloworld"},
|
||||||
|
"spoiler_text": {"hello hello"},
|
||||||
|
"sensitive": {"true"},
|
||||||
|
"visibility_advanced": {"mutuals_only"},
|
||||||
|
"likeable": {"false"},
|
||||||
|
"replyable": {"false"},
|
||||||
|
"federated": {"false"},
|
||||||
|
}
|
||||||
|
suite.statusModule.StatusCreatePOSTHandler(ctx)
|
||||||
|
|
||||||
|
// check response
|
||||||
|
|
||||||
|
// 1. we should have OK from our call to the function
|
||||||
|
suite.EqualValues(http.StatusOK, recorder.Code)
|
||||||
|
|
||||||
|
result := recorder.Result()
|
||||||
|
defer result.Body.Close()
|
||||||
|
b, err := ioutil.ReadAll(result.Body)
|
||||||
|
assert.NoError(suite.T(), err)
|
||||||
|
|
||||||
|
statusReply := &mastomodel.Status{}
|
||||||
|
err = json.Unmarshal(b, statusReply)
|
||||||
|
assert.NoError(suite.T(), err)
|
||||||
|
|
||||||
|
assert.Equal(suite.T(), "hello hello", statusReply.SpoilerText)
|
||||||
|
assert.Equal(suite.T(), "this is a brand new status! #helloworld", statusReply.Content)
|
||||||
|
assert.True(suite.T(), statusReply.Sensitive)
|
||||||
|
assert.Equal(suite.T(), mastomodel.VisibilityPrivate, statusReply.Visibility)
|
||||||
|
assert.Len(suite.T(), statusReply.Tags, 1)
|
||||||
|
assert.Equal(suite.T(), mastomodel.Tag{
|
||||||
|
Name: "helloworld",
|
||||||
|
URL: "http://localhost:8080/tags/helloworld",
|
||||||
|
}, statusReply.Tags[0])
|
||||||
|
|
||||||
|
gtsTag := >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))
|
||||||
|
}
|
|
@ -0,0 +1,207 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package status
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/apimodule/status"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/distributor"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/mastotypes"
|
||||||
|
mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||||
|
)
|
||||||
|
|
||||||
|
type StatusFaveTestSuite struct {
|
||||||
|
// standard suite interfaces
|
||||||
|
suite.Suite
|
||||||
|
config *config.Config
|
||||||
|
db db.DB
|
||||||
|
log *logrus.Logger
|
||||||
|
storage storage.Storage
|
||||||
|
mastoConverter mastotypes.Converter
|
||||||
|
mediaHandler media.MediaHandler
|
||||||
|
oauthServer oauth.Server
|
||||||
|
distributor distributor.Distributor
|
||||||
|
|
||||||
|
// standard suite models
|
||||||
|
testTokens map[string]*oauth.Token
|
||||||
|
testClients map[string]*oauth.Client
|
||||||
|
testApplications map[string]*gtsmodel.Application
|
||||||
|
testUsers map[string]*gtsmodel.User
|
||||||
|
testAccounts map[string]*gtsmodel.Account
|
||||||
|
testAttachments map[string]*gtsmodel.MediaAttachment
|
||||||
|
testStatuses map[string]*gtsmodel.Status
|
||||||
|
|
||||||
|
// module being tested
|
||||||
|
statusModule *status.StatusModule
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
TEST INFRASTRUCTURE
|
||||||
|
*/
|
||||||
|
|
||||||
|
// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
|
||||||
|
func (suite *StatusFaveTestSuite) SetupSuite() {
|
||||||
|
// setup standard items
|
||||||
|
suite.config = testrig.NewTestConfig()
|
||||||
|
suite.db = testrig.NewTestDB()
|
||||||
|
suite.log = testrig.NewTestLog()
|
||||||
|
suite.storage = testrig.NewTestStorage()
|
||||||
|
suite.mastoConverter = testrig.NewTestMastoConverter(suite.db)
|
||||||
|
suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage)
|
||||||
|
suite.oauthServer = testrig.NewTestOauthServer(suite.db)
|
||||||
|
suite.distributor = testrig.NewTestDistributor()
|
||||||
|
|
||||||
|
// setup module being tested
|
||||||
|
suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.StatusModule)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *StatusFaveTestSuite) TearDownSuite() {
|
||||||
|
testrig.StandardDBTeardown(suite.db)
|
||||||
|
testrig.StandardStorageTeardown(suite.storage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *StatusFaveTestSuite) SetupTest() {
|
||||||
|
testrig.StandardDBSetup(suite.db)
|
||||||
|
testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
|
||||||
|
suite.testTokens = testrig.NewTestTokens()
|
||||||
|
suite.testClients = testrig.NewTestClients()
|
||||||
|
suite.testApplications = testrig.NewTestApplications()
|
||||||
|
suite.testUsers = testrig.NewTestUsers()
|
||||||
|
suite.testAccounts = testrig.NewTestAccounts()
|
||||||
|
suite.testAttachments = testrig.NewTestAttachments()
|
||||||
|
suite.testStatuses = testrig.NewTestStatuses()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TearDownTest drops tables to make sure there's no data in the db
|
||||||
|
func (suite *StatusFaveTestSuite) TearDownTest() {
|
||||||
|
testrig.StandardDBTeardown(suite.db)
|
||||||
|
testrig.StandardStorageTeardown(suite.storage)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
ACTUAL TESTS
|
||||||
|
*/
|
||||||
|
|
||||||
|
// fave a status
|
||||||
|
func (suite *StatusFaveTestSuite) TestPostFave() {
|
||||||
|
|
||||||
|
t := suite.testTokens["local_account_1"]
|
||||||
|
oauthToken := oauth.PGTokenToOauthToken(t)
|
||||||
|
|
||||||
|
targetStatus := suite.testStatuses["admin_account_status_2"]
|
||||||
|
|
||||||
|
// setup
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
ctx, _ := gin.CreateTestContext(recorder)
|
||||||
|
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
|
||||||
|
ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
|
||||||
|
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
|
||||||
|
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
|
||||||
|
ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.FavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
|
||||||
|
|
||||||
|
// normally the router would populate these params from the path values,
|
||||||
|
// but because we're calling the function directly, we need to set them manually.
|
||||||
|
ctx.Params = gin.Params{
|
||||||
|
gin.Param{
|
||||||
|
Key: status.IDKey,
|
||||||
|
Value: targetStatus.ID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
suite.statusModule.StatusFavePOSTHandler(ctx)
|
||||||
|
|
||||||
|
// check response
|
||||||
|
suite.EqualValues(http.StatusOK, recorder.Code)
|
||||||
|
|
||||||
|
result := recorder.Result()
|
||||||
|
defer result.Body.Close()
|
||||||
|
b, err := ioutil.ReadAll(result.Body)
|
||||||
|
assert.NoError(suite.T(), err)
|
||||||
|
|
||||||
|
statusReply := &mastomodel.Status{}
|
||||||
|
err = json.Unmarshal(b, statusReply)
|
||||||
|
assert.NoError(suite.T(), err)
|
||||||
|
|
||||||
|
assert.Equal(suite.T(), targetStatus.ContentWarning, statusReply.SpoilerText)
|
||||||
|
assert.Equal(suite.T(), targetStatus.Content, statusReply.Content)
|
||||||
|
assert.True(suite.T(), statusReply.Sensitive)
|
||||||
|
assert.Equal(suite.T(), mastomodel.VisibilityPublic, statusReply.Visibility)
|
||||||
|
assert.True(suite.T(), statusReply.Favourited)
|
||||||
|
assert.Equal(suite.T(), 1, statusReply.FavouritesCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// try to fave a status that's not faveable
|
||||||
|
func (suite *StatusFaveTestSuite) TestPostUnfaveable() {
|
||||||
|
|
||||||
|
t := suite.testTokens["local_account_1"]
|
||||||
|
oauthToken := oauth.PGTokenToOauthToken(t)
|
||||||
|
|
||||||
|
targetStatus := suite.testStatuses["local_account_2_status_3"] // this one is unlikeable and unreplyable
|
||||||
|
|
||||||
|
// setup
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
ctx, _ := gin.CreateTestContext(recorder)
|
||||||
|
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
|
||||||
|
ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
|
||||||
|
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
|
||||||
|
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
|
||||||
|
ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.FavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
|
||||||
|
|
||||||
|
// normally the router would populate these params from the path values,
|
||||||
|
// but because we're calling the function directly, we need to set them manually.
|
||||||
|
ctx.Params = gin.Params{
|
||||||
|
gin.Param{
|
||||||
|
Key: status.IDKey,
|
||||||
|
Value: targetStatus.ID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
suite.statusModule.StatusFavePOSTHandler(ctx)
|
||||||
|
|
||||||
|
// check response
|
||||||
|
suite.EqualValues(http.StatusForbidden, recorder.Code) // we 403 unlikeable statuses
|
||||||
|
|
||||||
|
result := recorder.Result()
|
||||||
|
defer result.Body.Close()
|
||||||
|
b, err := ioutil.ReadAll(result.Body)
|
||||||
|
assert.NoError(suite.T(), err)
|
||||||
|
assert.Equal(suite.T(), fmt.Sprintf(`{"error":"status %s not faveable"}`, targetStatus.ID), string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusFaveTestSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(StatusFaveTestSuite))
|
||||||
|
}
|
|
@ -0,0 +1,159 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package status
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/apimodule/status"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/distributor"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/mastotypes"
|
||||||
|
mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||||
|
)
|
||||||
|
|
||||||
|
type StatusFavedByTestSuite struct {
|
||||||
|
// standard suite interfaces
|
||||||
|
suite.Suite
|
||||||
|
config *config.Config
|
||||||
|
db db.DB
|
||||||
|
log *logrus.Logger
|
||||||
|
storage storage.Storage
|
||||||
|
mastoConverter mastotypes.Converter
|
||||||
|
mediaHandler media.MediaHandler
|
||||||
|
oauthServer oauth.Server
|
||||||
|
distributor distributor.Distributor
|
||||||
|
|
||||||
|
// standard suite models
|
||||||
|
testTokens map[string]*oauth.Token
|
||||||
|
testClients map[string]*oauth.Client
|
||||||
|
testApplications map[string]*gtsmodel.Application
|
||||||
|
testUsers map[string]*gtsmodel.User
|
||||||
|
testAccounts map[string]*gtsmodel.Account
|
||||||
|
testAttachments map[string]*gtsmodel.MediaAttachment
|
||||||
|
testStatuses map[string]*gtsmodel.Status
|
||||||
|
|
||||||
|
// module being tested
|
||||||
|
statusModule *status.StatusModule
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
|
||||||
|
func (suite *StatusFavedByTestSuite) SetupSuite() {
|
||||||
|
// setup standard items
|
||||||
|
suite.config = testrig.NewTestConfig()
|
||||||
|
suite.db = testrig.NewTestDB()
|
||||||
|
suite.log = testrig.NewTestLog()
|
||||||
|
suite.storage = testrig.NewTestStorage()
|
||||||
|
suite.mastoConverter = testrig.NewTestMastoConverter(suite.db)
|
||||||
|
suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage)
|
||||||
|
suite.oauthServer = testrig.NewTestOauthServer(suite.db)
|
||||||
|
suite.distributor = testrig.NewTestDistributor()
|
||||||
|
|
||||||
|
// setup module being tested
|
||||||
|
suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.StatusModule)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *StatusFavedByTestSuite) TearDownSuite() {
|
||||||
|
testrig.StandardDBTeardown(suite.db)
|
||||||
|
testrig.StandardStorageTeardown(suite.storage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *StatusFavedByTestSuite) SetupTest() {
|
||||||
|
testrig.StandardDBSetup(suite.db)
|
||||||
|
testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
|
||||||
|
suite.testTokens = testrig.NewTestTokens()
|
||||||
|
suite.testClients = testrig.NewTestClients()
|
||||||
|
suite.testApplications = testrig.NewTestApplications()
|
||||||
|
suite.testUsers = testrig.NewTestUsers()
|
||||||
|
suite.testAccounts = testrig.NewTestAccounts()
|
||||||
|
suite.testAttachments = testrig.NewTestAttachments()
|
||||||
|
suite.testStatuses = testrig.NewTestStatuses()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TearDownTest drops tables to make sure there's no data in the db
|
||||||
|
func (suite *StatusFavedByTestSuite) TearDownTest() {
|
||||||
|
testrig.StandardDBTeardown(suite.db)
|
||||||
|
testrig.StandardStorageTeardown(suite.storage)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
ACTUAL TESTS
|
||||||
|
*/
|
||||||
|
|
||||||
|
func (suite *StatusFavedByTestSuite) TestGetFavedBy() {
|
||||||
|
t := suite.testTokens["local_account_2"]
|
||||||
|
oauthToken := oauth.PGTokenToOauthToken(t)
|
||||||
|
|
||||||
|
targetStatus := suite.testStatuses["admin_account_status_1"] // this status is faved by local_account_1
|
||||||
|
|
||||||
|
// setup
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
ctx, _ := gin.CreateTestContext(recorder)
|
||||||
|
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_2"])
|
||||||
|
ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
|
||||||
|
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_2"])
|
||||||
|
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_2"])
|
||||||
|
ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.FavouritedPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
|
||||||
|
|
||||||
|
// normally the router would populate these params from the path values,
|
||||||
|
// but because we're calling the function directly, we need to set them manually.
|
||||||
|
ctx.Params = gin.Params{
|
||||||
|
gin.Param{
|
||||||
|
Key: status.IDKey,
|
||||||
|
Value: targetStatus.ID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
suite.statusModule.StatusFavedByGETHandler(ctx)
|
||||||
|
|
||||||
|
// check response
|
||||||
|
suite.EqualValues(http.StatusOK, recorder.Code)
|
||||||
|
|
||||||
|
result := recorder.Result()
|
||||||
|
defer result.Body.Close()
|
||||||
|
b, err := ioutil.ReadAll(result.Body)
|
||||||
|
assert.NoError(suite.T(), err)
|
||||||
|
|
||||||
|
accts := []mastomodel.Account{}
|
||||||
|
err = json.Unmarshal(b, &accts)
|
||||||
|
assert.NoError(suite.T(), err)
|
||||||
|
|
||||||
|
assert.Len(suite.T(), accts, 1)
|
||||||
|
assert.Equal(suite.T(), "the_mighty_zork", accts[0].Username)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusFavedByTestSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(StatusFavedByTestSuite))
|
||||||
|
}
|
|
@ -0,0 +1,168 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package status
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/apimodule/status"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/distributor"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/mastotypes"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||||
|
)
|
||||||
|
|
||||||
|
type StatusGetTestSuite struct {
|
||||||
|
// standard suite interfaces
|
||||||
|
suite.Suite
|
||||||
|
config *config.Config
|
||||||
|
db db.DB
|
||||||
|
log *logrus.Logger
|
||||||
|
storage storage.Storage
|
||||||
|
mastoConverter mastotypes.Converter
|
||||||
|
mediaHandler media.MediaHandler
|
||||||
|
oauthServer oauth.Server
|
||||||
|
distributor distributor.Distributor
|
||||||
|
|
||||||
|
// standard suite models
|
||||||
|
testTokens map[string]*oauth.Token
|
||||||
|
testClients map[string]*oauth.Client
|
||||||
|
testApplications map[string]*gtsmodel.Application
|
||||||
|
testUsers map[string]*gtsmodel.User
|
||||||
|
testAccounts map[string]*gtsmodel.Account
|
||||||
|
testAttachments map[string]*gtsmodel.MediaAttachment
|
||||||
|
|
||||||
|
// module being tested
|
||||||
|
statusModule *status.StatusModule
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
TEST INFRASTRUCTURE
|
||||||
|
*/
|
||||||
|
|
||||||
|
// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
|
||||||
|
func (suite *StatusGetTestSuite) SetupSuite() {
|
||||||
|
// setup standard items
|
||||||
|
suite.config = testrig.NewTestConfig()
|
||||||
|
suite.db = testrig.NewTestDB()
|
||||||
|
suite.log = testrig.NewTestLog()
|
||||||
|
suite.storage = testrig.NewTestStorage()
|
||||||
|
suite.mastoConverter = testrig.NewTestMastoConverter(suite.db)
|
||||||
|
suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage)
|
||||||
|
suite.oauthServer = testrig.NewTestOauthServer(suite.db)
|
||||||
|
suite.distributor = testrig.NewTestDistributor()
|
||||||
|
|
||||||
|
// setup module being tested
|
||||||
|
suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.StatusModule)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *StatusGetTestSuite) TearDownSuite() {
|
||||||
|
testrig.StandardDBTeardown(suite.db)
|
||||||
|
testrig.StandardStorageTeardown(suite.storage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *StatusGetTestSuite) SetupTest() {
|
||||||
|
testrig.StandardDBSetup(suite.db)
|
||||||
|
testrig.StandardStorageSetup(suite.storage, "../../../testrig/media")
|
||||||
|
suite.testTokens = testrig.NewTestTokens()
|
||||||
|
suite.testClients = testrig.NewTestClients()
|
||||||
|
suite.testApplications = testrig.NewTestApplications()
|
||||||
|
suite.testUsers = testrig.NewTestUsers()
|
||||||
|
suite.testAccounts = testrig.NewTestAccounts()
|
||||||
|
suite.testAttachments = testrig.NewTestAttachments()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TearDownTest drops tables to make sure there's no data in the db
|
||||||
|
func (suite *StatusGetTestSuite) TearDownTest() {
|
||||||
|
testrig.StandardDBTeardown(suite.db)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
ACTUAL TESTS
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
TESTING: StatusGetPOSTHandler
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Post a new status with some custom visibility settings
|
||||||
|
func (suite *StatusGetTestSuite) TestPostNewStatus() {
|
||||||
|
|
||||||
|
// t := suite.testTokens["local_account_1"]
|
||||||
|
// oauthToken := oauth.PGTokenToOauthToken(t)
|
||||||
|
|
||||||
|
// // setup
|
||||||
|
// recorder := httptest.NewRecorder()
|
||||||
|
// ctx, _ := gin.CreateTestContext(recorder)
|
||||||
|
// ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
|
||||||
|
// ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
|
||||||
|
// ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
|
||||||
|
// ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
|
||||||
|
// ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", basePath), nil) // the endpoint we're hitting
|
||||||
|
// ctx.Request.Form = url.Values{
|
||||||
|
// "status": {"this is a brand new status! #helloworld"},
|
||||||
|
// "spoiler_text": {"hello hello"},
|
||||||
|
// "sensitive": {"true"},
|
||||||
|
// "visibility_advanced": {"mutuals_only"},
|
||||||
|
// "likeable": {"false"},
|
||||||
|
// "replyable": {"false"},
|
||||||
|
// "federated": {"false"},
|
||||||
|
// }
|
||||||
|
// suite.statusModule.statusGETHandler(ctx)
|
||||||
|
|
||||||
|
// // check response
|
||||||
|
|
||||||
|
// // 1. we should have OK from our call to the function
|
||||||
|
// suite.EqualValues(http.StatusOK, recorder.Code)
|
||||||
|
|
||||||
|
// result := recorder.Result()
|
||||||
|
// defer result.Body.Close()
|
||||||
|
// b, err := ioutil.ReadAll(result.Body)
|
||||||
|
// assert.NoError(suite.T(), err)
|
||||||
|
|
||||||
|
// statusReply := &mastomodel.Status{}
|
||||||
|
// err = json.Unmarshal(b, statusReply)
|
||||||
|
// assert.NoError(suite.T(), err)
|
||||||
|
|
||||||
|
// assert.Equal(suite.T(), "hello hello", statusReply.SpoilerText)
|
||||||
|
// assert.Equal(suite.T(), "this is a brand new status! #helloworld", statusReply.Content)
|
||||||
|
// assert.True(suite.T(), statusReply.Sensitive)
|
||||||
|
// assert.Equal(suite.T(), mastomodel.VisibilityPrivate, statusReply.Visibility)
|
||||||
|
// assert.Len(suite.T(), statusReply.Tags, 1)
|
||||||
|
// assert.Equal(suite.T(), mastomodel.Tag{
|
||||||
|
// Name: "helloworld",
|
||||||
|
// URL: "http://localhost:8080/tags/helloworld",
|
||||||
|
// }, statusReply.Tags[0])
|
||||||
|
|
||||||
|
// gtsTag := >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))
|
||||||
|
}
|
|
@ -0,0 +1,219 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package status
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/apimodule/status"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/distributor"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/mastotypes"
|
||||||
|
mastomodel "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||||
|
)
|
||||||
|
|
||||||
|
type StatusUnfaveTestSuite struct {
|
||||||
|
// standard suite interfaces
|
||||||
|
suite.Suite
|
||||||
|
config *config.Config
|
||||||
|
db db.DB
|
||||||
|
log *logrus.Logger
|
||||||
|
storage storage.Storage
|
||||||
|
mastoConverter mastotypes.Converter
|
||||||
|
mediaHandler media.MediaHandler
|
||||||
|
oauthServer oauth.Server
|
||||||
|
distributor distributor.Distributor
|
||||||
|
|
||||||
|
// standard suite models
|
||||||
|
testTokens map[string]*oauth.Token
|
||||||
|
testClients map[string]*oauth.Client
|
||||||
|
testApplications map[string]*gtsmodel.Application
|
||||||
|
testUsers map[string]*gtsmodel.User
|
||||||
|
testAccounts map[string]*gtsmodel.Account
|
||||||
|
testAttachments map[string]*gtsmodel.MediaAttachment
|
||||||
|
testStatuses map[string]*gtsmodel.Status
|
||||||
|
|
||||||
|
// module being tested
|
||||||
|
statusModule *status.StatusModule
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
TEST INFRASTRUCTURE
|
||||||
|
*/
|
||||||
|
|
||||||
|
// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout
|
||||||
|
func (suite *StatusUnfaveTestSuite) SetupSuite() {
|
||||||
|
// setup standard items
|
||||||
|
suite.config = testrig.NewTestConfig()
|
||||||
|
suite.db = testrig.NewTestDB()
|
||||||
|
suite.log = testrig.NewTestLog()
|
||||||
|
suite.storage = testrig.NewTestStorage()
|
||||||
|
suite.mastoConverter = testrig.NewTestMastoConverter(suite.db)
|
||||||
|
suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage)
|
||||||
|
suite.oauthServer = testrig.NewTestOauthServer(suite.db)
|
||||||
|
suite.distributor = testrig.NewTestDistributor()
|
||||||
|
|
||||||
|
// setup module being tested
|
||||||
|
suite.statusModule = status.New(suite.config, suite.db, suite.mediaHandler, suite.mastoConverter, suite.distributor, suite.log).(*status.StatusModule)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *StatusUnfaveTestSuite) TearDownSuite() {
|
||||||
|
testrig.StandardDBTeardown(suite.db)
|
||||||
|
testrig.StandardStorageTeardown(suite.storage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *StatusUnfaveTestSuite) SetupTest() {
|
||||||
|
testrig.StandardDBSetup(suite.db)
|
||||||
|
testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
|
||||||
|
suite.testTokens = testrig.NewTestTokens()
|
||||||
|
suite.testClients = testrig.NewTestClients()
|
||||||
|
suite.testApplications = testrig.NewTestApplications()
|
||||||
|
suite.testUsers = testrig.NewTestUsers()
|
||||||
|
suite.testAccounts = testrig.NewTestAccounts()
|
||||||
|
suite.testAttachments = testrig.NewTestAttachments()
|
||||||
|
suite.testStatuses = testrig.NewTestStatuses()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TearDownTest drops tables to make sure there's no data in the db
|
||||||
|
func (suite *StatusUnfaveTestSuite) TearDownTest() {
|
||||||
|
testrig.StandardDBTeardown(suite.db)
|
||||||
|
testrig.StandardStorageTeardown(suite.storage)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
ACTUAL TESTS
|
||||||
|
*/
|
||||||
|
|
||||||
|
// unfave a status
|
||||||
|
func (suite *StatusUnfaveTestSuite) TestPostUnfave() {
|
||||||
|
|
||||||
|
t := suite.testTokens["local_account_1"]
|
||||||
|
oauthToken := oauth.PGTokenToOauthToken(t)
|
||||||
|
|
||||||
|
// this is the status we wanna unfave: in the testrig it's already faved by this account
|
||||||
|
targetStatus := suite.testStatuses["admin_account_status_1"]
|
||||||
|
|
||||||
|
// setup
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
ctx, _ := gin.CreateTestContext(recorder)
|
||||||
|
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
|
||||||
|
ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
|
||||||
|
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
|
||||||
|
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
|
||||||
|
ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.UnfavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
|
||||||
|
|
||||||
|
// normally the router would populate these params from the path values,
|
||||||
|
// but because we're calling the function directly, we need to set them manually.
|
||||||
|
ctx.Params = gin.Params{
|
||||||
|
gin.Param{
|
||||||
|
Key: status.IDKey,
|
||||||
|
Value: targetStatus.ID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
suite.statusModule.StatusUnfavePOSTHandler(ctx)
|
||||||
|
|
||||||
|
// check response
|
||||||
|
suite.EqualValues(http.StatusOK, recorder.Code)
|
||||||
|
|
||||||
|
result := recorder.Result()
|
||||||
|
defer result.Body.Close()
|
||||||
|
b, err := ioutil.ReadAll(result.Body)
|
||||||
|
assert.NoError(suite.T(), err)
|
||||||
|
|
||||||
|
statusReply := &mastomodel.Status{}
|
||||||
|
err = json.Unmarshal(b, statusReply)
|
||||||
|
assert.NoError(suite.T(), err)
|
||||||
|
|
||||||
|
assert.Equal(suite.T(), targetStatus.ContentWarning, statusReply.SpoilerText)
|
||||||
|
assert.Equal(suite.T(), targetStatus.Content, statusReply.Content)
|
||||||
|
assert.False(suite.T(), statusReply.Sensitive)
|
||||||
|
assert.Equal(suite.T(), mastomodel.VisibilityPublic, statusReply.Visibility)
|
||||||
|
assert.False(suite.T(), statusReply.Favourited)
|
||||||
|
assert.Equal(suite.T(), 0, statusReply.FavouritesCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// try to unfave a status that's already not faved
|
||||||
|
func (suite *StatusUnfaveTestSuite) TestPostAlreadyNotFaved() {
|
||||||
|
|
||||||
|
t := suite.testTokens["local_account_1"]
|
||||||
|
oauthToken := oauth.PGTokenToOauthToken(t)
|
||||||
|
|
||||||
|
// this is the status we wanna unfave: in the testrig it's not faved by this account
|
||||||
|
targetStatus := suite.testStatuses["admin_account_status_2"]
|
||||||
|
|
||||||
|
// setup
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
ctx, _ := gin.CreateTestContext(recorder)
|
||||||
|
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
|
||||||
|
ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
|
||||||
|
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
|
||||||
|
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
|
||||||
|
ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(status.UnfavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
|
||||||
|
|
||||||
|
// normally the router would populate these params from the path values,
|
||||||
|
// but because we're calling the function directly, we need to set them manually.
|
||||||
|
ctx.Params = gin.Params{
|
||||||
|
gin.Param{
|
||||||
|
Key: status.IDKey,
|
||||||
|
Value: targetStatus.ID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
suite.statusModule.StatusUnfavePOSTHandler(ctx)
|
||||||
|
|
||||||
|
// check response
|
||||||
|
suite.EqualValues(http.StatusOK, recorder.Code)
|
||||||
|
|
||||||
|
result := recorder.Result()
|
||||||
|
defer result.Body.Close()
|
||||||
|
b, err := ioutil.ReadAll(result.Body)
|
||||||
|
assert.NoError(suite.T(), err)
|
||||||
|
|
||||||
|
statusReply := &mastomodel.Status{}
|
||||||
|
err = json.Unmarshal(b, statusReply)
|
||||||
|
assert.NoError(suite.T(), err)
|
||||||
|
|
||||||
|
assert.Equal(suite.T(), targetStatus.ContentWarning, statusReply.SpoilerText)
|
||||||
|
assert.Equal(suite.T(), targetStatus.Content, statusReply.Content)
|
||||||
|
assert.True(suite.T(), statusReply.Sensitive)
|
||||||
|
assert.Equal(suite.T(), mastomodel.VisibilityPublic, statusReply.Visibility)
|
||||||
|
assert.False(suite.T(), statusReply.Favourited)
|
||||||
|
assert.Equal(suite.T(), 0, statusReply.FavouritesCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusUnfaveTestSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(StatusUnfaveTestSuite))
|
||||||
|
}
|
|
@ -36,6 +36,7 @@ type Config struct {
|
||||||
AccountsConfig *AccountsConfig `yaml:"accounts"`
|
AccountsConfig *AccountsConfig `yaml:"accounts"`
|
||||||
MediaConfig *MediaConfig `yaml:"media"`
|
MediaConfig *MediaConfig `yaml:"media"`
|
||||||
StorageConfig *StorageConfig `yaml:"storage"`
|
StorageConfig *StorageConfig `yaml:"storage"`
|
||||||
|
StatusesConfig *StatusesConfig `yaml:"statuses"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// FromFile returns a new config from a file, or an error if something goes amiss.
|
// 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
|
return Empty(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Empty just returns an empty config
|
// Empty just returns a new empty config
|
||||||
func Empty() *Config {
|
func Empty() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
DBConfig: &DBConfig{},
|
DBConfig: &DBConfig{},
|
||||||
|
@ -58,6 +59,7 @@ func Empty() *Config {
|
||||||
AccountsConfig: &AccountsConfig{},
|
AccountsConfig: &AccountsConfig{},
|
||||||
MediaConfig: &MediaConfig{},
|
MediaConfig: &MediaConfig{},
|
||||||
StorageConfig: &StorageConfig{},
|
StorageConfig: &StorageConfig{},
|
||||||
|
StatusesConfig: &StatusesConfig{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -140,8 +142,8 @@ func (c *Config) ParseCLIFlags(f KeyedFlags) {
|
||||||
c.AccountsConfig.OpenRegistration = f.Bool(fn.AccountsOpenRegistration)
|
c.AccountsConfig.OpenRegistration = f.Bool(fn.AccountsOpenRegistration)
|
||||||
}
|
}
|
||||||
|
|
||||||
if f.IsSet(fn.AccountsRequireApproval) {
|
if f.IsSet(fn.AccountsApprovalRequired) {
|
||||||
c.AccountsConfig.RequireApproval = f.Bool(fn.AccountsRequireApproval)
|
c.AccountsConfig.RequireApproval = f.Bool(fn.AccountsApprovalRequired)
|
||||||
}
|
}
|
||||||
|
|
||||||
// media flags
|
// media flags
|
||||||
|
@ -153,6 +155,14 @@ func (c *Config) ParseCLIFlags(f KeyedFlags) {
|
||||||
c.MediaConfig.MaxVideoSize = f.Int(fn.MediaMaxVideoSize)
|
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
|
// storage flags
|
||||||
if c.StorageConfig.Backend == "" || f.IsSet(fn.StorageBackend) {
|
if c.StorageConfig.Backend == "" || f.IsSet(fn.StorageBackend) {
|
||||||
c.StorageConfig.Backend = f.String(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) {
|
if c.StorageConfig.ServeBasePath == "" || f.IsSet(fn.StorageServeBasePath) {
|
||||||
c.StorageConfig.ServeBasePath = f.String(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.
|
// 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
|
TemplateBaseDir string
|
||||||
|
|
||||||
AccountsOpenRegistration string
|
AccountsOpenRegistration string
|
||||||
AccountsRequireApproval string
|
AccountsApprovalRequired string
|
||||||
|
AccountsReasonRequired string
|
||||||
|
|
||||||
MediaMaxImageSize string
|
MediaMaxImageSize string
|
||||||
MediaMaxVideoSize string
|
MediaMaxVideoSize string
|
||||||
|
MediaMinDescriptionChars string
|
||||||
|
MediaMaxDescriptionChars string
|
||||||
|
|
||||||
StorageBackend string
|
StorageBackend string
|
||||||
StorageBasePath string
|
StorageBasePath string
|
||||||
StorageServeProtocol string
|
StorageServeProtocol string
|
||||||
StorageServeHost string
|
StorageServeHost string
|
||||||
StorageServeBasePath 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
|
// GetFlagNames returns a struct containing the names of the various flags used for
|
||||||
|
@ -235,16 +309,25 @@ func GetFlagNames() Flags {
|
||||||
TemplateBaseDir: "template-basedir",
|
TemplateBaseDir: "template-basedir",
|
||||||
|
|
||||||
AccountsOpenRegistration: "accounts-open-registration",
|
AccountsOpenRegistration: "accounts-open-registration",
|
||||||
AccountsRequireApproval: "accounts-require-approval",
|
AccountsApprovalRequired: "accounts-approval-required",
|
||||||
|
AccountsReasonRequired: "accounts-reason-required",
|
||||||
|
|
||||||
MediaMaxImageSize: "media-max-image-size",
|
MediaMaxImageSize: "media-max-image-size",
|
||||||
MediaMaxVideoSize: "media-max-video-size",
|
MediaMaxVideoSize: "media-max-video-size",
|
||||||
|
MediaMinDescriptionChars: "media-min-description-chars",
|
||||||
|
MediaMaxDescriptionChars: "media-max-description-chars",
|
||||||
|
|
||||||
StorageBackend: "storage-backend",
|
StorageBackend: "storage-backend",
|
||||||
StorageBasePath: "storage-base-path",
|
StorageBasePath: "storage-base-path",
|
||||||
StorageServeProtocol: "storage-serve-protocol",
|
StorageServeProtocol: "storage-serve-protocol",
|
||||||
StorageServeHost: "storage-serve-host",
|
StorageServeHost: "storage-serve-host",
|
||||||
StorageServeBasePath: "storage-serve-base-path",
|
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",
|
TemplateBaseDir: "GTS_TEMPLATE_BASEDIR",
|
||||||
|
|
||||||
AccountsOpenRegistration: "GTS_ACCOUNTS_OPEN_REGISTRATION",
|
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",
|
MediaMaxImageSize: "GTS_MEDIA_MAX_IMAGE_SIZE",
|
||||||
MediaMaxVideoSize: "GTS_MEDIA_MAX_VIDEO_SIZE",
|
MediaMaxVideoSize: "GTS_MEDIA_MAX_VIDEO_SIZE",
|
||||||
|
MediaMinDescriptionChars: "GTS_MEDIA_MIN_DESCRIPTION_CHARS",
|
||||||
|
MediaMaxDescriptionChars: "GTS_MEDIA_MAX_DESCRIPTION_CHARS",
|
||||||
|
|
||||||
StorageBackend: "GTS_STORAGE_BACKEND",
|
StorageBackend: "GTS_STORAGE_BACKEND",
|
||||||
StorageBasePath: "GTS_STORAGE_BASE_PATH",
|
StorageBasePath: "GTS_STORAGE_BASE_PATH",
|
||||||
StorageServeProtocol: "GTS_STORAGE_SERVE_PROTOCOL",
|
StorageServeProtocol: "GTS_STORAGE_SERVE_PROTOCOL",
|
||||||
StorageServeHost: "GTS_STORAGE_SERVE_HOST",
|
StorageServeHost: "GTS_STORAGE_SERVE_HOST",
|
||||||
StorageServeBasePath: "GTS_STORAGE_SERVE_BASE_PATH",
|
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",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -24,4 +24,8 @@ type MediaConfig struct {
|
||||||
MaxImageSize int `yaml:"maxImageSize"`
|
MaxImageSize int `yaml:"maxImageSize"`
|
||||||
// Max size of uploaded video in bytes
|
// Max size of uploaded video in bytes
|
||||||
MaxVideoSize int `yaml:"maxVideoSize"`
|
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"`
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,33 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package config
|
||||||
|
|
||||||
|
// StatusesConfig pertains to posting/deleting/interacting with statuses
|
||||||
|
type StatusesConfig struct {
|
||||||
|
// Maximum amount of characters allowed in a status, excluding CW
|
||||||
|
MaxChars int `yaml:"max_chars"`
|
||||||
|
// Maximum amount of characters allowed in a content-warning/spoiler field
|
||||||
|
CWMaxChars int `yaml:"cw_max_chars"`
|
||||||
|
// Maximum number of options allowed in a poll
|
||||||
|
PollMaxOptions int `yaml:"poll_max_options"`
|
||||||
|
// Maximum characters allowed per poll option
|
||||||
|
PollOptionMaxChars int `yaml:"poll_option_max_chars"`
|
||||||
|
// Maximum amount of media files allowed to be attached to one status
|
||||||
|
MaxMediaFiles int `yaml:"max_media_files"`
|
||||||
|
}
|
|
@ -27,8 +27,7 @@ import (
|
||||||
"github.com/go-fed/activity/pub"
|
"github.com/go-fed/activity/pub"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/db/model"
|
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||||
"github.com/superseriousbusiness/gotosocial/pkg/mastotypes"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const dbTypePostgres string = "POSTGRES"
|
const dbTypePostgres string = "POSTGRES"
|
||||||
|
@ -79,6 +78,11 @@ type DB interface {
|
||||||
// In case of no entries, a 'no entries' error will be returned
|
// In case of no entries, a 'no entries' error will be returned
|
||||||
GetWhere(key string, value interface{}, i interface{}) error
|
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.
|
// 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.
|
// 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
|
// 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.
|
// 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
|
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.
|
// 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.
|
// 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
|
UpdateByID(id string, i interface{}) error
|
||||||
|
@ -107,41 +116,46 @@ type DB interface {
|
||||||
HANDY SHORTCUTS
|
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.
|
// 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.
|
// 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
|
// 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.
|
// 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.
|
// 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
|
// 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.
|
// 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.
|
// 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
|
// 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.
|
// 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.
|
// 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
|
// 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.
|
// 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.
|
// 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
|
// 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
|
// 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
|
// 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!
|
// be very memory intensive so you probably shouldn't do this!
|
||||||
// In case of no entries, a 'no entries' error will be returned
|
// 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.
|
// 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.
|
// 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
|
// 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.
|
// 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.
|
// 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.
|
// 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!
|
// 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 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.
|
// 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.
|
// 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.
|
// 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.
|
// 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
|
USEFUL CONVERSION FUNCTIONS
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// AccountToMastoSensitive takes a db model account as a param, and returns a populated mastotype account, or an error
|
// MentionStringsToMentions takes a slice of deduplicated, lowercase account names in the form "@test@whatever.example.org" for a remote account,
|
||||||
// if something goes wrong. The returned account should be ready to serialize on an API level, and may have sensitive fields,
|
// or @test for a local account, which have been mentioned in a status.
|
||||||
// so serve it only to an authorized user who should have permission to see it.
|
// It takes the id of the account that wrote the status, and the id of the status itself, and then
|
||||||
AccountToMastoSensitive(account *model.Account) (*mastotypes.Account, error)
|
// 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
|
// TagStringsToTags takes a slice of deduplicated, lowercase tags in the form "somehashtag", which have been
|
||||||
// if something goes wrong. The returned account should be ready to serialize on an API level, and may NOT have sensitive fields.
|
// used in a status. It takes the id of the account that wrote the status, and the id of the status itself, and then
|
||||||
// In other words, this is the public record that the server has of an account.
|
// returns a slice of *model.Tag corresponding to the given tags. If the tag already exists in database, that tag
|
||||||
AccountToMastoPublic(account *model.Account) (*mastotypes.Account, error)
|
// 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,
|
// New returns a new database service that satisfies the DB interface and, by extension,
|
||||||
|
|
|
@ -16,15 +16,14 @@
|
||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Package model contains types used *internally* by GoToSocial and added/removed/selected from the database.
|
// 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.
|
// 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).
|
// 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/
|
// See here for more info on go-pg model annotations: https://pg.uptrace.dev/models/
|
||||||
package model
|
package gtsmodel
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rsa"
|
"crypto/rsa"
|
||||||
"net/url"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -38,33 +37,17 @@ type Account struct {
|
||||||
ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"`
|
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 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
|
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
|
Domain string `pg:",unique:userdomain"` // username and domain should be unique *with* each other
|
||||||
|
|
||||||
/*
|
/*
|
||||||
ACCOUNT METADATA
|
ACCOUNT METADATA
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// File name of the avatar on local storage
|
// ID of the avatar as a media attachment
|
||||||
AvatarFileName string
|
AvatarMediaAttachmentID string
|
||||||
// Gif? png? jpeg?
|
// ID of the header as a media attachment
|
||||||
AvatarContentType string
|
HeaderMediaAttachmentID string
|
||||||
// Size of the avatar in bytes
|
|
||||||
AvatarFileSize int
|
|
||||||
// When was the avatar last updated?
|
|
||||||
AvatarUpdatedAt time.Time `pg:"type:timestamp"`
|
|
||||||
// Where can the avatar be retrieved?
|
|
||||||
AvatarRemoteURL *url.URL `pg:"type:text"`
|
|
||||||
// File name of the header on local storage
|
|
||||||
HeaderFileName string
|
|
||||||
// Gif? png? jpeg?
|
|
||||||
HeaderContentType string
|
|
||||||
// Size of the header in bytes
|
|
||||||
HeaderFileSize int
|
|
||||||
// When was the header last updated?
|
|
||||||
HeaderUpdatedAt time.Time `pg:"type:timestamp"`
|
|
||||||
// Where can the header be retrieved?
|
|
||||||
HeaderRemoteURL *url.URL `pg:"type:text"`
|
|
||||||
// DisplayName for this account. Can be empty, then just the Username will be used for display purposes.
|
// DisplayName for this account. Can be empty, then just the Username will be used for display purposes.
|
||||||
DisplayName string
|
DisplayName string
|
||||||
// a key/value map of fields that this account has added to their profile
|
// 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?
|
// Is this a memorial account, ie., has the user passed away?
|
||||||
Memorial bool
|
Memorial bool
|
||||||
// This account has moved this account id in the database
|
// This account has moved this account id in the database
|
||||||
MovedToAccountID int
|
MovedToAccountID string
|
||||||
// When was this account created?
|
// When was this account created?
|
||||||
CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
|
CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
|
||||||
// When was this account last updated?
|
// When was this account last updated?
|
||||||
UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
|
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?
|
// Does this account identify itself as a bot?
|
||||||
Bot bool
|
Bot bool
|
||||||
// What reason was given for signing up when this account was created?
|
// 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?
|
// Should this account be shown in the instance's profile directory?
|
||||||
Discoverable bool
|
Discoverable bool
|
||||||
// Default post privacy for this account
|
// Default post privacy for this account
|
||||||
Privacy string
|
Privacy Visibility
|
||||||
// Set posts from this account to sensitive by default?
|
// Set posts from this account to sensitive by default?
|
||||||
Sensitive bool
|
Sensitive bool
|
||||||
// What language does this account post in?
|
// What language does this account post in?
|
||||||
|
@ -122,7 +103,7 @@ type Account struct {
|
||||||
// URL for getting the featured collection list of this account
|
// URL for getting the featured collection list of this account
|
||||||
FeaturedCollectionURL string `pg:",unique"`
|
FeaturedCollectionURL string `pg:",unique"`
|
||||||
// What type of activitypub actor is this account?
|
// What type of activitypub actor is this account?
|
||||||
ActorType string
|
ActorType ActivityStreamsActor
|
||||||
// This account is associated with x account id
|
// This account is associated with x account id
|
||||||
AlsoKnownAs string
|
AlsoKnownAs string
|
||||||
|
|
||||||
|
@ -130,7 +111,6 @@ type Account struct {
|
||||||
CRYPTO FIELDS
|
CRYPTO FIELDS
|
||||||
*/
|
*/
|
||||||
|
|
||||||
Secret string
|
|
||||||
// Privatekey for validating activitypub requests, will obviously only be defined for local accounts
|
// Privatekey for validating activitypub requests, will obviously only be defined for local accounts
|
||||||
PrivateKey *rsa.PrivateKey
|
PrivateKey *rsa.PrivateKey
|
||||||
// Publickey for encoding activitypub requests, will be defined for both local and remote accounts
|
// 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"`
|
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)
|
// 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"`
|
SuspendedAt time.Time `pg:"type:timestamp"`
|
||||||
// How much do we trust this account 🤔
|
|
||||||
TrustLevel int
|
|
||||||
// Should we hide this account's collections?
|
// Should we hide this account's collections?
|
||||||
HideCollections bool
|
HideCollections bool
|
||||||
// id of the user that suspended this account through an admin action
|
// 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.
|
// Field represents a key value field on an account, for things like pronouns, website, etc.
|
|
@ -0,0 +1,127 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package gtsmodel
|
||||||
|
|
||||||
|
// ActivityStreamsObject refers to https://www.w3.org/TR/activitystreams-vocabulary/#object-types
|
||||||
|
type ActivityStreamsObject string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-article
|
||||||
|
ActivityStreamsArticle ActivityStreamsObject = "Article"
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-audio
|
||||||
|
ActivityStreamsAudio ActivityStreamsObject = "Audio"
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-document
|
||||||
|
ActivityStreamsDocument ActivityStreamsObject = "Event"
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-event
|
||||||
|
ActivityStreamsEvent ActivityStreamsObject = "Event"
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-image
|
||||||
|
ActivityStreamsImage ActivityStreamsObject = "Image"
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-note
|
||||||
|
ActivityStreamsNote ActivityStreamsObject = "Note"
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-page
|
||||||
|
ActivityStreamsPage ActivityStreamsObject = "Page"
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-place
|
||||||
|
ActivityStreamsPlace ActivityStreamsObject = "Place"
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-profile
|
||||||
|
ActivityStreamsProfile ActivityStreamsObject = "Profile"
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-relationship
|
||||||
|
ActivityStreamsRelationship ActivityStreamsObject = "Relationship"
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tombstone
|
||||||
|
ActivityStreamsTombstone ActivityStreamsObject = "Tombstone"
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-video
|
||||||
|
ActivityStreamsVideo ActivityStreamsObject = "Video"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ActivityStreamsActor refers to https://www.w3.org/TR/activitystreams-vocabulary/#actor-types
|
||||||
|
type ActivityStreamsActor string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-application
|
||||||
|
ActivityStreamsApplication ActivityStreamsActor = "Application"
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-group
|
||||||
|
ActivityStreamsGroup ActivityStreamsActor = "Group"
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-organization
|
||||||
|
ActivityStreamsOrganization ActivityStreamsActor = "Organization"
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-person
|
||||||
|
ActivityStreamsPerson ActivityStreamsActor = "Person"
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-service
|
||||||
|
ActivityStreamsService ActivityStreamsActor = "Service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ActivityStreamsActivity refers to https://www.w3.org/TR/activitystreams-vocabulary/#activity-types
|
||||||
|
type ActivityStreamsActivity string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-accept
|
||||||
|
ActivityStreamsAccept ActivityStreamsActivity = "Accept"
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-add
|
||||||
|
ActivityStreamsAdd ActivityStreamsActivity = "Add"
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-announce
|
||||||
|
ActivityStreamsAnnounce ActivityStreamsActivity = "Announce"
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-arrive
|
||||||
|
ActivityStreamsArrive ActivityStreamsActivity = "Arrive"
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-block
|
||||||
|
ActivityStreamsBlock ActivityStreamsActivity = "Block"
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-create
|
||||||
|
ActivityStreamsCreate ActivityStreamsActivity = "Create"
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-delete
|
||||||
|
ActivityStreamsDelete ActivityStreamsActivity = "Delete"
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-dislike
|
||||||
|
ActivityStreamsDislike ActivityStreamsActivity = "Dislike"
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-flag
|
||||||
|
ActivityStreamsFlag ActivityStreamsActivity = "Flag"
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-follow
|
||||||
|
ActivityStreamsFollow ActivityStreamsActivity = "Follow"
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-ignore
|
||||||
|
ActivityStreamsIgnore ActivityStreamsActivity = "Ignore"
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-invite
|
||||||
|
ActivityStreamsInvite ActivityStreamsActivity = "Invite"
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-join
|
||||||
|
ActivityStreamsJoin ActivityStreamsActivity = "Join"
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-leave
|
||||||
|
ActivityStreamsLeave ActivityStreamsActivity = "Leave"
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-like
|
||||||
|
ActivityStreamsLike ActivityStreamsActivity = "Like"
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-listen
|
||||||
|
ActivityStreamsListen ActivityStreamsActivity = "Listen"
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-move
|
||||||
|
ActivityStreamsMove ActivityStreamsActivity = "Move"
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-offer
|
||||||
|
ActivityStreamsOffer ActivityStreamsActivity = "Offer"
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-question
|
||||||
|
ActivityStreamsQuestion ActivityStreamsActivity = "Question"
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-reject
|
||||||
|
ActivityStreamsReject ActivityStreamsActivity = "Reject"
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-read
|
||||||
|
ActivityStreamsRead ActivityStreamsActivity = "Read"
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-remove
|
||||||
|
ActivityStreamsRemove ActivityStreamsActivity = "Remove"
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tentativereject
|
||||||
|
ActivityStreamsTentativeReject ActivityStreamsActivity = "TentativeReject"
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tentativeaccept
|
||||||
|
ActivityStreamsTentativeAccept ActivityStreamsActivity = "TentativeAccept"
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-travel
|
||||||
|
ActivityStreamsTravel ActivityStreamsActivity = "Travel"
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-undo
|
||||||
|
ActivityStreamsUndo ActivityStreamsActivity = "Undo"
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-update
|
||||||
|
ActivityStreamsUpdate ActivityStreamsActivity = "Update"
|
||||||
|
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-view
|
||||||
|
ActivityStreamsView ActivityStreamsActivity = "View"
|
||||||
|
)
|
|
@ -16,9 +16,7 @@
|
||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package model
|
package gtsmodel
|
||||||
|
|
||||||
import "github.com/superseriousbusiness/gotosocial/pkg/mastotypes"
|
|
||||||
|
|
||||||
// Application represents an application that can perform actions on behalf of a user.
|
// 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.
|
// 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
|
// a vapid key generated for this app when it was created
|
||||||
VapidKey string
|
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,
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
||||||
|
}
|
|
@ -16,7 +16,7 @@
|
||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package model
|
package gtsmodel
|
||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
|
@ -16,7 +16,7 @@
|
||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package model
|
package gtsmodel
|
||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
|
@ -0,0 +1,74 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package gtsmodel
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Emoji struct {
|
||||||
|
// database ID of this emoji
|
||||||
|
ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull"`
|
||||||
|
// String shortcode for this emoji -- the part that's between colons. This should be lowercase a-z_
|
||||||
|
// eg., 'blob_hug' 'purple_heart' Must be unique with domain.
|
||||||
|
Shortcode string `pg:",notnull,unique:shortcodedomain"`
|
||||||
|
// Origin domain of this emoji, eg 'example.org', 'queer.party'. empty string for local emojis.
|
||||||
|
Domain string `pg:",notnull,default:'',use_zero,unique:shortcodedomain"`
|
||||||
|
// When was this emoji created. Must be unique with shortcode.
|
||||||
|
CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
|
||||||
|
// When was this emoji updated
|
||||||
|
UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
|
||||||
|
// Where can this emoji be retrieved remotely? Null for local emojis.
|
||||||
|
// For remote emojis, it'll be something like:
|
||||||
|
// https://hackers.town/system/custom_emojis/images/000/049/842/original/1b74481204feabfd.png
|
||||||
|
ImageRemoteURL string
|
||||||
|
// Where can a static / non-animated version of this emoji be retrieved remotely? Null for local emojis.
|
||||||
|
// For remote emojis, it'll be something like:
|
||||||
|
// https://hackers.town/system/custom_emojis/images/000/049/842/static/1b74481204feabfd.png
|
||||||
|
ImageStaticRemoteURL string
|
||||||
|
// Where can this emoji be retrieved from the local server? Null for remote emojis.
|
||||||
|
// Assuming our server is hosted at 'example.org', this will be something like:
|
||||||
|
// 'https://example.org/fileserver/6339820e-ef65-4166-a262-5a9f46adb1a7/emoji/original/bfa6c9c5-6c25-4ea4-98b4-d78b8126fb52.png'
|
||||||
|
ImageURL string
|
||||||
|
// Where can a static version of this emoji be retrieved from the local server? Null for remote emojis.
|
||||||
|
// Assuming our server is hosted at 'example.org', this will be something like:
|
||||||
|
// 'https://example.org/fileserver/6339820e-ef65-4166-a262-5a9f46adb1a7/emoji/small/bfa6c9c5-6c25-4ea4-98b4-d78b8126fb52.png'
|
||||||
|
ImageStaticURL string
|
||||||
|
// Path of the emoji image in the server storage system. Will be something like:
|
||||||
|
// '/gotosocial/storage/6339820e-ef65-4166-a262-5a9f46adb1a7/emoji/original/bfa6c9c5-6c25-4ea4-98b4-d78b8126fb52.png'
|
||||||
|
ImagePath string `pg:",notnull"`
|
||||||
|
// Path of a static version of the emoji image in the server storage system. Will be something like:
|
||||||
|
// '/gotosocial/storage/6339820e-ef65-4166-a262-5a9f46adb1a7/emoji/small/bfa6c9c5-6c25-4ea4-98b4-d78b8126fb52.png'
|
||||||
|
ImageStaticPath string `pg:",notnull"`
|
||||||
|
// MIME content type of the emoji image
|
||||||
|
// Probably "image/png"
|
||||||
|
ImageContentType string `pg:",notnull"`
|
||||||
|
// Size of the emoji image file in bytes, for serving purposes.
|
||||||
|
ImageFileSize int `pg:",notnull"`
|
||||||
|
// Size of the static version of the emoji image file in bytes, for serving purposes.
|
||||||
|
ImageStaticFileSize int `pg:",notnull"`
|
||||||
|
// When was the emoji image last updated?
|
||||||
|
ImageUpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
|
||||||
|
// Has a moderation action disabled this emoji from being shown?
|
||||||
|
Disabled bool `pg:",notnull,default:false"`
|
||||||
|
// ActivityStreams uri of this emoji. Something like 'https://example.org/emojis/1234'
|
||||||
|
URI string `pg:",notnull,unique"`
|
||||||
|
// Is this emoji visible in the admin emoji picker?
|
||||||
|
VisibleInPicker bool `pg:",notnull,default:true"`
|
||||||
|
// In which emoji category is this emoji visible?
|
||||||
|
CategoryID string
|
||||||
|
}
|
|
@ -16,7 +16,7 @@
|
||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package model
|
package gtsmodel
|
||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
|
@ -16,7 +16,7 @@
|
||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package model
|
package gtsmodel
|
||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
|
@ -16,7 +16,7 @@
|
||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package model
|
package gtsmodel
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
@ -29,7 +29,9 @@ type MediaAttachment struct {
|
||||||
ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"`
|
ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"`
|
||||||
// ID of the status to which this is attached
|
// ID of the status to which this is attached
|
||||||
StatusID string
|
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
|
RemoteURL string
|
||||||
// When was the attachment created
|
// When was the attachment created
|
||||||
CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
|
CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
|
||||||
|
@ -81,7 +83,9 @@ type Thumbnail struct {
|
||||||
FileSize int
|
FileSize int
|
||||||
// When was the file last updated
|
// When was the file last updated
|
||||||
UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
|
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
|
RemoteURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -111,15 +115,18 @@ const (
|
||||||
FileTypeAudio FileType = "audio"
|
FileTypeAudio FileType = "audio"
|
||||||
// FileTypeVideo is for files with audio + visual
|
// FileTypeVideo is for files with audio + visual
|
||||||
FileTypeVideo FileType = "video"
|
FileTypeVideo FileType = "video"
|
||||||
|
// FileTypeUnknown is for unknown file types (surprise surprise!)
|
||||||
|
FileTypeUnknown FileType = "unknown"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FileMeta describes metadata about the actual contents of the file.
|
// FileMeta describes metadata about the actual contents of the file.
|
||||||
type FileMeta struct {
|
type FileMeta struct {
|
||||||
Original Original
|
Original Original
|
||||||
Small Small
|
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 {
|
type Small struct {
|
||||||
Width int
|
Width int
|
||||||
Height int
|
Height int
|
||||||
|
@ -127,10 +134,15 @@ type Small struct {
|
||||||
Aspect float64
|
Aspect float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImageOriginal implements OriginalMeta for still images
|
// Original can be used for original metadata for any media type
|
||||||
type Original struct {
|
type Original struct {
|
||||||
Width int
|
Width int
|
||||||
Height int
|
Height int
|
||||||
Size int
|
Size int
|
||||||
Aspect float64
|
Aspect float64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Focus struct {
|
||||||
|
X float32
|
||||||
|
Y float32
|
||||||
|
}
|
|
@ -0,0 +1,39 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package gtsmodel
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Mention refers to the 'tagging' or 'mention' of a user within a status.
|
||||||
|
type Mention struct {
|
||||||
|
// ID of this mention in the database
|
||||||
|
ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"`
|
||||||
|
// ID of the status this mention originates from
|
||||||
|
StatusID string `pg:",notnull"`
|
||||||
|
// When was this mention created?
|
||||||
|
CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
|
||||||
|
// When was this mention last updated?
|
||||||
|
UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
|
||||||
|
// Who created this mention?
|
||||||
|
OriginAccountID string `pg:",notnull"`
|
||||||
|
// Who does this mention target?
|
||||||
|
TargetAccountID string `pg:",notnull"`
|
||||||
|
// Prevent this mention from generating a notification?
|
||||||
|
Silent bool
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package gtsmodel
|
|
@ -0,0 +1,138 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package gtsmodel
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Status represents a user-created 'post' or 'status' in the database, either remote or local
|
||||||
|
type Status struct {
|
||||||
|
// id of the status in the database
|
||||||
|
ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull"`
|
||||||
|
// uri at which this status is reachable
|
||||||
|
URI string `pg:",unique"`
|
||||||
|
// web url for viewing this status
|
||||||
|
URL string `pg:",unique"`
|
||||||
|
// the html-formatted content of this status
|
||||||
|
Content string
|
||||||
|
// Database IDs of any media attachments associated with this status
|
||||||
|
Attachments []string `pg:",array"`
|
||||||
|
// Database IDs of any tags used in this status
|
||||||
|
Tags []string `pg:",array"`
|
||||||
|
// Database IDs of any accounts mentioned in this status
|
||||||
|
Mentions []string `pg:",array"`
|
||||||
|
// Database IDs of any emojis used in this status
|
||||||
|
Emojis []string `pg:",array"`
|
||||||
|
// when was this status created?
|
||||||
|
CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
|
||||||
|
// when was this status updated?
|
||||||
|
UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
|
||||||
|
// is this status from a local account?
|
||||||
|
Local bool
|
||||||
|
// which account posted this status?
|
||||||
|
AccountID string
|
||||||
|
// id of the status this status is a reply to
|
||||||
|
InReplyToID string
|
||||||
|
// id of the account that this status replies to
|
||||||
|
InReplyToAccountID string
|
||||||
|
// id of the status this status is a boost of
|
||||||
|
BoostOfID string
|
||||||
|
// cw string for this status
|
||||||
|
ContentWarning string
|
||||||
|
// visibility entry for this status
|
||||||
|
Visibility Visibility `pg:",notnull"`
|
||||||
|
// mark the status as sensitive?
|
||||||
|
Sensitive bool
|
||||||
|
// what language is this status written in?
|
||||||
|
Language string
|
||||||
|
// Which application was used to create this status?
|
||||||
|
CreatedWithApplicationID string
|
||||||
|
// advanced visibility for this status
|
||||||
|
VisibilityAdvanced *VisibilityAdvanced
|
||||||
|
// What is the activitystreams type of this status? See: https://www.w3.org/TR/activitystreams-vocabulary/#object-types
|
||||||
|
// Will probably almost always be Note but who knows!.
|
||||||
|
ActivityStreamsType ActivityStreamsObject
|
||||||
|
// Original text of the status without formatting
|
||||||
|
Text string
|
||||||
|
|
||||||
|
/*
|
||||||
|
NON-DATABASE FIELDS
|
||||||
|
|
||||||
|
These are for convenience while passing the status around internally,
|
||||||
|
but these fields should *never* be put in the db.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Mentions created in this status
|
||||||
|
GTSMentions []*Mention `pg:"-"`
|
||||||
|
// Hashtags used in this status
|
||||||
|
GTSTags []*Tag `pg:"-"`
|
||||||
|
// Emojis used in this status
|
||||||
|
GTSEmojis []*Emoji `pg:"-"`
|
||||||
|
// MediaAttachments used in this status
|
||||||
|
GTSMediaAttachments []*MediaAttachment `pg:"-"`
|
||||||
|
// Status being replied to
|
||||||
|
GTSReplyToStatus *Status `pg:"-"`
|
||||||
|
// Account being replied to
|
||||||
|
GTSReplyToAccount *Account `pg:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visibility represents the visibility granularity of a status.
|
||||||
|
type Visibility string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// This status will be visible to everyone on all timelines.
|
||||||
|
VisibilityPublic Visibility = "public"
|
||||||
|
// This status will be visible to everyone, but will only show on home timeline to followers, and in lists.
|
||||||
|
VisibilityUnlocked Visibility = "unlocked"
|
||||||
|
// This status is viewable to followers only.
|
||||||
|
VisibilityFollowersOnly Visibility = "followers_only"
|
||||||
|
// This status is visible to mutual followers only.
|
||||||
|
VisibilityMutualsOnly Visibility = "mutuals_only"
|
||||||
|
// This status is visible only to mentioned recipients
|
||||||
|
VisibilityDirect Visibility = "direct"
|
||||||
|
// Default visibility to use when no other setting can be found
|
||||||
|
VisibilityDefault Visibility = "public"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VisibilityAdvanced denotes a set of flags that can be set on a status for fine-tuning visibility and interactivity of the status.
|
||||||
|
type VisibilityAdvanced struct {
|
||||||
|
/*
|
||||||
|
ADVANCED SETTINGS -- These should all default to TRUE.
|
||||||
|
|
||||||
|
If PUBLIC is selected, they will all be overwritten to TRUE regardless of what is selected.
|
||||||
|
If UNLOCKED is selected, any of them can be turned on or off in any combination.
|
||||||
|
If FOLLOWERS-ONLY or MUTUALS-ONLY are selected, boostable will always be FALSE. The others can be turned on or off as desired.
|
||||||
|
If DIRECT is selected, boostable will be FALSE, and all other flags will be TRUE.
|
||||||
|
*/
|
||||||
|
// This status will be federated beyond the local timeline(s)
|
||||||
|
Federated bool `pg:"default:true"`
|
||||||
|
// This status can be boosted/reblogged
|
||||||
|
Boostable bool `pg:"default:true"`
|
||||||
|
// This status can be replied to
|
||||||
|
Replyable bool `pg:"default:true"`
|
||||||
|
// This status can be liked/faved
|
||||||
|
Likeable bool `pg:"default:true"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RelevantAccounts denotes accounts that are replied to, boosted by, or mentioned in a status.
|
||||||
|
type RelevantAccounts struct {
|
||||||
|
ReplyToAccount *Account
|
||||||
|
BoostedAccount *Account
|
||||||
|
BoostedReplyToAccount *Account
|
||||||
|
MentionedAccounts []*Account
|
||||||
|
}
|
|
@ -0,0 +1,35 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package gtsmodel
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// StatusBookmark refers to one account having a 'bookmark' of the status of another account
|
||||||
|
type StatusBookmark struct {
|
||||||
|
// id of this bookmark in the database
|
||||||
|
ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"`
|
||||||
|
// when was this bookmark created
|
||||||
|
CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
|
||||||
|
// id of the account that created ('did') the bookmarking
|
||||||
|
AccountID string `pg:",notnull"`
|
||||||
|
// id the account owning the bookmarked status
|
||||||
|
TargetAccountID string `pg:",notnull"`
|
||||||
|
// database id of the status that has been bookmarked
|
||||||
|
StatusID string `pg:",notnull"`
|
||||||
|
}
|
|
@ -0,0 +1,38 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package gtsmodel
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// StatusFave refers to a 'fave' or 'like' in the database, from one account, targeting the status of another account
|
||||||
|
type StatusFave struct {
|
||||||
|
// id of this fave in the database
|
||||||
|
ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"`
|
||||||
|
// when was this fave created
|
||||||
|
CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
|
||||||
|
// id of the account that created ('did') the fave
|
||||||
|
AccountID string `pg:",notnull"`
|
||||||
|
// id the account owning the faved status
|
||||||
|
TargetAccountID string `pg:",notnull"`
|
||||||
|
// database id of the status that has been 'faved'
|
||||||
|
StatusID string `pg:",notnull"`
|
||||||
|
|
||||||
|
// FavedStatus is the status being interacted with. It won't be put or retrieved from the db, it's just for conveniently passing a pointer around.
|
||||||
|
FavedStatus *Status `pg:"-"`
|
||||||
|
}
|
|
@ -0,0 +1,35 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package gtsmodel
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// StatusMute refers to one account having muted the status of another account or its own
|
||||||
|
type StatusMute struct {
|
||||||
|
// id of this mute in the database
|
||||||
|
ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"`
|
||||||
|
// when was this mute created
|
||||||
|
CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
|
||||||
|
// id of the account that created ('did') the mute
|
||||||
|
AccountID string `pg:",notnull"`
|
||||||
|
// id the account owning the muted status (can be the same as accountID)
|
||||||
|
TargetAccountID string `pg:",notnull"`
|
||||||
|
// database id of the status that has been muted
|
||||||
|
StatusID string `pg:",notnull"`
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package gtsmodel
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// StatusPin refers to a status 'pinned' to the top of an account
|
||||||
|
type StatusPin struct {
|
||||||
|
// id of this pin in the database
|
||||||
|
ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"`
|
||||||
|
// when was this pin created
|
||||||
|
CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
|
||||||
|
// id of the account that created ('did') the pinning (this should always be the same as the author of the status)
|
||||||
|
AccountID string `pg:",notnull"`
|
||||||
|
// database id of the status that has been pinned
|
||||||
|
StatusID string `pg:",notnull"`
|
||||||
|
}
|
|
@ -0,0 +1,41 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package gtsmodel
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Tag represents a hashtag for gathering public statuses together
|
||||||
|
type Tag struct {
|
||||||
|
// id of this tag in the database
|
||||||
|
ID string `pg:",unique,type:uuid,default:gen_random_uuid(),pk,notnull"`
|
||||||
|
// name of this tag -- the tag without the hash part
|
||||||
|
Name string `pg:",unique,pk,notnull"`
|
||||||
|
// Which account ID is the first one we saw using this tag?
|
||||||
|
FirstSeenFromAccountID string
|
||||||
|
// when was this tag created
|
||||||
|
CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
|
||||||
|
// when was this tag last updated
|
||||||
|
UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
|
||||||
|
// can our instance users use this tag?
|
||||||
|
Useable bool `pg:",notnull,default:true"`
|
||||||
|
// can our instance users look up this tag?
|
||||||
|
Listable bool `pg:",notnull,default:true"`
|
||||||
|
// when was this tag last used?
|
||||||
|
LastStatusAt time.Time `pg:"type:timestamp,notnull,default:now()"`
|
||||||
|
}
|
|
@ -16,7 +16,7 @@
|
||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package model
|
package gtsmodel
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net"
|
"net"
|
|
@ -6,9 +6,7 @@ import (
|
||||||
context "context"
|
context "context"
|
||||||
|
|
||||||
mock "github.com/stretchr/testify/mock"
|
mock "github.com/stretchr/testify/mock"
|
||||||
mastotypes "github.com/superseriousbusiness/gotosocial/pkg/mastotypes"
|
gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||||
|
|
||||||
model "github.com/superseriousbusiness/gotosocial/internal/db/model"
|
|
||||||
|
|
||||||
net "net"
|
net "net"
|
||||||
|
|
||||||
|
@ -20,22 +18,20 @@ type MockDB struct {
|
||||||
mock.Mock
|
mock.Mock
|
||||||
}
|
}
|
||||||
|
|
||||||
// AccountToMastoSensitive provides a mock function with given fields: account
|
// Blocked provides a mock function with given fields: account1, account2
|
||||||
func (_m *MockDB) AccountToMastoSensitive(account *model.Account) (*mastotypes.Account, error) {
|
func (_m *MockDB) Blocked(account1 string, account2 string) (bool, error) {
|
||||||
ret := _m.Called(account)
|
ret := _m.Called(account1, account2)
|
||||||
|
|
||||||
var r0 *mastotypes.Account
|
var r0 bool
|
||||||
if rf, ok := ret.Get(0).(func(*model.Account) *mastotypes.Account); ok {
|
if rf, ok := ret.Get(0).(func(string, string) bool); ok {
|
||||||
r0 = rf(account)
|
r0 = rf(account1, account2)
|
||||||
} else {
|
} else {
|
||||||
if ret.Get(0) != nil {
|
r0 = ret.Get(0).(bool)
|
||||||
r0 = ret.Get(0).(*mastotypes.Account)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var r1 error
|
var r1 error
|
||||||
if rf, ok := ret.Get(1).(func(*model.Account) error); ok {
|
if rf, ok := ret.Get(1).(func(string, string) error); ok {
|
||||||
r1 = rf(account)
|
r1 = rf(account1, account2)
|
||||||
} else {
|
} else {
|
||||||
r1 = ret.Error(1)
|
r1 = ret.Error(1)
|
||||||
}
|
}
|
||||||
|
@ -99,6 +95,29 @@ func (_m *MockDB) DropTable(i interface{}) error {
|
||||||
return r0
|
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:
|
// Federation provides a mock function with given fields:
|
||||||
func (_m *MockDB) Federation() pub.Database {
|
func (_m *MockDB) Federation() pub.Database {
|
||||||
ret := _m.Called()
|
ret := _m.Called()
|
||||||
|
@ -116,11 +135,11 @@ func (_m *MockDB) Federation() pub.Database {
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAccountByUserID provides a mock function with given fields: userID, account
|
// 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)
|
ret := _m.Called(userID, account)
|
||||||
|
|
||||||
var r0 error
|
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)
|
r0 = rf(userID, account)
|
||||||
} else {
|
} else {
|
||||||
r0 = ret.Error(0)
|
r0 = ret.Error(0)
|
||||||
|
@ -143,6 +162,20 @@ func (_m *MockDB) GetAll(i interface{}) error {
|
||||||
return r0
|
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
|
// GetByID provides a mock function with given fields: id, i
|
||||||
func (_m *MockDB) GetByID(id string, i interface{}) error {
|
func (_m *MockDB) GetByID(id string, i interface{}) error {
|
||||||
ret := _m.Called(id, i)
|
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
|
// 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)
|
ret := _m.Called(accountID, followRequests)
|
||||||
|
|
||||||
var r0 error
|
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)
|
r0 = rf(accountID, followRequests)
|
||||||
} else {
|
} else {
|
||||||
r0 = ret.Error(0)
|
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
|
// 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)
|
ret := _m.Called(accountID, followers)
|
||||||
|
|
||||||
var r0 error
|
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)
|
r0 = rf(accountID, followers)
|
||||||
} else {
|
} else {
|
||||||
r0 = ret.Error(0)
|
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
|
// 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)
|
ret := _m.Called(accountID, following)
|
||||||
|
|
||||||
var r0 error
|
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)
|
r0 = rf(accountID, following)
|
||||||
} else {
|
} else {
|
||||||
r0 = ret.Error(0)
|
r0 = ret.Error(0)
|
||||||
|
@ -199,12 +232,26 @@ func (_m *MockDB) GetFollowingByAccountID(accountID string, following *[]model.F
|
||||||
return r0
|
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
|
// 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)
|
ret := _m.Called(accountID, status)
|
||||||
|
|
||||||
var r0 error
|
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)
|
r0 = rf(accountID, status)
|
||||||
} else {
|
} else {
|
||||||
r0 = ret.Error(0)
|
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
|
// 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)
|
ret := _m.Called(accountID, statuses)
|
||||||
|
|
||||||
var r0 error
|
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)
|
r0 = rf(accountID, statuses)
|
||||||
} else {
|
} else {
|
||||||
r0 = ret.Error(0)
|
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
|
// 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)
|
ret := _m.Called(accountID, statuses, limit)
|
||||||
|
|
||||||
var r0 error
|
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)
|
r0 = rf(accountID, statuses, limit)
|
||||||
} else {
|
} else {
|
||||||
r0 = ret.Error(0)
|
r0 = ret.Error(0)
|
||||||
|
@ -297,16 +344,39 @@ func (_m *MockDB) IsUsernameAvailable(username string) error {
|
||||||
return r0
|
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
|
// 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)
|
ret := _m.Called(username, reason, requireApproval, email, password, signUpIP, locale, appID)
|
||||||
|
|
||||||
var r0 *model.User
|
var r0 *gtsmodel.User
|
||||||
if rf, ok := ret.Get(0).(func(string, string, bool, string, string, net.IP, string, string) *model.User); ok {
|
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)
|
r0 = rf(username, reason, requireApproval, email, password, signUpIP, locale, appID)
|
||||||
} else {
|
} else {
|
||||||
if ret.Get(0) != nil {
|
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
|
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
|
// Stop provides a mock function with given fields: ctx
|
||||||
func (_m *MockDB) Stop(ctx context.Context) error {
|
func (_m *MockDB) Stop(ctx context.Context) error {
|
||||||
ret := _m.Called(ctx)
|
ret := _m.Called(ctx)
|
||||||
|
@ -348,6 +432,29 @@ func (_m *MockDB) Stop(ctx context.Context) error {
|
||||||
return r0
|
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
|
// UpdateByID provides a mock function with given fields: id, i
|
||||||
func (_m *MockDB) UpdateByID(id string, i interface{}) error {
|
func (_m *MockDB) UpdateByID(id string, i interface{}) error {
|
||||||
ret := _m.Called(id, i)
|
ret := _m.Called(id, i)
|
||||||
|
@ -361,3 +468,17 @@ func (_m *MockDB) UpdateByID(id string, i interface{}) error {
|
||||||
|
|
||||||
return r0
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -1,63 +0,0 @@
|
||||||
/*
|
|
||||||
GoToSocial
|
|
||||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
(at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package model
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// Status represents a user-created 'post' or 'status' in the database, either remote or local
|
|
||||||
type Status struct {
|
|
||||||
// id of the status in the database
|
|
||||||
ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull"`
|
|
||||||
// uri at which this status is reachable
|
|
||||||
URI string `pg:",unique"`
|
|
||||||
// web url for viewing this status
|
|
||||||
URL string `pg:",unique"`
|
|
||||||
// the html-formatted content of this status
|
|
||||||
Content string
|
|
||||||
// when was this status created?
|
|
||||||
CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
|
|
||||||
// when was this status updated?
|
|
||||||
UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"`
|
|
||||||
// is this status from a local account?
|
|
||||||
Local bool
|
|
||||||
// which account posted this status?
|
|
||||||
AccountID string
|
|
||||||
// id of the status this status is a reply to
|
|
||||||
InReplyToID string
|
|
||||||
// id of the status this status is a boost of
|
|
||||||
BoostOfID string
|
|
||||||
// cw string for this status
|
|
||||||
ContentWarning string
|
|
||||||
// visibility entry for this status
|
|
||||||
Visibility *Visibility
|
|
||||||
}
|
|
||||||
|
|
||||||
// Visibility represents the visibility granularity of a status. It is a combination of flags.
|
|
||||||
type Visibility struct {
|
|
||||||
// Is this status viewable as a direct message?
|
|
||||||
Direct bool
|
|
||||||
// Is this status viewable to followers?
|
|
||||||
Followers bool
|
|
||||||
// Is this status viewable on the local timeline?
|
|
||||||
Local bool
|
|
||||||
// Is this status boostable but not shown on public timelines?
|
|
||||||
Unlisted bool
|
|
||||||
// Is this status shown on public and federated timelines?
|
|
||||||
Public bool
|
|
||||||
}
|
|
|
@ -34,11 +34,11 @@ import (
|
||||||
"github.com/go-pg/pg/extra/pgdebug"
|
"github.com/go-pg/pg/extra/pgdebug"
|
||||||
"github.com/go-pg/pg/v10"
|
"github.com/go-pg/pg/v10"
|
||||||
"github.com/go-pg/pg/v10/orm"
|
"github.com/go-pg/pg/v10/orm"
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
"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/internal/util"
|
||||||
"github.com/superseriousbusiness/gotosocial/pkg/mastotypes"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"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)
|
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
|
// create a connection
|
||||||
pgCtx, cancel := context.WithCancel(ctx)
|
pgCtx, cancel := context.WithCancel(ctx)
|
||||||
conn := pg.Connect(opts).WithContext(pgCtx)
|
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
|
// actually *begin* the connection so that we can tell if the db is there and listening
|
||||||
// and listening, and also trigger the opts.OnConnect function passed in above
|
|
||||||
if err := conn.Ping(ctx); err != nil {
|
if err := conn.Ping(ctx); err != nil {
|
||||||
cancel()
|
cancel()
|
||||||
return nil, fmt.Errorf("db connection error: %s", err)
|
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)
|
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{
|
ps := &postgresService{
|
||||||
config: c,
|
config: c,
|
||||||
conn: conn,
|
conn: conn,
|
||||||
|
@ -214,9 +197,9 @@ func (ps *postgresService) IsHealthy(ctx context.Context) error {
|
||||||
|
|
||||||
func (ps *postgresService) CreateSchema(ctx context.Context) error {
|
func (ps *postgresService) CreateSchema(ctx context.Context) error {
|
||||||
models := []interface{}{
|
models := []interface{}{
|
||||||
(*model.Account)(nil),
|
(*gtsmodel.Account)(nil),
|
||||||
(*model.Status)(nil),
|
(*gtsmodel.Status)(nil),
|
||||||
(*model.User)(nil),
|
(*gtsmodel.User)(nil),
|
||||||
}
|
}
|
||||||
ps.log.Info("creating db schema")
|
ps.log.Info("creating db schema")
|
||||||
|
|
||||||
|
@ -254,6 +237,10 @@ func (ps *postgresService) GetWhere(key string, value interface{}, i interface{}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// func (ps *postgresService) GetWhereMany(i interface{}, where ...model.Where) error {
|
||||||
|
// return nil
|
||||||
|
// }
|
||||||
|
|
||||||
func (ps *postgresService) GetAll(i interface{}) error {
|
func (ps *postgresService) GetAll(i interface{}) error {
|
||||||
if err := ps.conn.Model(i).Select(); err != nil {
|
if err := ps.conn.Model(i).Select(); err != nil {
|
||||||
if err == pg.ErrNoRows {
|
if err == pg.ErrNoRows {
|
||||||
|
@ -269,8 +256,18 @@ func (ps *postgresService) Put(i interface{}) error {
|
||||||
return err
|
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 {
|
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 {
|
if err == pg.ErrNoRows {
|
||||||
return ErrNoEntries{}
|
return ErrNoEntries{}
|
||||||
}
|
}
|
||||||
|
@ -308,8 +305,25 @@ func (ps *postgresService) DeleteWhere(key string, value interface{}, i interfac
|
||||||
HANDY SHORTCUTS
|
HANDY SHORTCUTS
|
||||||
*/
|
*/
|
||||||
|
|
||||||
func (ps *postgresService) GetAccountByUserID(userID string, account *model.Account) error {
|
func (ps *postgresService) CreateInstanceAccount() error {
|
||||||
user := &model.User{
|
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,
|
ID: userID,
|
||||||
}
|
}
|
||||||
if err := ps.conn.Model(user).Where("id = ?", userID).Select(); err != nil {
|
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
|
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 := ps.conn.Model(followRequests).Where("target_account_id = ?", accountID).Select(); err != nil {
|
||||||
if err == pg.ErrNoRows {
|
if err == pg.ErrNoRows {
|
||||||
return ErrNoEntries{}
|
return ErrNoEntries{}
|
||||||
|
@ -337,7 +351,7 @@ func (ps *postgresService) GetFollowRequestsForAccountID(accountID string, follo
|
||||||
return nil
|
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 := ps.conn.Model(following).Where("account_id = ?", accountID).Select(); err != nil {
|
||||||
if err == pg.ErrNoRows {
|
if err == pg.ErrNoRows {
|
||||||
return ErrNoEntries{}
|
return ErrNoEntries{}
|
||||||
|
@ -347,7 +361,7 @@ func (ps *postgresService) GetFollowingByAccountID(accountID string, following *
|
||||||
return nil
|
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 := ps.conn.Model(followers).Where("target_account_id = ?", accountID).Select(); err != nil {
|
||||||
if err == pg.ErrNoRows {
|
if err == pg.ErrNoRows {
|
||||||
return ErrNoEntries{}
|
return ErrNoEntries{}
|
||||||
|
@ -357,7 +371,7 @@ func (ps *postgresService) GetFollowersByAccountID(accountID string, followers *
|
||||||
return nil
|
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 := ps.conn.Model(statuses).Where("account_id = ?", accountID).Select(); err != nil {
|
||||||
if err == pg.ErrNoRows {
|
if err == pg.ErrNoRows {
|
||||||
return ErrNoEntries{}
|
return ErrNoEntries{}
|
||||||
|
@ -367,7 +381,7 @@ func (ps *postgresService) GetStatusesByAccountID(accountID string, statuses *[]
|
||||||
return nil
|
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")
|
q := ps.conn.Model(statuses).Order("created_at DESC")
|
||||||
if limit != 0 {
|
if limit != 0 {
|
||||||
q = q.Limit(limit)
|
q = q.Limit(limit)
|
||||||
|
@ -384,7 +398,7 @@ func (ps *postgresService) GetStatusesByTimeDescending(accountID string, statuse
|
||||||
return nil
|
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 := ps.conn.Model(status).Order("created_at DESC").Limit(1).Where("account_id = ?", accountID).Select(); err != nil {
|
||||||
if err == pg.ErrNoRows {
|
if err == pg.ErrNoRows {
|
||||||
return ErrNoEntries{}
|
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 no error we fail because it means we found something
|
||||||
// if error but it's not pg.ErrNoRows then we fail
|
// 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 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)
|
return fmt.Errorf("username %s already in use", username)
|
||||||
} else if err != pg.ErrNoRows {
|
} else if err != pg.ErrNoRows {
|
||||||
return fmt.Errorf("db error: %s", err)
|
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 @
|
domain := strings.Split(m.Address, "@")[1] // domain will always be the second part after @
|
||||||
|
|
||||||
// check if the email domain is blocked
|
// 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
|
// fail because we found something
|
||||||
return fmt.Errorf("email domain %s is blocked", domain)
|
return fmt.Errorf("email domain %s is blocked", domain)
|
||||||
} else if err != pg.ErrNoRows {
|
} 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
|
// 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
|
// fail because we found something
|
||||||
return fmt.Errorf("email %s already in use", email)
|
return fmt.Errorf("email %s already in use", email)
|
||||||
} else if err != pg.ErrNoRows {
|
} else if err != pg.ErrNoRows {
|
||||||
|
@ -435,7 +449,7 @@ func (ps *postgresService) IsEmailAvailable(email string) error {
|
||||||
return nil
|
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)
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ps.log.Errorf("error creating new rsa key: %s", err)
|
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)
|
uris := util.GenerateURIs(username, ps.config.Protocol, ps.config.Host)
|
||||||
|
|
||||||
a := &model.Account{
|
a := >smodel.Account{
|
||||||
Username: username,
|
Username: username,
|
||||||
DisplayName: username,
|
DisplayName: username,
|
||||||
Reason: reason,
|
Reason: reason,
|
||||||
URL: uris.UserURL,
|
URL: uris.UserURL,
|
||||||
PrivateKey: key,
|
PrivateKey: key,
|
||||||
PublicKey: &key.PublicKey,
|
PublicKey: &key.PublicKey,
|
||||||
ActorType: "Person",
|
ActorType: gtsmodel.ActivityStreamsPerson,
|
||||||
URI: uris.UserURI,
|
URI: uris.UserURI,
|
||||||
InboxURL: uris.InboxURL,
|
InboxURL: uris.InboxURI,
|
||||||
OutboxURL: uris.OutboxURL,
|
OutboxURL: uris.OutboxURI,
|
||||||
FollowersURL: uris.FollowersURL,
|
FollowersURL: uris.FollowersURI,
|
||||||
FeaturedCollectionURL: uris.CollectionURL,
|
FeaturedCollectionURL: uris.CollectionURI,
|
||||||
}
|
}
|
||||||
if _, err = ps.conn.Model(a).Insert(); err != nil {
|
if _, err = ps.conn.Model(a).Insert(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -466,7 +480,7 @@ func (ps *postgresService) NewSignup(username string, reason string, requireAppr
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error hashing password: %s", err)
|
return nil, fmt.Errorf("error hashing password: %s", err)
|
||||||
}
|
}
|
||||||
u := &model.User{
|
u := >smodel.User{
|
||||||
AccountID: a.ID,
|
AccountID: a.ID,
|
||||||
EncryptedPassword: string(pw),
|
EncryptedPassword: string(pw),
|
||||||
SignUpIP: signUpIP,
|
SignUpIP: signUpIP,
|
||||||
|
@ -482,13 +496,45 @@ func (ps *postgresService) NewSignup(username string, reason string, requireAppr
|
||||||
return u, nil
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ps *postgresService) SetHeaderOrAvatarForAccountID(mediaAttachment *model.MediaAttachment, accountID string) error {
|
func (ps *postgresService) SetHeaderOrAvatarForAccountID(mediaAttachment *gtsmodel.MediaAttachment, accountID string) error {
|
||||||
_, err := ps.conn.Model(mediaAttachment).Insert()
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ps *postgresService) GetHeaderForAccountID(header *model.MediaAttachment, accountID string) error {
|
if _, err := ps.conn.Model(>smodel.Account{}).Set(fmt.Sprintf("%s_media_attachment_id = ?", headerOrAVI), mediaAttachment.ID).Where("id = ?", accountID).Update(); err != nil {
|
||||||
if err := ps.conn.Model(header).Where("account_id = ?", accountID).Where("header = ?", true).Select(); err != nil {
|
return err
|
||||||
|
}
|
||||||
|
return 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 {
|
if err == pg.ErrNoRows {
|
||||||
return ErrNoEntries{}
|
return ErrNoEntries{}
|
||||||
}
|
}
|
||||||
|
@ -497,8 +543,20 @@ func (ps *postgresService) GetHeaderForAccountID(header *model.MediaAttachment,
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ps *postgresService) GetAvatarForAccountID(avatar *model.MediaAttachment, accountID string) error {
|
func (ps *postgresService) GetAvatarForAccountID(avatar *gtsmodel.MediaAttachment, accountID string) error {
|
||||||
if err := ps.conn.Model(avatar).Where("account_id = ?", accountID).Where("avatar = ?", true).Select(); err != nil {
|
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 {
|
if err == pg.ErrNoRows {
|
||||||
return ErrNoEntries{}
|
return ErrNoEntries{}
|
||||||
}
|
}
|
||||||
|
@ -507,156 +565,480 @@ func (ps *postgresService) GetAvatarForAccountID(avatar *model.MediaAttachment,
|
||||||
return nil
|
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
|
CONVERSION FUNCTIONS
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// AccountToMastoSensitive takes an internal account model and transforms it into an account ready to be served through the API.
|
func (ps *postgresService) MentionStringsToMentions(targetAccounts []string, originAccountID string, statusID string) ([]*gtsmodel.Mention, error) {
|
||||||
// The resulting account fits the specifications for the path /api/v1/accounts/verify_credentials, as described here:
|
menchies := []*gtsmodel.Mention{}
|
||||||
// https://docs.joinmastodon.org/methods/accounts/. Note that it's *sensitive* because it's only meant to be exposed to the user
|
for _, a := range targetAccounts {
|
||||||
// that the account actually belongs to.
|
// A mentioned account looks like "@test@example.org" or just "@test" for a local account
|
||||||
func (ps *postgresService) AccountToMastoSensitive(a *model.Account) (*mastotypes.Account, error) {
|
// -- we can guarantee this from the regex that targetAccounts should have been derived from.
|
||||||
// we can build this sensitive account easily by first getting the public account....
|
// But we still need to do a bit of fiddling to get what we need here -- the username and domain (if given).
|
||||||
mastoAccount, err := ps.AccountToMastoPublic(a)
|
|
||||||
if err != nil {
|
// 1. trim off the first @
|
||||||
return nil, err
|
t := strings.TrimPrefix(a, "@")
|
||||||
|
|
||||||
|
// 2. split the username and domain
|
||||||
|
s := strings.Split(t, "@")
|
||||||
|
|
||||||
|
// 3. if it's length 1 it's a local account, length 2 means remote, anything else means something is wrong
|
||||||
|
var local bool
|
||||||
|
switch len(s) {
|
||||||
|
case 1:
|
||||||
|
local = true
|
||||||
|
case 2:
|
||||||
|
local = false
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("mentioned account format '%s' was not valid", a)
|
||||||
}
|
}
|
||||||
|
|
||||||
// then adding the Source object to it...
|
var username, domain string
|
||||||
|
username = s[0]
|
||||||
// check pending follow requests aimed at this account
|
if !local {
|
||||||
fr := []model.FollowRequest{}
|
domain = s[1]
|
||||||
if err := ps.GetFollowRequestsForAccountID(a.ID, &fr); err != nil {
|
|
||||||
if _, ok := err.(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{
|
// 4. check we now have a proper username and domain
|
||||||
Privacy: a.Privacy,
|
if username == "" || (!local && domain == "") {
|
||||||
Sensitive: a.Sensitive,
|
return nil, fmt.Errorf("username or domain for '%s' was nil", a)
|
||||||
Language: a.Language,
|
|
||||||
Note: a.Note,
|
|
||||||
Fields: mastoAccount.Fields,
|
|
||||||
FollowRequestsCount: frc,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return mastoAccount, nil
|
// okay we're good now, we can start pulling accounts out of the database
|
||||||
}
|
mentionedAccount := >smodel.Account{}
|
||||||
|
var err error
|
||||||
func (ps *postgresService) AccountToMastoPublic(a *model.Account) (*mastotypes.Account, error) {
|
if local {
|
||||||
// count followers
|
// local user -- should have a null domain
|
||||||
followers := []model.Follow{}
|
err = ps.conn.Model(mentionedAccount).Where("username = ?", username).Where("? IS NULL", pg.Ident("domain")).Select()
|
||||||
if err := ps.GetFollowersByAccountID(a.ID, &followers); err != nil {
|
|
||||||
if _, ok := err.(ErrNoEntries); !ok {
|
|
||||||
return nil, fmt.Errorf("error getting followers: %s", 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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 {
|
} else {
|
||||||
// this is a local user
|
// remote user -- should have domain defined
|
||||||
acct = a.Username
|
err = ps.conn.Model(mentionedAccount).Where("username = ?", username).Where("? = ?", pg.Ident("domain"), domain).Select()
|
||||||
}
|
}
|
||||||
|
|
||||||
return &mastotypes.Account{
|
if err != nil {
|
||||||
ID: a.ID,
|
if err == pg.ErrNoRows {
|
||||||
Username: a.Username,
|
// no result found for this username/domain so just don't include it as a mencho and carry on about our business
|
||||||
Acct: acct,
|
ps.log.Debugf("no account found with username '%s' and domain '%s', skipping it", username, domain)
|
||||||
DisplayName: a.DisplayName,
|
continue
|
||||||
Locked: a.Locked,
|
}
|
||||||
Bot: a.Bot,
|
// a serious error has happened so bail
|
||||||
CreatedAt: a.CreatedAt.Format(time.RFC3339),
|
return nil, fmt.Errorf("error getting account with username '%s' and domain '%s': %s", username, domain, err)
|
||||||
Note: a.Note,
|
}
|
||||||
URL: a.URL,
|
|
||||||
Avatar: aviURL,
|
// id, createdAt and updatedAt will be populated by the db, so we have everything we need!
|
||||||
AvatarStatic: aviURLStatic,
|
menchies = append(menchies, >smodel.Mention{
|
||||||
Header: headerURL,
|
StatusID: statusID,
|
||||||
HeaderStatic: headerURLStatic,
|
OriginAccountID: originAccountID,
|
||||||
FollowersCount: followersCount,
|
TargetAccountID: mentionedAccount.ID,
|
||||||
FollowingCount: followingCount,
|
})
|
||||||
StatusesCount: statusesCount,
|
}
|
||||||
LastStatusAt: lastStatusAt,
|
return menchies, nil
|
||||||
Emojis: nil, // TODO: implement this
|
}
|
||||||
Fields: fields,
|
|
||||||
}, nil
|
func (ps *postgresService) TagStringsToTags(tags []string, originAccountID string, statusID string) ([]*gtsmodel.Tag, error) {
|
||||||
|
newTags := []*gtsmodel.Tag{}
|
||||||
|
for _, t := range tags {
|
||||||
|
tag := >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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// bail already if the tag isn't useable
|
||||||
|
if !tag.Useable {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tag.LastStatusAt = time.Now()
|
||||||
|
newTags = append(newTags, tag)
|
||||||
|
}
|
||||||
|
return newTags, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ps *postgresService) EmojiStringsToEmojis(emojis []string, originAccountID string, statusID string) ([]*gtsmodel.Emoji, error) {
|
||||||
|
newEmojis := []*gtsmodel.Emoji{}
|
||||||
|
for _, e := range emojis {
|
||||||
|
emoji := >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
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,8 +19,8 @@
|
||||||
package distributor
|
package distributor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/go-fed/activity/pub"
|
|
||||||
"github.com/sirupsen/logrus"
|
"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
|
// 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
|
// 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.
|
// for clean distribution of messages without slowing down the client API and harming the user experience.
|
||||||
type Distributor interface {
|
type Distributor interface {
|
||||||
// ClientAPIIn returns a channel for accepting messages that come from the gts client API.
|
// FromClientAPI returns a channel for accepting messages that come from the gts client API.
|
||||||
ClientAPIIn() chan interface{}
|
FromClientAPI() chan FromClientAPI
|
||||||
// ClientAPIOut returns a channel for putting in messages that need to go to the gts client API.
|
// 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 starts the Distributor, reading from its channels and passing messages back and forth.
|
||||||
Start() error
|
Start() error
|
||||||
// Stop stops the distributor cleanly, finishing handling any remaining messages before closing down.
|
// 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
|
// distributor just implements the Distributor interface
|
||||||
type distributor struct {
|
type distributor struct {
|
||||||
federator pub.FederatingActor
|
// federator pub.FederatingActor
|
||||||
clientAPIIn chan interface{}
|
fromClientAPI chan FromClientAPI
|
||||||
clientAPIOut chan interface{}
|
toClientAPI chan ToClientAPI
|
||||||
stop chan interface{}
|
stop chan interface{}
|
||||||
log *logrus.Logger
|
log *logrus.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns a new Distributor that uses the given federator and 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{
|
return &distributor{
|
||||||
federator: federator,
|
// federator: federator,
|
||||||
clientAPIIn: make(chan interface{}, 100),
|
fromClientAPI: make(chan FromClientAPI, 100),
|
||||||
clientAPIOut: make(chan interface{}, 100),
|
toClientAPI: make(chan ToClientAPI, 100),
|
||||||
stop: make(chan interface{}),
|
stop: make(chan interface{}),
|
||||||
log: log,
|
log: log,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClientAPIIn returns a channel for accepting messages that come from the gts client API.
|
// ClientAPIIn returns a channel for accepting messages that come from the gts client API.
|
||||||
func (d *distributor) ClientAPIIn() chan interface{} {
|
func (d *distributor) FromClientAPI() chan FromClientAPI {
|
||||||
return d.clientAPIIn
|
return d.fromClientAPI
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClientAPIOut returns a channel for putting in messages that need to go to the gts client API.
|
// ClientAPIOut returns a channel for putting in messages that need to go to the gts client API.
|
||||||
func (d *distributor) ClientAPIOut() chan interface{} {
|
func (d *distributor) ToClientAPI() chan ToClientAPI {
|
||||||
return d.clientAPIOut
|
return d.toClientAPI
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start starts the Distributor, reading from its channels and passing messages back and forth.
|
// Start starts the Distributor, reading from its channels and passing messages back and forth.
|
||||||
|
@ -76,10 +76,10 @@ func (d *distributor) Start() error {
|
||||||
DistLoop:
|
DistLoop:
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case clientMsgIn := <-d.clientAPIIn:
|
case clientMsg := <-d.fromClientAPI:
|
||||||
d.log.Infof("received clientMsgIn: %+v", clientMsgIn)
|
d.log.Infof("received message FROM client API: %+v", clientMsg)
|
||||||
case clientMsgOut := <-d.clientAPIOut:
|
case clientMsg := <-d.toClientAPI:
|
||||||
d.log.Infof("received clientMsgOut: %+v", clientMsgOut)
|
d.log.Infof("received message TO client API: %+v", clientMsg)
|
||||||
case <-d.stop:
|
case <-d.stop:
|
||||||
break DistLoop
|
break DistLoop
|
||||||
}
|
}
|
||||||
|
@ -94,3 +94,15 @@ func (d *distributor) Stop() error {
|
||||||
close(d.stop)
|
close(d.stop)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FromClientAPI struct {
|
||||||
|
APObjectType gtsmodel.ActivityStreamsObject
|
||||||
|
APActivityType gtsmodel.ActivityStreamsActivity
|
||||||
|
Activity interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToClientAPI struct {
|
||||||
|
APObjectType gtsmodel.ActivityStreamsObject
|
||||||
|
APActivityType gtsmodel.ActivityStreamsActivity
|
||||||
|
Activity interface{}
|
||||||
|
}
|
||||||
|
|
|
@ -9,32 +9,16 @@ type MockDistributor struct {
|
||||||
mock.Mock
|
mock.Mock
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClientAPIIn provides a mock function with given fields:
|
// FromClientAPI provides a mock function with given fields:
|
||||||
func (_m *MockDistributor) ClientAPIIn() chan interface{} {
|
func (_m *MockDistributor) FromClientAPI() chan FromClientAPI {
|
||||||
ret := _m.Called()
|
ret := _m.Called()
|
||||||
|
|
||||||
var r0 chan interface{}
|
var r0 chan FromClientAPI
|
||||||
if rf, ok := ret.Get(0).(func() chan interface{}); ok {
|
if rf, ok := ret.Get(0).(func() chan FromClientAPI); ok {
|
||||||
r0 = rf()
|
r0 = rf()
|
||||||
} else {
|
} else {
|
||||||
if ret.Get(0) != nil {
|
if ret.Get(0) != nil {
|
||||||
r0 = ret.Get(0).(chan interface{})
|
r0 = ret.Get(0).(chan FromClientAPI)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return r0
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClientAPIOut provides a mock function with given fields:
|
|
||||||
func (_m *MockDistributor) ClientAPIOut() chan interface{} {
|
|
||||||
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{})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -68,3 +52,19 @@ func (_m *MockDistributor) Stop() error {
|
||||||
|
|
||||||
return r0
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -29,12 +29,19 @@ import (
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/action"
|
"github.com/superseriousbusiness/gotosocial/internal/action"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/apimodule"
|
"github.com/superseriousbusiness/gotosocial/internal/apimodule"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/apimodule/account"
|
"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/app"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/apimodule/auth"
|
"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/cache"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/distributor"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/federation"
|
"github.com/superseriousbusiness/gotosocial/internal/federation"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/mastotypes"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/router"
|
"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)
|
return fmt.Errorf("error creating router: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
storageBackend, err := storage.NewInMem(c, log)
|
storageBackend, err := storage.NewLocal(c, log)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error creating storage backend: %s", err)
|
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
|
// build backend handlers
|
||||||
mediaHandler := media.New(c, dbService, storageBackend, log)
|
mediaHandler := media.New(c, dbService, storageBackend, log)
|
||||||
oauthServer := oauth.New(dbService, 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
|
// build client api modules
|
||||||
authModule := auth.New(oauthServer, dbService, log)
|
authModule := auth.New(oauthServer, dbService, log)
|
||||||
accountModule := account.New(c, dbService, oauthServer, mediaHandler, log)
|
accountModule := account.New(c, dbService, oauthServer, mediaHandler, mastoConverter, log)
|
||||||
appsModule := app.New(oauthServer, dbService, 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{
|
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,
|
accountModule,
|
||||||
appsModule,
|
appsModule,
|
||||||
|
mm,
|
||||||
|
fileServerModule,
|
||||||
|
adminModule,
|
||||||
|
statusModule,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, m := range apiModules {
|
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)
|
gts, err := New(dbService, &cache.MockCache{}, router, federation.New(dbService, log), c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error creating gotosocial service: %s", err)
|
return fmt.Errorf("error creating gotosocial service: %s", err)
|
||||||
|
|
|
@ -26,3 +26,17 @@ func (_m *MockGotosocial) Start(_a0 context.Context) error {
|
||||||
|
|
||||||
return r0
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,544 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package mastotypes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
|
||||||
|
mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Converter is an interface for the common action of converting between mastotypes (frontend, serializable) models and internal gts models used in the database.
|
||||||
|
// It requires access to the database because many of the conversions require pulling out database entries and counting them etc.
|
||||||
|
type Converter interface {
|
||||||
|
// AccountToMastoSensitive takes a db model account as a param, and returns a populated mastotype account, or an error
|
||||||
|
// if something goes wrong. The returned account should be ready to serialize on an API level, and may have sensitive fields,
|
||||||
|
// so serve it only to an authorized user who should have permission to see it.
|
||||||
|
AccountToMastoSensitive(account *gtsmodel.Account) (*mastotypes.Account, error)
|
||||||
|
|
||||||
|
// AccountToMastoPublic takes a db model account as a param, and returns a populated mastotype account, or an error
|
||||||
|
// if something goes wrong. The returned account should be ready to serialize on an API level, and may NOT have sensitive fields.
|
||||||
|
// In other words, this is the public record that the server has of an account.
|
||||||
|
AccountToMastoPublic(account *gtsmodel.Account) (*mastotypes.Account, error)
|
||||||
|
|
||||||
|
// AppToMastoSensitive takes a db model application as a param, and returns a populated mastotype application, or an error
|
||||||
|
// if something goes wrong. The returned application should be ready to serialize on an API level, and may have sensitive fields
|
||||||
|
// (such as client id and client secret), so serve it only to an authorized user who should have permission to see it.
|
||||||
|
AppToMastoSensitive(application *gtsmodel.Application) (*mastotypes.Application, error)
|
||||||
|
|
||||||
|
// AppToMastoPublic takes a db model application as a param, and returns a populated mastotype application, or an error
|
||||||
|
// if something goes wrong. The returned application should be ready to serialize on an API level, and has sensitive
|
||||||
|
// fields sanitized so that it can be served to non-authorized accounts without revealing any private information.
|
||||||
|
AppToMastoPublic(application *gtsmodel.Application) (*mastotypes.Application, error)
|
||||||
|
|
||||||
|
// AttachmentToMasto converts a gts model media attacahment into its mastodon representation for serialization on the API.
|
||||||
|
AttachmentToMasto(attachment *gtsmodel.MediaAttachment) (mastotypes.Attachment, error)
|
||||||
|
|
||||||
|
// MentionToMasto converts a gts model mention into its mastodon (frontend) representation for serialization on the API.
|
||||||
|
MentionToMasto(m *gtsmodel.Mention) (mastotypes.Mention, error)
|
||||||
|
|
||||||
|
// EmojiToMasto converts a gts model emoji into its mastodon (frontend) representation for serialization on the API.
|
||||||
|
EmojiToMasto(e *gtsmodel.Emoji) (mastotypes.Emoji, error)
|
||||||
|
|
||||||
|
// TagToMasto converts a gts model tag into its mastodon (frontend) representation for serialization on the API.
|
||||||
|
TagToMasto(t *gtsmodel.Tag) (mastotypes.Tag, error)
|
||||||
|
|
||||||
|
// StatusToMasto converts a gts model status into its mastodon (frontend) representation for serialization on the API.
|
||||||
|
StatusToMasto(s *gtsmodel.Status, targetAccount *gtsmodel.Account, requestingAccount *gtsmodel.Account, boostOfAccount *gtsmodel.Account, replyToAccount *gtsmodel.Account, reblogOfStatus *gtsmodel.Status) (*mastotypes.Status, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type converter struct {
|
||||||
|
config *config.Config
|
||||||
|
db db.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a new Converter
|
||||||
|
func New(config *config.Config, db db.DB) Converter {
|
||||||
|
return &converter{
|
||||||
|
config: config,
|
||||||
|
db: db,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *converter) AccountToMastoSensitive(a *gtsmodel.Account) (*mastotypes.Account, error) {
|
||||||
|
// we can build this sensitive account easily by first getting the public account....
|
||||||
|
mastoAccount, err := c.AccountToMastoPublic(a)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// then adding the Source object to it...
|
||||||
|
|
||||||
|
// check pending follow requests aimed at this account
|
||||||
|
fr := []gtsmodel.FollowRequest{}
|
||||||
|
if err := c.db.GetFollowRequestsForAccountID(a.ID, &fr); err != nil {
|
||||||
|
if _, ok := err.(db.ErrNoEntries); !ok {
|
||||||
|
return nil, fmt.Errorf("error getting follow requests: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var frc int
|
||||||
|
if fr != nil {
|
||||||
|
frc = len(fr)
|
||||||
|
}
|
||||||
|
|
||||||
|
mastoAccount.Source = &mastotypes.Source{
|
||||||
|
Privacy: util.ParseMastoVisFromGTSVis(a.Privacy),
|
||||||
|
Sensitive: a.Sensitive,
|
||||||
|
Language: a.Language,
|
||||||
|
Note: a.Note,
|
||||||
|
Fields: mastoAccount.Fields,
|
||||||
|
FollowRequestsCount: frc,
|
||||||
|
}
|
||||||
|
|
||||||
|
return mastoAccount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *converter) AccountToMastoPublic(a *gtsmodel.Account) (*mastotypes.Account, error) {
|
||||||
|
// count followers
|
||||||
|
followers := []gtsmodel.Follow{}
|
||||||
|
if err := c.db.GetFollowersByAccountID(a.ID, &followers); err != nil {
|
||||||
|
if _, ok := err.(db.ErrNoEntries); !ok {
|
||||||
|
return nil, fmt.Errorf("error getting followers: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var followersCount int
|
||||||
|
if followers != nil {
|
||||||
|
followersCount = len(followers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// count following
|
||||||
|
following := []gtsmodel.Follow{}
|
||||||
|
if err := c.db.GetFollowingByAccountID(a.ID, &following); err != nil {
|
||||||
|
if _, ok := err.(db.ErrNoEntries); !ok {
|
||||||
|
return nil, fmt.Errorf("error getting following: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var followingCount int
|
||||||
|
if following != nil {
|
||||||
|
followingCount = len(following)
|
||||||
|
}
|
||||||
|
|
||||||
|
// count statuses
|
||||||
|
statuses := []gtsmodel.Status{}
|
||||||
|
if err := c.db.GetStatusesByAccountID(a.ID, &statuses); err != nil {
|
||||||
|
if _, ok := err.(db.ErrNoEntries); !ok {
|
||||||
|
return nil, fmt.Errorf("error getting last statuses: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var statusesCount int
|
||||||
|
if statuses != nil {
|
||||||
|
statusesCount = len(statuses)
|
||||||
|
}
|
||||||
|
|
||||||
|
// check when the last status was
|
||||||
|
lastStatus := >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
|
||||||
|
}
|
|
@ -67,7 +67,7 @@ type Account struct {
|
||||||
// When a timed mute will expire, if applicable. (ISO 8601 Datetime)
|
// When a timed mute will expire, if applicable. (ISO 8601 Datetime)
|
||||||
MuteExpiresAt string `json:"mute_expires_at,omitempty"`
|
MuteExpiresAt string `json:"mute_expires_at,omitempty"`
|
||||||
// An extra entity to be used with API methods to verify credentials and update credentials.
|
// 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.
|
// AccountCreateRequest represents the form submitted during a POST request to /api/v1/accounts.
|
|
@ -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/)
|
// 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"`
|
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
|
// 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.
|
// ApplicationPOSTRequest represents a POST request to https://example.org/api/v1/apps.
|
|
@ -45,8 +45,10 @@ type Attachment struct {
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
// The location of a scaled-down preview of the attachment.
|
// The location of a scaled-down preview of the attachment.
|
||||||
PreviewURL string `json:"preview_url"`
|
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"`
|
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.
|
// A shorter URL for the attachment.
|
||||||
TextURL string `json:"text_url,omitempty"`
|
TextURL string `json:"text_url,omitempty"`
|
||||||
// Metadata returned by Paperclip.
|
// Metadata returned by Paperclip.
|
|
@ -18,6 +18,8 @@
|
||||||
|
|
||||||
package mastotypes
|
package mastotypes
|
||||||
|
|
||||||
|
import "mime/multipart"
|
||||||
|
|
||||||
// Emoji represents a custom emoji. See https://docs.joinmastodon.org/entities/emoji/
|
// Emoji represents a custom emoji. See https://docs.joinmastodon.org/entities/emoji/
|
||||||
type Emoji struct {
|
type Emoji struct {
|
||||||
// REQUIRED
|
// REQUIRED
|
||||||
|
@ -36,3 +38,11 @@ type Emoji struct {
|
||||||
// Used for sorting custom emoji in the picker.
|
// Used for sorting custom emoji in the picker.
|
||||||
Category string `json:"category,omitempty"`
|
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"`
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue