diff --git a/internal/api/client/statuses/status.go b/internal/api/client/statuses/status.go
index 33af9c456..88b34cbf5 100644
--- a/internal/api/client/statuses/status.go
+++ b/internal/api/client/statuses/status.go
@@ -83,9 +83,10 @@ func New(processor *processing.Processor) *Module {
}
func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) {
- // create / get / delete status
+ // create / get / edit / delete status
attachHandler(http.MethodPost, BasePath, m.StatusCreatePOSTHandler)
attachHandler(http.MethodGet, BasePathWithID, m.StatusGETHandler)
+ attachHandler(http.MethodPut, BasePathWithID, m.StatusEditPUTHandler)
attachHandler(http.MethodDelete, BasePathWithID, m.StatusDELETEHandler)
// fave stuff
diff --git a/internal/api/client/statuses/statuscreate.go b/internal/api/client/statuses/statuscreate.go
index 8198d5358..c83cdbad7 100644
--- a/internal/api/client/statuses/statuscreate.go
+++ b/internal/api/client/statuses/statuscreate.go
@@ -27,11 +27,9 @@ import (
"github.com/go-playground/form/v4"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
- "github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/internal/util"
- "github.com/superseriousbusiness/gotosocial/internal/validate"
)
// StatusCreatePOSTHandler swagger:operation POST /api/v1/statuses statusCreate
@@ -272,9 +270,9 @@ func (m *Module) StatusCreatePOSTHandler(c *gin.Context) {
return
}
- form, err := parseStatusCreateForm(c)
- if err != nil {
- apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
+ form, errWithCode := parseStatusCreateForm(c)
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
@@ -287,11 +285,6 @@ func (m *Module) StatusCreatePOSTHandler(c *gin.Context) {
// }
// form.Status += "\n\nsent from " + user + "'s iphone\n"
- if errWithCode := validateStatusCreateForm(form); errWithCode != nil {
- apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
- return
- }
-
apiStatus, errWithCode := m.processor.Status().Create(
c.Request.Context(),
authed.Account,
@@ -303,7 +296,7 @@ func (m *Module) StatusCreatePOSTHandler(c *gin.Context) {
return
}
- c.JSON(http.StatusOK, apiStatus)
+ apiutil.JSON(c, http.StatusOK, apiStatus)
}
// intPolicyFormBinding satisfies gin's binding.Binding interface.
@@ -328,108 +321,69 @@ func (intPolicyFormBinding) Bind(req *http.Request, obj any) error {
return decoder.Decode(obj, req.Form)
}
-func parseStatusCreateForm(c *gin.Context) (*apimodel.StatusCreateRequest, error) {
+func parseStatusCreateForm(c *gin.Context) (*apimodel.StatusCreateRequest, gtserror.WithCode) {
form := new(apimodel.StatusCreateRequest)
switch ct := c.ContentType(); ct {
case binding.MIMEJSON:
// Just bind with default json binding.
if err := c.ShouldBindWith(form, binding.JSON); err != nil {
- return nil, err
+ return nil, gtserror.NewErrorBadRequest(
+ err,
+ err.Error(),
+ )
}
case binding.MIMEPOSTForm:
// Bind with default form binding first.
if err := c.ShouldBindWith(form, binding.FormPost); err != nil {
- return nil, err
+ return nil, gtserror.NewErrorBadRequest(
+ err,
+ err.Error(),
+ )
}
// Now do custom binding.
intReqForm := new(apimodel.StatusInteractionPolicyForm)
if err := c.ShouldBindWith(intReqForm, intPolicyFormBinding{}); err != nil {
- return nil, err
+ return nil, gtserror.NewErrorBadRequest(
+ err,
+ err.Error(),
+ )
}
+
form.InteractionPolicy = intReqForm.InteractionPolicy
case binding.MIMEMultipartPOSTForm:
// Bind with default form binding first.
if err := c.ShouldBindWith(form, binding.FormMultipart); err != nil {
- return nil, err
+ return nil, gtserror.NewErrorBadRequest(
+ err,
+ err.Error(),
+ )
}
// Now do custom binding.
intReqForm := new(apimodel.StatusInteractionPolicyForm)
if err := c.ShouldBindWith(intReqForm, intPolicyFormBinding{}); err != nil {
- return nil, err
+ return nil, gtserror.NewErrorBadRequest(
+ err,
+ err.Error(),
+ )
}
+
form.InteractionPolicy = intReqForm.InteractionPolicy
default:
- err := fmt.Errorf(
- "content-type %s not supported for this endpoint; supported content-types are %s, %s, %s",
- ct, binding.MIMEJSON, binding.MIMEPOSTForm, binding.MIMEMultipartPOSTForm,
- )
- return nil, err
- }
-
- return form, nil
-}
-
-// validateStatusCreateForm checks the form for disallowed
-// combinations of attachments, overlength inputs, etc.
-//
-// Side effect: normalizes the post's language tag.
-func validateStatusCreateForm(form *apimodel.StatusCreateRequest) gtserror.WithCode {
- var (
- chars = len([]rune(form.Status)) + len([]rune(form.SpoilerText))
- maxChars = config.GetStatusesMaxChars()
- mediaFiles = len(form.MediaIDs)
- maxMediaFiles = config.GetStatusesMediaMaxFiles()
- hasMedia = mediaFiles != 0
- hasPoll = form.Poll != nil
- )
-
- if chars == 0 && !hasMedia && !hasPoll {
- // Status must contain *some* kind of content.
- const text = "no status content, content warning, media, or poll provided"
- return gtserror.NewErrorBadRequest(errors.New(text), text)
- }
-
- if chars > maxChars {
- text := fmt.Sprintf(
- "status too long, %d characters provided (including content warning) but limit is %d",
- chars, maxChars,
- )
- return gtserror.NewErrorBadRequest(errors.New(text), text)
- }
-
- if mediaFiles > maxMediaFiles {
- text := fmt.Sprintf(
- "too many media files attached to status, %d attached but limit is %d",
- mediaFiles, maxMediaFiles,
- )
- return gtserror.NewErrorBadRequest(errors.New(text), text)
- }
-
- if form.Poll != nil {
- if errWithCode := validateStatusPoll(form); errWithCode != nil {
- return errWithCode
- }
+ text := fmt.Sprintf("content-type %s not supported for this endpoint; supported content-types are %s, %s, %s",
+ ct, binding.MIMEJSON, binding.MIMEPOSTForm, binding.MIMEMultipartPOSTForm)
+ return nil, gtserror.NewErrorNotAcceptable(errors.New(text), text)
}
+ // Check not scheduled status.
if form.ScheduledAt != "" {
const text = "scheduled_at is not yet implemented"
- return gtserror.NewErrorNotImplemented(errors.New(text), text)
- }
-
- // Validate + normalize
- // language tag if provided.
- if form.Language != "" {
- lang, err := validate.Language(form.Language)
- if err != nil {
- return gtserror.NewErrorBadRequest(err, err.Error())
- }
- form.Language = lang
+ return nil, gtserror.NewErrorNotImplemented(errors.New(text), text)
}
// Check if the deprecated "federated" field was
@@ -438,42 +392,9 @@ func validateStatusCreateForm(form *apimodel.StatusCreateRequest) gtserror.WithC
form.LocalOnly = util.Ptr(!*form.Federated) // nolint:staticcheck
}
- return nil
-}
+ // Normalize poll expiry time if a poll was given.
+ if form.Poll != nil && form.Poll.ExpiresInI != nil {
-func validateStatusPoll(form *apimodel.StatusCreateRequest) gtserror.WithCode {
- var (
- maxPollOptions = config.GetStatusesPollMaxOptions()
- pollOptions = len(form.Poll.Options)
- maxPollOptionChars = config.GetStatusesPollOptionMaxChars()
- )
-
- if pollOptions == 0 {
- const text = "poll with no options"
- return gtserror.NewErrorBadRequest(errors.New(text), text)
- }
-
- if pollOptions > maxPollOptions {
- text := fmt.Sprintf(
- "too many poll options provided, %d provided but limit is %d",
- pollOptions, maxPollOptions,
- )
- return gtserror.NewErrorBadRequest(errors.New(text), text)
- }
-
- for _, option := range form.Poll.Options {
- optionChars := len([]rune(option))
- if optionChars > maxPollOptionChars {
- text := fmt.Sprintf(
- "poll option too long, %d characters provided but limit is %d",
- optionChars, maxPollOptionChars,
- )
- return gtserror.NewErrorBadRequest(errors.New(text), text)
- }
- }
-
- // Normalize poll expiry if necessary.
- if form.Poll.ExpiresInI != nil {
// If we parsed this as JSON, expires_in
// may be either a float64 or a string.
expiresIn, err := apiutil.ParseDuration(
@@ -481,13 +402,10 @@ func validateStatusPoll(form *apimodel.StatusCreateRequest) gtserror.WithCode {
"expires_in",
)
if err != nil {
- return gtserror.NewErrorBadRequest(err, err.Error())
- }
-
- if expiresIn != nil {
- form.Poll.ExpiresIn = *expiresIn
+ return nil, gtserror.NewErrorBadRequest(err, err.Error())
}
+ form.Poll.ExpiresIn = util.PtrOrZero(expiresIn)
}
- return nil
+ return form, nil
}
diff --git a/internal/api/client/statuses/statusdelete.go b/internal/api/client/statuses/statusdelete.go
index 7ee240dff..fa62d6893 100644
--- a/internal/api/client/statuses/statusdelete.go
+++ b/internal/api/client/statuses/statusdelete.go
@@ -95,5 +95,5 @@ func (m *Module) StatusDELETEHandler(c *gin.Context) {
return
}
- c.JSON(http.StatusOK, apiStatus)
+ apiutil.JSON(c, http.StatusOK, apiStatus)
}
diff --git a/internal/api/client/statuses/statusedit.go b/internal/api/client/statuses/statusedit.go
new file mode 100644
index 000000000..dfd7d651e
--- /dev/null
+++ b/internal/api/client/statuses/statusedit.go
@@ -0,0 +1,249 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// 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
Hey this is a status!
Content string `json:"content"` + // Subject, summary, or content warning for the status at this revision. // example: warning nsfw SpoilerText string `json:"spoiler_text"` + // Status marked sensitive at this revision. // example: false Sensitive bool `json:"sensitive"` + // The date when this revision was created (ISO 8601 Datetime). // example: 2021-07-30T09:20:25+00:00 CreatedAt string `json:"created_at"` + // The account that authored this status. Account *Account `json:"account"` + // The poll attached to the status at this revision. // Note that edits changing the poll options will be collapsed together into one edit, since this action resets the poll. // nullable: true Poll *Poll `json:"poll"` + // Media that is attached to this status. MediaAttachments []*Attachment `json:"media_attachments"` + // Custom emoji to be used when rendering status content. Emojis []Emoji `json:"emojis"` } + +// StatusEditRequest models status edit parameters. +// +// swagger:ignore +type StatusEditRequest 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" json:"status"` + + // 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" json:"spoiler_text"` + + // Content type to use when parsing this status. + ContentType StatusContentType `form:"content_type" json:"content_type"` + + // Status and attached media should be marked as sensitive. + Sensitive bool `form:"sensitive" json:"sensitive"` + + // ISO 639 language code for this status. + Language string `form:"language" json:"language"` + + // Array of Attachment ids to be attached as media. + // If provided, status becomes optional, and poll cannot be used. + MediaIDs []string `form:"media_ids[]" json:"media_ids"` + + // ... + MediaAttributes []AttachmentAttributesRequest `form:"media_attributes[]" json:"media_attributes"` + + // Poll to include with this status. + Poll *PollRequest `form:"poll" json:"poll"` +} diff --git a/internal/federation/dereferencing/status.go b/internal/federation/dereferencing/status.go index d19669891..c20936692 100644 --- a/internal/federation/dereferencing/status.go +++ b/internal/federation/dereferencing/status.go @@ -35,6 +35,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/util" + "github.com/superseriousbusiness/gotosocial/internal/util/xslices" ) // statusFresh returns true if the given status is still @@ -1000,12 +1001,21 @@ func (d *Dereferencer) fetchStatusEmojis( // Set latest emojis. status.Emojis = emojis - // Iterate over and set changed emoji IDs. + // Extract IDs from latest slice of emojis. status.EmojiIDs = make([]string, len(emojis)) for i, emoji := range emojis { status.EmojiIDs[i] = emoji.ID } + // Combine both old and new emojis, as statuses.emojis + // keeps track of emojis for both old and current edits. + status.EmojiIDs = append(status.EmojiIDs, existing.EmojiIDs...) + status.Emojis = append(status.Emojis, existing.Emojis...) + status.EmojiIDs = xslices.Deduplicate(status.EmojiIDs) + status.Emojis = xslices.DeduplicateFunc(status.Emojis, + func(e *gtsmodel.Emoji) string { return e.ID }, + ) + return true, nil } @@ -1118,10 +1128,10 @@ func (d *Dereferencer) handleStatusEdit( var edited bool // Preallocate max slice length. - cols = make([]string, 0, 13) + cols = make([]string, 1, 13) // Always update `fetched_at`. - cols = append(cols, "fetched_at") + cols[0] = "fetched_at" // Check for edited status content. if existing.Content != status.Content { @@ -1230,7 +1240,8 @@ func (d *Dereferencer) handleStatusEdit( // Poll only set if existing contained them. edit.PollOptions = existing.Poll.Options - if !*existing.Poll.HideCounts || pollChanged { + if pollChanged || !*existing.Poll.HideCounts || + !existing.Poll.ClosedAt.IsZero() { // If the counts are allowed to be // shown, or poll has changed, then // include poll vote counts in edit. diff --git a/internal/processing/admin/rule.go b/internal/processing/admin/rule.go index d1ee63cc8..8134c21cd 100644 --- a/internal/processing/admin/rule.go +++ b/internal/processing/admin/rule.go @@ -27,6 +27,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/id" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" "github.com/superseriousbusiness/gotosocial/internal/util" ) @@ -42,7 +43,7 @@ func (p *Processor) RulesGet( apiRules := make([]*apimodel.AdminInstanceRule, len(rules)) for i := range rules { - apiRules[i] = p.converter.InstanceRuleToAdminAPIRule(&rules[i]) + apiRules[i] = typeutils.InstanceRuleToAdminAPIRule(&rules[i]) } return apiRules, nil @@ -58,7 +59,7 @@ func (p *Processor) RuleGet(ctx context.Context, id string) (*apimodel.AdminInst return nil, gtserror.NewErrorInternalError(err) } - return p.converter.InstanceRuleToAdminAPIRule(rule), nil + return typeutils.InstanceRuleToAdminAPIRule(rule), nil } // RuleCreate adds a new rule to the instance. @@ -77,7 +78,7 @@ func (p *Processor) RuleCreate(ctx context.Context, form *apimodel.InstanceRuleC return nil, gtserror.NewErrorInternalError(err) } - return p.converter.InstanceRuleToAdminAPIRule(rule), nil + return typeutils.InstanceRuleToAdminAPIRule(rule), nil } // RuleUpdate updates text for an existing rule. @@ -99,7 +100,7 @@ func (p *Processor) RuleUpdate(ctx context.Context, id string, form *apimodel.In return nil, gtserror.NewErrorInternalError(err) } - return p.converter.InstanceRuleToAdminAPIRule(updatedRule), nil + return typeutils.InstanceRuleToAdminAPIRule(updatedRule), nil } // RuleDelete deletes an existing rule. @@ -120,5 +121,5 @@ func (p *Processor) RuleDelete(ctx context.Context, id string) (*apimodel.AdminI return nil, gtserror.NewErrorInternalError(err) } - return p.converter.InstanceRuleToAdminAPIRule(deletedRule), nil + return typeutils.InstanceRuleToAdminAPIRule(deletedRule), nil } diff --git a/internal/processing/common/status.go b/internal/processing/common/status.go index da5cf1290..377f54c5d 100644 --- a/internal/processing/common/status.go +++ b/internal/processing/common/status.go @@ -31,6 +31,47 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/log" ) +// GetOwnStatus ... +func (p *Processor) GetOwnStatus( + ctx context.Context, + requester *gtsmodel.Account, + targetID string, +) ( + *gtsmodel.Status, + gtserror.WithCode, +) { + target, err := p.state.DB.GetStatusByID(ctx, targetID) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("error getting from db: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + if target == nil { + const text = "target status not found" + return nil, gtserror.NewErrorNotFound( + errors.New(text), + text, + ) + } + + switch { + case target == nil: + const text = "target status not found" + return nil, gtserror.NewErrorNotFound( + errors.New(text), + text, + ) + + case target.AccountID != requester.ID: + return nil, gtserror.NewErrorNotFound( + errors.New("status does not belong to requester"), + "target status not found", + ) + } + + return target, nil +} + // GetTargetStatusBy fetches the target status with db load // function, given the authorized (or, nil) requester's // account. This returns an approprate gtserror.WithCode diff --git a/internal/processing/instance.go b/internal/processing/instance.go index fab71b1de..2f4c40416 100644 --- a/internal/processing/instance.go +++ b/internal/processing/instance.go @@ -29,6 +29,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/text" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" "github.com/superseriousbusiness/gotosocial/internal/util" "github.com/superseriousbusiness/gotosocial/internal/validate" ) @@ -133,7 +134,7 @@ func (p *Processor) InstanceGetRules(ctx context.Context) ([]apimodel.InstanceRu return nil, gtserror.NewErrorInternalError(fmt.Errorf("db error fetching instance: %s", err)) } - return p.converter.InstanceRulesToAPIRules(i.Rules), nil + return typeutils.InstanceRulesToAPIRules(i.Rules), nil } func (p *Processor) InstancePatch(ctx context.Context, form *apimodel.InstanceSettingsUpdateRequest) (*apimodel.InstanceV1, gtserror.WithCode) { diff --git a/internal/processing/status/common.go b/internal/processing/status/common.go new file mode 100644 index 000000000..63faac5bc --- /dev/null +++ b/internal/processing/status/common.go @@ -0,0 +1,305 @@ +package status + +import ( + "context" + "errors" + "fmt" + "time" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" + "github.com/superseriousbusiness/gotosocial/internal/text" + "github.com/superseriousbusiness/gotosocial/internal/util/xslices" + "github.com/superseriousbusiness/gotosocial/internal/validate" +) + +func validateStatusContent( + status string, + spoiler string, + mediaIDs []string, + poll *apimodel.PollRequest, +) gtserror.WithCode { + totalChars := len([]rune(status)) + + len([]rune(spoiler)) + + if totalChars == 0 && len(mediaIDs) == 0 && poll == nil { + const text = "status contains no text, media or poll" + return gtserror.NewErrorBadRequest(errors.New(text), text) + } + + if max := config.GetStatusesMaxChars(); totalChars > max { + text := fmt.Sprintf("text with spoiler exceed max chars (%d)", max) + return gtserror.NewErrorBadRequest(errors.New(text), text) + } + + if max := config.GetStatusesMediaMaxFiles(); len(mediaIDs) > max { + text := fmt.Sprintf("media files exceed max count (%d)", max) + return gtserror.NewErrorBadRequest(errors.New(text), text) + } + + if poll != nil { + switch max := config.GetStatusesPollMaxOptions(); { + case len(poll.Options) == 0: + const text = "poll cannot have no options" + return gtserror.NewErrorBadRequest(errors.New(text), text) + + case len(poll.Options) > max: + text := fmt.Sprintf("poll options exceed max count (%d)", max) + return gtserror.NewErrorBadRequest(errors.New(text), text) + } + + max := config.GetStatusesPollOptionMaxChars() + for i, option := range poll.Options { + switch l := len([]rune(option)); { + case l == 0: + const text = "poll option cannot be empty" + return gtserror.NewErrorBadRequest(errors.New(text), text) + + case l > max: + text := fmt.Sprintf("poll option %d exceed max chars (%d)", i, max) + return gtserror.NewErrorBadRequest(errors.New(text), text) + } + } + } + + return nil +} + +type statusContent struct { + Content string + ContentWarning string + PollOptions []string + Language string + MentionIDs []string + Mentions []*gtsmodel.Mention + EmojiIDs []string + Emojis []*gtsmodel.Emoji + TagIDs []string + Tags []*gtsmodel.Tag +} + +func (p *Processor) processContent( + ctx context.Context, + author *gtsmodel.Account, + statusID string, + contentType string, + content string, + contentWarning string, + language string, + poll *apimodel.PollRequest, +) ( + *statusContent, + gtserror.WithCode, +) { + if language == "" { + // Ensure we have a status language. + language = author.Settings.Language + if language == "" { + const text = "account default language unset" + return nil, gtserror.NewErrorInternalError( + errors.New(text), + ) + } + } + + var err error + + // Validate + normalize determined language. + language, err = validate.Language(language) + if err != nil { + text := fmt.Sprintf("invalid language tag: %v", err) + return nil, gtserror.NewErrorBadRequest( + errors.New(text), + text, + ) + } + + // format is the currently set text formatting + // function, according to the provided content-type. + var format text.FormatFunc + + if contentType == "" { + // If content type wasn't specified, use + // the author's preferred content-type. + contentType = author.Settings.StatusContentType + } + + switch contentType { + + // Format status according to text/plain. + case "", string(apimodel.StatusContentTypePlain): + format = p.formatter.FromPlain + + // Format status according to text/markdown. + case string(apimodel.StatusContentTypeMarkdown): + format = p.formatter.FromMarkdown + + // Unknown. + default: + const text = "invalid status format" + return nil, gtserror.NewErrorBadRequest( + errors.New(text), + text, + ) + } + + // Allocate a structure to hold the + // majority of formatted content without + // needing to alloc a whole gtsmodel.Status{}. + var status statusContent + status.Language = language + + // formatInput is a shorthand function to format the given input string with the + // currently set 'formatFunc', passing in all required args and returning result. + formatInput := func(formatFunc text.FormatFunc, input string) *text.FormatResult { + return formatFunc(ctx, p.parseMention, author.ID, statusID, input) + } + + // Sanitize input status text and format. + contentRes := formatInput(format, content) + + // Gather results of formatted. + status.Content = contentRes.HTML + status.Mentions = contentRes.Mentions + status.Emojis = contentRes.Emojis + status.Tags = contentRes.Tags + + // From here-on-out just use emoji-only + // plain-text formatting as the FormatFunc. + format = p.formatter.FromPlainEmojiOnly + + // Sanitize content warning and format. + warning := text.SanitizeToPlaintext(contentWarning) + warningRes := formatInput(format, warning) + + // Gather results of the formatted. + status.ContentWarning = warningRes.HTML + status.Emojis = append(status.Emojis, warningRes.Emojis...) + + if poll != nil { + // Pre-allocate slice of poll options of expected length. + status.PollOptions = make([]string, len(poll.Options)) + for i, option := range poll.Options { + + // Sanitize each poll option and format. + option = text.SanitizeToPlaintext(option) + optionRes := formatInput(format, option) + + // Gather results of the formatted. + status.PollOptions[i] = optionRes.HTML + status.Emojis = append(status.Emojis, optionRes.Emojis...) + } + + // Also update options on the form. + poll.Options = status.PollOptions + } + + // Gather up the IDs of mentions from parsed content. + status.MentionIDs = xslices.Gather(nil, status.Mentions, + func(m *gtsmodel.Mention) string { + return m.ID + }, + ) + + // Gather up the IDs of tags from parsed content. + status.TagIDs = xslices.Gather(nil, status.Tags, + func(t *gtsmodel.Tag) string { + return t.ID + }, + ) + + // Gather up the IDs of emojis in updated content. + status.EmojiIDs = xslices.Gather(nil, status.Emojis, + func(e *gtsmodel.Emoji) string { + return e.ID + }, + ) + + return &status, nil +} + +func (p *Processor) processMedia( + ctx context.Context, + authorID string, + statusID string, + mediaIDs []string, +) ( + []*gtsmodel.MediaAttachment, + gtserror.WithCode, +) { + // No media provided! + if len(mediaIDs) == 0 { + return nil, nil + } + + // Pre-allocate slice of media attachments of expected length. + attachments := make([]*gtsmodel.MediaAttachment, len(mediaIDs)) + for i, id := range mediaIDs { + + // Look for media attachment by ID in database. + media, err := p.state.DB.GetAttachmentByID(ctx, id) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("error getting media from db: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + // Check media exists and is owned by author + // (this masks finding out media ownership info). + if media == nil || media.AccountID != authorID { + text := fmt.Sprintf("media not found: %s", id) + return nil, gtserror.NewErrorBadRequest(errors.New(text), text) + } + + // Check media isn't already attached to another status. + if (media.StatusID != "" && media.StatusID != statusID) || + (media.ScheduledStatusID != "" && media.ScheduledStatusID != statusID) { + text := fmt.Sprintf("media already attached to status: %s", id) + return nil, gtserror.NewErrorBadRequest(errors.New(text), text) + } + + // Set media at index. + attachments[i] = media + } + + return attachments, nil +} + +func (p *Processor) processPoll( + ctx context.Context, + statusID string, + form *apimodel.PollRequest, + now time.Time, // used for expiry time +) ( + *gtsmodel.Poll, + gtserror.WithCode, +) { + var expiresAt time.Time + + // Set an expiry time if one given. + if in := form.ExpiresIn; in > 0 { + expiresIn := time.Duration(in) + expiresAt = now.Add(expiresIn * time.Second) + } + + // Create new poll model. + poll := >smodel.Poll{ + ID: id.NewULIDFromTime(now), + Multiple: &form.Multiple, + HideCounts: &form.HideTotals, + Options: form.Options, + StatusID: statusID, + ExpiresAt: expiresAt, + } + + // Insert the newly created poll model in the database. + if err := p.state.DB.PutPoll(ctx, poll); err != nil { + err := gtserror.Newf("error inserting poll in db: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return poll, nil +} diff --git a/internal/processing/status/create.go b/internal/processing/status/create.go index 340cf9ff3..b61f952b7 100644 --- a/internal/processing/status/create.go +++ b/internal/processing/status/create.go @@ -19,29 +19,22 @@ package status import ( "context" - "errors" - "fmt" "time" "github.com/superseriousbusiness/gotosocial/internal/ap" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/messages" - "github.com/superseriousbusiness/gotosocial/internal/text" "github.com/superseriousbusiness/gotosocial/internal/typeutils" "github.com/superseriousbusiness/gotosocial/internal/uris" "github.com/superseriousbusiness/gotosocial/internal/util" - "github.com/superseriousbusiness/gotosocial/internal/util/xslices" ) // Create processes the given form to create a new status, returning the api model representation of that status if it's OK. -// -// Precondition: the form's fields should have already been validated and normalized by the caller. +// Note this also handles validation of incoming form field data. func (p *Processor) Create( ctx context.Context, requester *gtsmodel.Account, @@ -51,6 +44,16 @@ func (p *Processor) Create( *apimodel.Status, gtserror.WithCode, ) { + // Validate incoming form status content. + if errWithCode := validateStatusContent( + form.Status, + form.SpoilerText, + form.MediaIDs, + form.Poll, + ); errWithCode != nil { + return nil, errWithCode + } + // Ensure account populated; we'll need settings. if err := p.state.DB.PopulateAccount(ctx, requester); err != nil { log.Errorf(ctx, "error(s) populating account, will continue: %s", err) @@ -59,6 +62,30 @@ func (p *Processor) Create( // Generate new ID for status. statusID := id.NewULID() + // Process incoming status content fields. + content, errWithCode := p.processContent(ctx, + requester, + statusID, + string(form.ContentType), + form.Status, + form.SpoilerText, + form.Language, + form.Poll, + ) + if errWithCode != nil { + return nil, errWithCode + } + + // Process incoming status attachments. + media, errWithCode := p.processMedia(ctx, + requester.ID, + statusID, + form.MediaIDs, + ) + if errWithCode != nil { + return nil, errWithCode + } + // Generate necessary URIs for username, to build status URIs. accountURIs := uris.GenerateURIsForAccount(requester.Username) @@ -78,16 +105,36 @@ func (p *Processor) Create( ActivityStreamsType: ap.ObjectNote, Sensitive: &form.Sensitive, CreatedWithApplicationID: application.ID, - Text: form.Status, + + // Set validated language. + Language: content.Language, + + // Set formatted status content. + Content: content.Content, + ContentWarning: content.ContentWarning, + Text: form.Status, // raw + + // Set gathered mentions. + MentionIDs: content.MentionIDs, + Mentions: content.Mentions, + + // Set gathered emojis. + EmojiIDs: content.EmojiIDs, + Emojis: content.Emojis, + + // Set gathered tags. + TagIDs: content.TagIDs, + Tags: content.Tags, + + // Set gathered media. + AttachmentIDs: form.MediaIDs, + Attachments: media, // Assume not pending approval; this may // change when permissivity is checked. PendingApproval: util.Ptr(false), } - // Process any attached poll. - p.processPoll(status, form.Poll) - // Check + attach in-reply-to status. if errWithCode := p.processInReplyTo(ctx, requester, @@ -101,10 +148,6 @@ func (p *Processor) Create( return nil, errWithCode } - if errWithCode := p.processMediaIDs(ctx, form, requester.ID, status); errWithCode != nil { - return nil, errWithCode - } - if err := p.processVisibility(ctx, form, requester.Settings.Privacy, status); err != nil { return nil, gtserror.NewErrorInternalError(err) } @@ -115,36 +158,49 @@ func (p *Processor) Create( return nil, errWithCode } - if err := processLanguage(form, requester.Settings.Language, status); err != nil { - return nil, gtserror.NewErrorInternalError(err) + if status.ContentWarning != "" && len(status.AttachmentIDs) > 0 { + // If a content-warning is set, and + // the status contains media, always + // set the status sensitive flag. + status.Sensitive = util.Ptr(true) } - if err := p.processContent(ctx, p.parseMention, form, status); err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - - if status.Poll != nil { - // Try to insert the new status poll in the database. - if err := p.state.DB.PutPoll(ctx, status.Poll); err != nil { - err := gtserror.Newf("error inserting poll in db: %w", err) - return nil, gtserror.NewErrorInternalError(err) + if form.Poll != nil { + // Process poll, inserting into database. + poll, errWithCode := p.processPoll(ctx, + statusID, + form.Poll, + now, + ) + if errWithCode != nil { + return nil, errWithCode } + + // Set poll and its ID + // on status before insert. + status.PollID = poll.ID + status.Poll = poll + poll.Status = status + + // Update the status' ActivityPub type to Question. + status.ActivityStreamsType = ap.ActivityQuestion } - // Insert this new status in the database. + // Insert this newly prepared status into the database. if err := p.state.DB.PutStatus(ctx, status); err != nil { + err := gtserror.Newf("error inserting status in db: %w", err) return nil, gtserror.NewErrorInternalError(err) } if status.Poll != nil && !status.Poll.ExpiresAt.IsZero() { - // Now that the status is inserted, and side effects queued, - // attempt to schedule an expiry handler for the status poll. + // Now that the status is inserted, attempt to + // schedule an expiry handler for the status poll. if err := p.polls.ScheduleExpiry(ctx, status.Poll); err != nil { log.Errorf(ctx, "error scheduling poll expiry: %v", err) } } - // send it back to the client API worker for async side-effects. + // Send it to the client API worker for async side-effects. p.state.Workers.Client.Queue.Push(&messages.FromClientAPI{ APObjectType: ap.ObjectNote, APActivityType: ap.ActivityCreate, @@ -172,43 +228,6 @@ func (p *Processor) Create( return p.c.GetAPIStatus(ctx, requester, status) } -func (p *Processor) processPoll(status *gtsmodel.Status, poll *apimodel.PollRequest) { - if poll == nil { - // No poll set. - // Nothing to do. - return - } - - var expiresAt time.Time - - // Now will have been set - // as the status creation. - now := status.CreatedAt - - // Update the status AS type to "Question". - status.ActivityStreamsType = ap.ActivityQuestion - - // Set an expiry time if one given. - if in := poll.ExpiresIn; in > 0 { - expiresIn := time.Duration(in) - expiresAt = now.Add(expiresIn * time.Second) - } - - // Create new poll for status. - status.Poll = >smodel.Poll{ - ID: id.NewULID(), - Multiple: &poll.Multiple, - HideCounts: &poll.HideTotals, - Options: poll.Options, - StatusID: status.ID, - Status: status, - ExpiresAt: expiresAt, - } - - // Set poll ID on the status. - status.PollID = status.Poll.ID -} - func (p *Processor) processInReplyTo(ctx context.Context, requester *gtsmodel.Account, status *gtsmodel.Status, inReplyToID string) gtserror.WithCode { if inReplyToID == "" { // Not a reply. @@ -332,53 +351,6 @@ func (p *Processor) processThreadID(ctx context.Context, status *gtsmodel.Status return nil } -func (p *Processor) processMediaIDs(ctx context.Context, form *apimodel.StatusCreateRequest, thisAccountID string, status *gtsmodel.Status) gtserror.WithCode { - if form.MediaIDs == nil { - return nil - } - - // Get minimum allowed char descriptions. - minChars := config.GetMediaDescriptionMinChars() - - attachments := []*gtsmodel.MediaAttachment{} - attachmentIDs := []string{} - - for _, mediaID := range form.MediaIDs { - attachment, err := p.state.DB.GetAttachmentByID(ctx, mediaID) - if err != nil && !errors.Is(err, db.ErrNoEntries) { - err := gtserror.Newf("error fetching media from db: %w", err) - return gtserror.NewErrorInternalError(err) - } - - if attachment == nil { - text := fmt.Sprintf("media %s not found", mediaID) - return gtserror.NewErrorBadRequest(errors.New(text), text) - } - - if attachment.AccountID != thisAccountID { - text := fmt.Sprintf("media %s does not belong to account", mediaID) - return gtserror.NewErrorBadRequest(errors.New(text), text) - } - - if attachment.StatusID != "" || attachment.ScheduledStatusID != "" { - text := fmt.Sprintf("media %s already attached to status", mediaID) - return gtserror.NewErrorBadRequest(errors.New(text), text) - } - - if length := len([]rune(attachment.Description)); length < minChars { - text := fmt.Sprintf("media %s description too short, at least %d required", mediaID, minChars) - return gtserror.NewErrorBadRequest(errors.New(text), text) - } - - attachments = append(attachments, attachment) - attachmentIDs = append(attachmentIDs, attachment.ID) - } - - status.Attachments = attachments - status.AttachmentIDs = attachmentIDs - return nil -} - func (p *Processor) processVisibility( ctx context.Context, form *apimodel.StatusCreateRequest, @@ -474,99 +446,3 @@ func processInteractionPolicy( // setting it explicitly to save space. return nil } - -func processLanguage(form *apimodel.StatusCreateRequest, 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 (p *Processor) processContent(ctx context.Context, parseMention gtsmodel.ParseMentionFunc, form *apimodel.StatusCreateRequest, status *gtsmodel.Status) error { - if form.ContentType == "" { - // If content type wasn't specified, use the author's preferred content-type. - contentType := apimodel.StatusContentType(status.Account.Settings.StatusContentType) - form.ContentType = contentType - } - - // format is the currently set text formatting - // function, according to the provided content-type. - var format text.FormatFunc - - // formatInput is a shorthand function to format the given input string with the - // currently set 'formatFunc', passing in all required args and returning result. - formatInput := func(formatFunc text.FormatFunc, input string) *text.FormatResult { - return formatFunc(ctx, parseMention, status.AccountID, status.ID, input) - } - - switch form.ContentType { - // None given / set, - // use default (plain). - case "": - fallthrough - - // Format status according to text/plain. - case apimodel.StatusContentTypePlain: - format = p.formatter.FromPlain - - // Format status according to text/markdown. - case apimodel.StatusContentTypeMarkdown: - format = p.formatter.FromMarkdown - - // Unknown. - default: - return fmt.Errorf("invalid status format: %q", form.ContentType) - } - - // Sanitize status text and format. - contentRes := formatInput(format, form.Status) - - // Collect formatted results. - status.Content = contentRes.HTML - status.Mentions = append(status.Mentions, contentRes.Mentions...) - status.Emojis = append(status.Emojis, contentRes.Emojis...) - status.Tags = append(status.Tags, contentRes.Tags...) - - // From here-on-out just use emoji-only - // plain-text formatting as the FormatFunc. - format = p.formatter.FromPlainEmojiOnly - - // Sanitize content warning and format. - spoiler := text.SanitizeToPlaintext(form.SpoilerText) - warningRes := formatInput(format, spoiler) - - // Collect formatted results. - status.ContentWarning = warningRes.HTML - status.Emojis = append(status.Emojis, warningRes.Emojis...) - - if status.Poll != nil { - for i := range status.Poll.Options { - // Sanitize each option title name and format. - option := text.SanitizeToPlaintext(status.Poll.Options[i]) - optionRes := formatInput(format, option) - - // Collect each formatted result. - status.Poll.Options[i] = optionRes.HTML - status.Emojis = append(status.Emojis, optionRes.Emojis...) - } - } - - // Gather all the database IDs from each of the gathered status mentions, tags, and emojis. - status.MentionIDs = xslices.Gather(nil, status.Mentions, func(mention *gtsmodel.Mention) string { return mention.ID }) - status.TagIDs = xslices.Gather(nil, status.Tags, func(tag *gtsmodel.Tag) string { return tag.ID }) - status.EmojiIDs = xslices.Gather(nil, status.Emojis, func(emoji *gtsmodel.Emoji) string { return emoji.ID }) - - if status.ContentWarning != "" && len(status.AttachmentIDs) > 0 { - // If a content-warning is set, and - // the status contains media, always - // set the status sensitive flag. - status.Sensitive = util.Ptr(true) - } - - return nil -} diff --git a/internal/processing/status/edit.go b/internal/processing/status/edit.go new file mode 100644 index 000000000..67374b794 --- /dev/null +++ b/internal/processing/status/edit.go @@ -0,0 +1,572 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// 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