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