From e58229175d148592a3e0f385ac1f4f60e3ff1eb6 Mon Sep 17 00:00:00 2001 From: tsmethurst Date: Thu, 1 Apr 2021 23:05:31 +0200 Subject: [PATCH] start on status creation --- cmd/gotosocial/main.go | 32 ++++++ internal/apimodule/status/status.go | 95 ++++++++++++++++++ internal/apimodule/status/statuscreate.go | 117 ++++++++++++++++++++++ internal/config/config.go | 37 +++++++ internal/config/statuses.go | 33 ++++++ internal/db/model/status.go | 5 +- pkg/mastotypes/status.go | 46 ++++----- 7 files changed, 341 insertions(+), 24 deletions(-) create mode 100644 internal/apimodule/status/status.go create mode 100644 internal/apimodule/status/statuscreate.go create mode 100644 internal/config/statuses.go diff --git a/cmd/gotosocial/main.go b/cmd/gotosocial/main.go index 983d49d..091678b 100644 --- a/cmd/gotosocial/main.go +++ b/cmd/gotosocial/main.go @@ -175,6 +175,38 @@ func main() { Value: "/fileserver/media", EnvVars: []string{envNames.StorageServeBasePath}, }, + + // STATUSES FLAGS + &cli.IntFlag{ + Name: flagNames.StatusesMaxChars, + Usage: "Max permitted characters for posted statuses", + Value: 5000, + EnvVars: []string{envNames.StatusesMaxChars}, + }, + &cli.IntFlag{ + Name: flagNames.StatusesCWMaxChars, + Usage: "Max permitted characters for content/spoiler warnings on statuses", + Value: 100, + EnvVars: []string{envNames.StatusesCWMaxChars}, + }, + &cli.IntFlag{ + Name: flagNames.StatusesPollMaxOptions, + Usage: "Max amount of options permitted on a poll", + Value: 6, + EnvVars: []string{envNames.StatusesPollMaxOptions}, + }, + &cli.IntFlag{ + Name: flagNames.StatusesPollOptionMaxChars, + Usage: "Max amount of characters for a poll option", + Value: 50, + EnvVars: []string{envNames.StatusesPollOptionMaxChars}, + }, + &cli.IntFlag{ + Name: flagNames.StatusesMaxMediaFiles, + Usage: "Maximum number of media files/attachments per status", + Value: 6, + EnvVars: []string{envNames.StatusesMaxMediaFiles}, + }, }, Commands: []*cli.Command{ { diff --git a/internal/apimodule/status/status.go b/internal/apimodule/status/status.go new file mode 100644 index 0000000..cea0003 --- /dev/null +++ b/internal/apimodule/status/status.go @@ -0,0 +1,95 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package status + +import ( + "fmt" + + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/apimodule" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/db/model" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/router" +) + +const ( + idKey = "id" + basePath = "/api/v1/statuses" + basePathWithID = basePath + "/:" + idKey + contextPath = basePath + "/context" + rebloggedPath = basePath + "/reblogged_by" + favouritedPath = basePath + "/favourited_by" + favouritePath = basePath + "/favourite" + reblogPath = basePath + "/reblog" + unreblogPath = basePath + "/unreblog" + bookmarkPath = basePath + "/bookmark" + unbookmarkPath = basePath + "/unbookmark" + mutePath = basePath + "/mute" + unmutePath = basePath + "/unmute" + pinPath = basePath + "/pin" + unpinPath = basePath + "/unpin" +) + +type statusModule struct { + config *config.Config + db db.DB + oauthServer oauth.Server + mediaHandler media.MediaHandler + log *logrus.Logger +} + +// New returns a new account module +func New(config *config.Config, db db.DB, oauthServer oauth.Server, mediaHandler media.MediaHandler, log *logrus.Logger) apimodule.ClientAPIModule { + return &statusModule{ + config: config, + db: db, + mediaHandler: mediaHandler, + 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.accountCreatePOSTHandler) + // r.AttachHandler(http.MethodGet, basePathWithID, m.muxHandler) + return nil +} + +func (m *statusModule) CreateTables(db db.DB) error { + models := []interface{}{ + &model.User{}, + &model.Account{}, + &model.Follow{}, + &model.FollowRequest{}, + &model.Status{}, + &model.Application{}, + &model.EmailDomainBlock{}, + &model.MediaAttachment{}, + } + + for _, m := range models { + if err := db.CreateTable(m); err != nil { + return fmt.Errorf("error creating table: %s", err) + } + } + return nil +} diff --git a/internal/apimodule/status/statuscreate.go b/internal/apimodule/status/statuscreate.go new file mode 100644 index 0000000..587f4ed --- /dev/null +++ b/internal/apimodule/status/statuscreate.go @@ -0,0 +1,117 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package status + +import ( + "errors" + "fmt" + "net" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/util" + "github.com/superseriousbusiness/gotosocial/pkg/mastotypes" +) + +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 + } + + // check this user/account is permitted to post new statuses + 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 + } + + l.Trace("parsing request form") + form := &mastotypes.StatusCreateRequest{} + 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 + } + + l.Tracef("validating form %+v", form) + if err := validateCreateStatus(form, m.config.StatusesConfig, m.db); err != nil { + l.Debugf("error validating form: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + clientIP := c.ClientIP() + l.Tracef("attempting to parse client ip address %s", clientIP) + signUpIP := net.ParseIP(clientIP) + if signUpIP == nil { + l.Debugf("error validating client ip address %s", clientIP) + c.JSON(http.StatusBadRequest, gin.H{"error": "ip address could not be parsed from request"}) + return + } +} + +func validateCreateStatus(form *mastotypes.StatusCreateRequest, config *config.StatusesConfig, db db.DB) error { + + if form.Language != "" { + if err := util.ValidateLanguage(form.Language); err != nil { + return err + } + } + + 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") + } + + 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) + } + } + } + + 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) + } + + 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) + } + } + + return nil +} diff --git a/internal/config/config.go b/internal/config/config.go index 811cf16..59023d3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -36,6 +36,7 @@ type Config struct { AccountsConfig *AccountsConfig `yaml:"accounts"` MediaConfig *MediaConfig `yaml:"media"` StorageConfig *StorageConfig `yaml:"storage"` + StatusesConfig *StatusesConfig `yaml:"statuses"` } // FromFile returns a new config from a file, or an error if something goes amiss. @@ -58,6 +59,7 @@ func Empty() *Config { AccountsConfig: &AccountsConfig{}, MediaConfig: &MediaConfig{}, StorageConfig: &StorageConfig{}, + StatusesConfig: &StatusesConfig{}, } } @@ -173,6 +175,23 @@ func (c *Config) ParseCLIFlags(f KeyedFlags) { if c.StorageConfig.ServeBasePath == "" || f.IsSet(fn.StorageServeBasePath) { c.StorageConfig.ServeBasePath = f.String(fn.StorageServeBasePath) } + + // statuses flags + if c.StatusesConfig.MaxChars == 0 || f.IsSet(fn.StatusesMaxChars) { + c.StatusesConfig.MaxChars = f.Int(fn.StatusesMaxChars) + } + if c.StatusesConfig.CWMaxChars == 0 || f.IsSet(fn.StatusesCWMaxChars) { + c.StatusesConfig.CWMaxChars = f.Int(fn.StatusesCWMaxChars) + } + if c.StatusesConfig.PollMaxOptions == 0 || f.IsSet(fn.StatusesPollMaxOptions) { + c.StatusesConfig.PollMaxOptions = f.Int(fn.StatusesPollMaxOptions) + } + if c.StatusesConfig.PollOptionMaxChars == 0 || f.IsSet(fn.StatusesPollOptionMaxChars) { + c.StatusesConfig.PollOptionMaxChars = f.Int(fn.StatusesPollOptionMaxChars) + } + if c.StatusesConfig.MaxMediaFiles == 0 || f.IsSet(fn.StatusesMaxMediaFiles) { + c.StatusesConfig.MaxMediaFiles = f.Int(fn.StatusesMaxMediaFiles) + } } // KeyedFlags is a wrapper for any type that can store keyed flags and give them back. @@ -213,6 +232,12 @@ type Flags struct { StorageServeProtocol string StorageServeHost string StorageServeBasePath string + + StatusesMaxChars string + StatusesCWMaxChars string + StatusesPollMaxOptions string + StatusesPollOptionMaxChars string + StatusesMaxMediaFiles string } // GetFlagNames returns a struct containing the names of the various flags used for @@ -245,6 +270,12 @@ func GetFlagNames() Flags { StorageServeProtocol: "storage-serve-protocol", StorageServeHost: "storage-serve-host", StorageServeBasePath: "storage-serve-base-path", + + StatusesMaxChars: "statuses-max-chars", + StatusesCWMaxChars: "statuses-cw-max-chars", + StatusesPollMaxOptions: "statuses-poll-max-options", + StatusesPollOptionMaxChars: "statuses-poll-option-max-chars", + StatusesMaxMediaFiles: "statuses-max-media-files", } } @@ -278,5 +309,11 @@ func GetEnvNames() Flags { StorageServeProtocol: "GTS_STORAGE_SERVE_PROTOCOL", StorageServeHost: "GTS_STORAGE_SERVE_HOST", StorageServeBasePath: "GTS_STORAGE_SERVE_BASE_PATH", + + StatusesMaxChars: "GTS_STATUSES_MAX_CHARS", + StatusesCWMaxChars: "GTS_STATUSES_CW_MAX_CHARS", + StatusesPollMaxOptions: "GTS_STATUSES_POLL_MAX_OPTIONS", + StatusesPollOptionMaxChars: "GTS_STATUSES_POLL_OPTION_MAX_CHARS", + StatusesMaxMediaFiles: "GTS_STATUSES_MAX_MEDIA_FILES", } } diff --git a/internal/config/statuses.go b/internal/config/statuses.go new file mode 100644 index 0000000..bdc50d5 --- /dev/null +++ b/internal/config/statuses.go @@ -0,0 +1,33 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package config + +// StatusesConfig pertains to posting/deleting/interacting with statuses +type StatusesConfig struct { + // Maximum amount of characters allowed in a status, excluding CW + MaxChars int `yaml:"max_chars"` + // Maximum amount of characters allowed in a content-warning/spoiler field + CWMaxChars int `yaml:"cw_max_chars"` + // Maximum number of options allowed in a poll + PollMaxOptions int `yaml:"poll_max_options"` + // Maximum characters allowed per poll option + PollOptionMaxChars int `yaml:"poll_option_max_chars"` + // Maximum amount of media files allowed to be attached to one status + MaxMediaFiles int `yaml:"max_media_files"` +} diff --git a/internal/db/model/status.go b/internal/db/model/status.go index d152587..d51ae28 100644 --- a/internal/db/model/status.go +++ b/internal/db/model/status.go @@ -45,7 +45,10 @@ type Status struct { // cw string for this status ContentWarning string // visibility entry for this status - Visibility *Visibility + Visibility *Visibility + // What is the activitystreams type of this status? See: https://www.w3.org/TR/activitystreams-vocabulary/#object-types + // Will probably almost always be a note. + ActivityStreamsType string } // Visibility represents the visibility granularity of a status. It is a combination of flags. diff --git a/pkg/mastotypes/status.go b/pkg/mastotypes/status.go index e98504e..d88e7df 100644 --- a/pkg/mastotypes/status.go +++ b/pkg/mastotypes/status.go @@ -18,29 +18,6 @@ package mastotypes -// StatusRequest represents a mastodon-api status POST request, as defined here: https://docs.joinmastodon.org/methods/statuses/ -// It should be used at the path https://mastodon.example/api/v1/statuses -type StatusRequest struct { - // Text content of the status. If media_ids is provided, this becomes optional. Attaching a poll is optional while status is provided. - Status string `form:"status"` - // Array of Attachment ids to be attached as media. If provided, status becomes optional, and poll cannot be used. - MediaIDs []string `form:"media_ids"` - // Poll to include with this status. - Poll *PollRequest `form:"poll"` - // ID of the status being replied to, if status is a reply - InReplyToID string `form:"in_reply_to_id"` - // Mark status and attached media as sensitive? - Sensitive bool `form:"sensitive"` - // Text to be shown as a warning or subject before the actual content. Statuses are generally collapsed behind this field. - SpoilerText string `form:"spoiler_text"` - // Visibility of the posted status. Enumerable oneOf public, unlisted, private, direct. - Visibility string `form:"visibility"` - // ISO 8601 Datetime at which to schedule a status. Providing this paramter will cause ScheduledStatus to be returned instead of Status. Must be at least 5 minutes in the future. - ScheduledAt string `form:"scheduled_at"` - // ISO 639 language code for this status. - Language string `form:"language"` -} - // Status represents a mastodon-api Status type, as defined here: https://docs.joinmastodon.org/entities/status/ type Status struct { // ID of the status in the database. @@ -108,3 +85,26 @@ type Status struct { // the original text from the HTML content. Text string `json:"text"` } + +// StatusCreateRequest represents a mastodon-api status POST request, as defined here: https://docs.joinmastodon.org/methods/statuses/ +// It should be used at the path https://mastodon.example/api/v1/statuses +type StatusCreateRequest struct { + // Text content of the status. If media_ids is provided, this becomes optional. Attaching a poll is optional while status is provided. + Status string `form:"status"` + // Array of Attachment ids to be attached as media. If provided, status becomes optional, and poll cannot be used. + MediaIDs []string `form:"media_ids"` + // Poll to include with this status. + Poll *PollRequest `form:"poll"` + // ID of the status being replied to, if status is a reply + InReplyToID string `form:"in_reply_to_id"` + // Mark status and attached media as sensitive? + Sensitive bool `form:"sensitive"` + // Text to be shown as a warning or subject before the actual content. Statuses are generally collapsed behind this field. + SpoilerText string `form:"spoiler_text"` + // Visibility of the posted status. Enumerable oneOf public, unlisted, private, direct. + Visibility string `form:"visibility"` + // ISO 8601 Datetime at which to schedule a status. Providing this paramter will cause ScheduledStatus to be returned instead of Status. Must be at least 5 minutes in the future. + ScheduledAt string `form:"scheduled_at"` + // ISO 639 language code for this status. + Language string `form:"language"` +}