summaryrefslogtreecommitdiff
path: root/internal/processing
diff options
context:
space:
mode:
Diffstat (limited to 'internal/processing')
-rw-r--r--internal/processing/admin/rule.go11
-rw-r--r--internal/processing/common/status.go34
-rw-r--r--internal/processing/instance.go3
-rw-r--r--internal/processing/media/create.go22
-rw-r--r--internal/processing/media/update.go42
-rw-r--r--internal/processing/media/util.go62
-rw-r--r--internal/processing/status/common.go351
-rw-r--r--internal/processing/status/create.go300
-rw-r--r--internal/processing/status/create_test.go2
-rw-r--r--internal/processing/status/edit.go555
-rw-r--r--internal/processing/status/edit_test.go544
-rw-r--r--internal/processing/status/get.go78
-rw-r--r--internal/processing/workers/fromfediapi.go14
13 files changed, 1659 insertions, 359 deletions
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..01f2ab72d 100644
--- a/internal/processing/common/status.go
+++ b/internal/processing/common/status.go
@@ -31,6 +31,40 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/log"
)
+// GetOwnStatus fetches the given status with ID,
+// and ensures that it belongs to given requester.
+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)
+ }
+
+ 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/media/create.go b/internal/processing/media/create.go
index ca1f1c3c6..5ea630618 100644
--- a/internal/processing/media/create.go
+++ b/internal/processing/media/create.go
@@ -25,6 +25,7 @@ import (
"codeberg.org/gruf/go-iotools"
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/gtsmodel"
@@ -45,10 +46,21 @@ func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, form
}
// Parse focus details from API form input.
- focusX, focusY, err := parseFocus(form.Focus)
- if err != nil {
- text := fmt.Sprintf("could not parse focus value %s: %s", form.Focus, err)
- return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
+ focusX, focusY, errWithCode := apiutil.ParseFocus(form.Focus)
+ if errWithCode != nil {
+ return nil, errWithCode
+ }
+
+ // If description provided,
+ // process and validate it.
+ //
+ // This may not yet be set as it
+ // is often set on status post.
+ if form.Description != "" {
+ form.Description, errWithCode = processDescription(form.Description)
+ if errWithCode != nil {
+ return nil, errWithCode
+ }
}
// Open multipart file reader.
@@ -58,7 +70,7 @@ func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, form
return nil, gtserror.NewErrorInternalError(err)
}
- // Wrap the multipart file reader to ensure is limited to max.
+ // Wrap multipart file reader to ensure is limited to max size.
rc, _, _ := iotools.UpdateReadCloserLimit(mpfile, maxszInt64)
// Create local media and write to instance storage.
diff --git a/internal/processing/media/update.go b/internal/processing/media/update.go
index d3a9cfe61..c8592395f 100644
--- a/internal/processing/media/update.go
+++ b/internal/processing/media/update.go
@@ -23,6 +23,8 @@ import (
"fmt"
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/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
@@ -47,17 +49,27 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, media
var updatingColumns []string
if form.Description != nil {
- attachment.Description = text.SanitizeToPlaintext(*form.Description)
+ // Sanitize and validate incoming description.
+ description, errWithCode := processDescription(
+ *form.Description,
+ )
+ if errWithCode != nil {
+ return nil, errWithCode
+ }
+
+ attachment.Description = description
updatingColumns = append(updatingColumns, "description")
}
if form.Focus != nil {
- focusx, focusy, err := parseFocus(*form.Focus)
- if err != nil {
- return nil, gtserror.NewErrorBadRequest(err)
+ // Parse focus details from API form input.
+ focusX, focusY, errWithCode := apiutil.ParseFocus(*form.Focus)
+ if errWithCode != nil {
+ return nil, errWithCode
}
- attachment.FileMeta.Focus.X = focusx
- attachment.FileMeta.Focus.Y = focusy
+
+ attachment.FileMeta.Focus.X = focusX
+ attachment.FileMeta.Focus.Y = focusY
updatingColumns = append(updatingColumns, "focus_x", "focus_y")
}
@@ -72,3 +84,21 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, media
return &a, nil
}
+
+// processDescription will sanitize and valid description against server configuration.
+func processDescription(description string) (string, gtserror.WithCode) {
+ description = text.SanitizeToPlaintext(description)
+ chars := len([]rune(description))
+
+ if min := config.GetMediaDescriptionMinChars(); chars < min {
+ text := fmt.Sprintf("media description less than min chars (%d)", min)
+ return "", gtserror.NewErrorBadRequest(errors.New(text), text)
+ }
+
+ if max := config.GetMediaDescriptionMaxChars(); chars > max {
+ text := fmt.Sprintf("media description exceeds max chars (%d)", max)
+ return "", gtserror.NewErrorBadRequest(errors.New(text), text)
+ }
+
+ return description, nil
+}
diff --git a/internal/processing/media/util.go b/internal/processing/media/util.go
deleted file mode 100644
index 0ca2697fd..000000000
--- a/internal/processing/media/util.go
+++ /dev/null
@@ -1,62 +0,0 @@
-// 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 <http://www.gnu.org/licenses/>.
-
-package media
-
-import (
- "fmt"
- "strconv"
- "strings"
-)
-
-func parseFocus(focus string) (focusx, focusy float32, err error) {
- if focus == "" {
- return
- }
- spl := strings.Split(focus, ",")
- if len(spl) != 2 {
- err = fmt.Errorf("improperly formatted focus %s", focus)
- return
- }
- xStr := spl[0]
- yStr := spl[1]
- if xStr == "" || yStr == "" {
- err = fmt.Errorf("improperly formatted focus %s", focus)
- return
- }
- fx, err := strconv.ParseFloat(xStr, 32)
- if err != nil {
- err = fmt.Errorf("improperly formatted focus %s: %s", focus, err)
- return
- }
- if fx > 1 || fx < -1 {
- err = fmt.Errorf("improperly formatted focus %s", focus)
- return
- }
- focusx = float32(fx)
- fy, err := strconv.ParseFloat(yStr, 32)
- if err != nil {
- err = fmt.Errorf("improperly formatted focus %s: %s", focus, err)
- return
- }
- if fy > 1 || fy < -1 {
- err = fmt.Errorf("improperly formatted focus %s", focus)
- return
- }
- focusy = float32(fy)
- return
-}
diff --git a/internal/processing/status/common.go b/internal/processing/status/common.go
new file mode 100644
index 000000000..3f2b7b6cb
--- /dev/null
+++ b/internal/processing/status/common.go
@@ -0,0 +1,351 @@
+// 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 <http://www.gnu.org/licenses/>.
+
+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"
+)
+
+// validateStatusContent will validate the common
+// content fields across status write endpoints against
+// current server configuration (e.g. max char counts).
+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
+}
+
+// statusContent encompasses the set of common processed
+// status content fields from status write operations for
+// an easily returnable type, without needing to allocate
+// an entire gtsmodel.Status{} model.
+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
+ }
+
+ // We may have received multiple copies of the same emoji, deduplicate these first.
+ status.Emojis = xslices.DeduplicateFunc(status.Emojis, func(e *gtsmodel.Emoji) string {
+ return e.ID
+ })
+
+ // 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
+ }
+
+ // Get configured min/max supported descr chars.
+ minChars := config.GetMediaDescriptionMinChars()
+ maxChars := config.GetMediaDescriptionMaxChars()
+
+ // 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)
+ }
+
+ // Check media description chars within range,
+ // this needs to be done here as lots of clients
+ // only update media description on status post.
+ switch chars := len([]rune(media.Description)); {
+ case chars < minChars:
+ text := fmt.Sprintf("media description less than min chars (%d)", minChars)
+ return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
+
+ case chars > maxChars:
+ text := fmt.Sprintf("media description exceeds max chars (%d)", maxChars)
+ 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 := &gtsmodel.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..af9831b9c 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,7 +44,17 @@ func (p *Processor) Create(
*apimodel.Status,
gtserror.WithCode,
) {
- // Ensure account populated; we'll need settings.
+ // 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 their 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 err := p.processContent(ctx, p.parseMention, form, 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 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 = &gtsmodel.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/create_test.go b/internal/processing/status/create_test.go
index 84168880e..d0a5c7f92 100644
--- a/internal/processing/status/create_test.go
+++ b/internal/processing/status/create_test.go
@@ -170,7 +170,7 @@ func (suite *StatusCreateTestSuite) TestProcessMediaDescriptionTooShort() {
}
apiStatus, err := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm)
- suite.EqualError(err, "media 01F8MH8RMYQ6MSNY3JM2XT1CQ5 description too short, at least 100 required")
+ suite.EqualError(err, "media description less than min chars (100)")
suite.Nil(apiStatus)
}
diff --git a/internal/processing/status/edit.go b/internal/processing/status/edit.go
new file mode 100644
index 000000000..d16092a57
--- /dev/null
+++ b/internal/processing/status/edit.go
@@ -0,0 +1,555 @@
+// 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 <http://www.gnu.org/licenses/>.
+
+package status
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "slices"
+ "time"
+
+ "github.com/superseriousbusiness/gotosocial/internal/ap"
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+ "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/util/xslices"
+)
+
+// Edit ...
+func (p *Processor) Edit(
+ ctx context.Context,
+ requester *gtsmodel.Account,
+ statusID string,
+ form *apimodel.StatusEditRequest,
+) (
+ *apimodel.Status,
+ gtserror.WithCode,
+) {
+ // Fetch status and ensure it's owned by requesting account.
+ status, errWithCode := p.c.GetOwnStatus(ctx, requester, statusID)
+ if errWithCode != nil {
+ return nil, errWithCode
+ }
+
+ // Ensure this isn't a boost.
+ if status.BoostOfID != "" {
+ return nil, gtserror.NewErrorNotFound(
+ errors.New("status is a boost wrapper"),
+ "target status not found",
+ )
+ }
+
+ // Ensure account populated; we'll need their settings.
+ if err := p.state.DB.PopulateAccount(ctx, requester); err != nil {
+ log.Errorf(ctx, "error(s) populating account, will continue: %s", err)
+ }
+
+ // We need the status populated including all historical edits.
+ if err := p.state.DB.PopulateStatusEdits(ctx, status); err != nil {
+ err := gtserror.Newf("error getting status edits from db: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ // Time of edit.
+ now := time.Now()
+
+ // Validate incoming form edit content.
+ if errWithCode := validateStatusContent(
+ form.Status,
+ form.SpoilerText,
+ form.MediaIDs,
+ form.Poll,
+ ); errWithCode != nil {
+ return nil, errWithCode
+ }
+
+ // Process incoming status edit 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 new status attachments to use.
+ media, errWithCode := p.processMedia(ctx,
+ requester.ID,
+ statusID,
+ form.MediaIDs,
+ )
+ if errWithCode != nil {
+ return nil, errWithCode
+ }
+
+ // Process incoming edits of any attached media.
+ mediaEdited, errWithCode := p.processMediaEdits(ctx,
+ media,
+ form.MediaAttributes,
+ )
+ if errWithCode != nil {
+ return nil, errWithCode
+ }
+
+ // Process incoming edits of any attached status poll.
+ poll, pollEdited, errWithCode := p.processPollEdit(ctx,
+ statusID,
+ status.Poll,
+ form.Poll,
+ now,
+ )
+ if errWithCode != nil {
+ return nil, errWithCode
+ }
+
+ // Check if new status poll was set.
+ pollChanged := (poll != status.Poll)
+
+ // Determine whether there were any changes possibly
+ // causing a change to embedded mentions, tags, emojis.
+ contentChanged := (status.Content != content.Content)
+ warningChanged := (status.ContentWarning != content.ContentWarning)
+ languageChanged := (status.Language != content.Language)
+ anyContentChanged := contentChanged || warningChanged ||
+ pollEdited // encapsulates pollChanged too
+
+ // Check if status media attachments have changed.
+ mediaChanged := !slices.Equal(status.AttachmentIDs,
+ form.MediaIDs,
+ )
+
+ // Track status columns we
+ // need to update in database.
+ cols := make([]string, 2, 13)
+ cols[0] = "updated_at"
+ cols[1] = "edits"
+
+ if contentChanged {
+ // Update status text.
+ //
+ // Note we don't update these
+ // status fields right away so
+ // we can save current version.
+ cols = append(cols, "content")
+ cols = append(cols, "text")
+ }
+
+ if warningChanged {
+ // Update status content warning.
+ //
+ // Note we don't update these
+ // status fields right away so
+ // we can save current version.
+ cols = append(cols, "content_warning")
+ }
+
+ if languageChanged {
+ // Update status language pref.
+ //
+ // Note we don't update these
+ // status fields right away so
+ // we can save current version.
+ cols = append(cols, "language")
+ }
+
+ if *status.Sensitive != form.Sensitive {
+ // Update status sensitivity pref.
+ //
+ // Note we don't update these
+ // status fields right away so
+ // we can save current version.
+ cols = append(cols, "sensitive")
+ }
+
+ if mediaChanged {
+ // Updated status media attachments.
+ //
+ // Note we don't update these
+ // status fields right away so
+ // we can save current version.
+ cols = append(cols, "attachments")
+ }
+
+ if pollChanged {
+ // Updated attached status poll.
+ //
+ // Note we don't update these
+ // status fields right away so
+ // we can save current version.
+ cols = append(cols, "poll_id")
+
+ if status.Poll == nil || poll == nil {
+ // Went from with-poll to without-poll
+ // or vice-versa. This changes AP type.
+ cols = append(cols, "activity_streams_type")
+ }
+ }
+
+ if anyContentChanged {
+ if !slices.Equal(status.MentionIDs, content.MentionIDs) {
+ // Update attached status mentions.
+ cols = append(cols, "mentions")
+ status.MentionIDs = content.MentionIDs
+ status.Mentions = content.Mentions
+ }
+
+ if !slices.Equal(status.TagIDs, content.TagIDs) {
+ // Updated attached status tags.
+ cols = append(cols, "tags")
+ status.TagIDs = content.TagIDs
+ status.Tags = content.Tags
+ }
+
+ if !slices.Equal(status.EmojiIDs, content.EmojiIDs) {
+ // We specifically store both *new* AND *old* edit
+ // revision emojis in the statuses.emojis column.
+ emojiByID := func(e *gtsmodel.Emoji) string { return e.ID }
+ status.Emojis = append(status.Emojis, content.Emojis...)
+ status.Emojis = xslices.DeduplicateFunc(status.Emojis, emojiByID)
+ status.EmojiIDs = xslices.Gather(status.EmojiIDs[:0], status.Emojis, emojiByID)
+
+ // Update attached status emojis.
+ cols = append(cols, "emojis")
+ }
+ }
+
+ // If no status columns were updated, no media and
+ // no poll were edited, there's nothing to do!
+ if len(cols) == 2 && !mediaEdited && !pollEdited {
+ const text = "status was not changed"
+ return nil, gtserror.NewErrorUnprocessableEntity(
+ errors.New(text),
+ text,
+ )
+ }
+
+ // Create an edit to store a
+ // historical snapshot of status.
+ var edit gtsmodel.StatusEdit
+ edit.ID = id.NewULIDFromTime(now)
+ edit.Content = status.Content
+ edit.ContentWarning = status.ContentWarning
+ edit.Text = status.Text
+ edit.Language = status.Language
+ edit.Sensitive = status.Sensitive
+ edit.StatusID = status.ID
+ edit.CreatedAt = status.UpdatedAt
+
+ // Copy existing media and descriptions.
+ edit.AttachmentIDs = status.AttachmentIDs
+ if l := len(status.Attachments); l > 0 {
+ edit.AttachmentDescriptions = make([]string, l)
+ for i, attach := range status.Attachments {
+ edit.AttachmentDescriptions[i] = attach.Description
+ }
+ }
+
+ if status.Poll != nil {
+ // Poll only set if existed previously.
+ edit.PollOptions = status.Poll.Options
+
+ if pollChanged || !*status.Poll.HideCounts ||
+ !status.Poll.ClosedAt.IsZero() {
+ // If the counts are allowed to be
+ // shown, or poll has changed, then
+ // include poll vote counts in edit.
+ edit.PollVotes = status.Poll.Votes
+ }
+ }
+
+ // Insert this new edit of existing status into database.
+ if err := p.state.DB.PutStatusEdit(ctx, &edit); err != nil {
+ err := gtserror.Newf("error putting edit in database: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ // Add edit to list of edits on the status.
+ status.EditIDs = append(status.EditIDs, edit.ID)
+ status.Edits = append(status.Edits, &edit)
+
+ // Now historical status data is stored,
+ // update the other necessary status fields.
+ status.Content = content.Content
+ status.ContentWarning = content.ContentWarning
+ status.Text = form.Status
+ status.Language = content.Language
+ status.Sensitive = &form.Sensitive
+ status.AttachmentIDs = form.MediaIDs
+ status.Attachments = media
+ status.UpdatedAt = now
+
+ if poll != nil {
+ // Set relevent fields for latest with poll.
+ status.ActivityStreamsType = ap.ActivityQuestion
+ status.PollID = poll.ID
+ status.Poll = poll
+ } else {
+ // Set relevant fields for latest without poll.
+ status.ActivityStreamsType = ap.ObjectNote
+ status.PollID = ""
+ status.Poll = nil
+ }
+
+ // Finally update the existing status model in the database.
+ if err := p.state.DB.UpdateStatus(ctx, status, cols...); err != nil {
+ err := gtserror.Newf("error updating status in db: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ if pollChanged && status.Poll != nil && !status.Poll.ExpiresAt.IsZero() {
+ // Now the status is updated, attempt to schedule
+ // an expiry handler for the changed status poll.
+ if err := p.polls.ScheduleExpiry(ctx, status.Poll); err != nil {
+ log.Errorf(ctx, "error scheduling poll expiry: %v", err)
+ }
+ }
+
+ // Send it to the client API worker for async side-effects.
+ p.state.Workers.Client.Queue.Push(&messages.FromClientAPI{
+ APObjectType: ap.ObjectNote,
+ APActivityType: ap.ActivityUpdate,
+ GTSModel: status,
+ Origin: requester,
+ })
+
+ // Return an API model of the updated status.
+ return p.c.GetAPIStatus(ctx, requester, status)
+}
+
+// HistoryGet gets edit history for the target status, taking account of privacy settings and blocks etc.
+func (p *Processor) HistoryGet(ctx context.Context, requester *gtsmodel.Account, targetStatusID string) ([]*apimodel.StatusEdit, gtserror.WithCode) {
+ target, errWithCode := p.c.GetVisibleTargetStatus(ctx,
+ requester,
+ targetStatusID,
+ nil, // default freshness
+ )
+ if errWithCode != nil {
+ return nil, errWithCode
+ }
+
+ if err := p.state.DB.PopulateStatusEdits(ctx, target); err != nil {
+ err := gtserror.Newf("error getting status edits from db: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ edits, err := p.converter.StatusToAPIEdits(ctx, target)
+ if err != nil {
+ err := gtserror.Newf("error converting status edits: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ return edits, nil
+}
+
+func (p *Processor) processMediaEdits(
+ ctx context.Context,
+ attachs []*gtsmodel.MediaAttachment,
+ attrs []apimodel.AttachmentAttributesRequest,
+) (
+ bool,
+ gtserror.WithCode,
+) {
+ var edited bool
+
+ for _, attr := range attrs {
+ // Search the media attachments slice for index of media with attr.ID.
+ i := slices.IndexFunc(attachs, func(m *gtsmodel.MediaAttachment) bool {
+ return m.ID == attr.ID
+ })
+ if i == -1 {
+ text := fmt.Sprintf("media not found: %s", attr.ID)
+ return false, gtserror.NewErrorBadRequest(errors.New(text), text)
+ }
+
+ // Get attach at index.
+ attach := attachs[i]
+
+ // Track which columns need
+ // updating in database query.
+ cols := make([]string, 0, 2)
+
+ // Check for description change.
+ if attr.Description != attach.Description {
+ attach.Description = attr.Description
+ cols = append(cols, "description")
+ }
+
+ if attr.Focus != "" {
+ // Parse provided media focus parameters from string.
+ fx, fy, errWithCode := apiutil.ParseFocus(attr.Focus)
+ if errWithCode != nil {
+ return false, errWithCode
+ }
+
+ // Check for change in focus coords.
+ if attach.FileMeta.Focus.X != fx ||
+ attach.FileMeta.Focus.Y != fy {
+ attach.FileMeta.Focus.X = fx
+ attach.FileMeta.Focus.Y = fy
+ cols = append(cols, "focus_x", "focus_y")
+ }
+ }
+
+ if len(cols) > 0 {
+ // Media attachment was changed, update this in database.
+ err := p.state.DB.UpdateAttachment(ctx, attach, cols...)
+ if err != nil {
+ err := gtserror.Newf("error updating attachment in db: %w", err)
+ return false, gtserror.NewErrorInternalError(err)
+ }
+
+ // Set edited.
+ edited = true
+ }
+ }
+
+ return edited, nil
+}
+
+func (p *Processor) processPollEdit(
+ ctx context.Context,
+ statusID string,
+ original *gtsmodel.Poll,
+ form *apimodel.PollRequest,
+ now time.Time, // used for expiry time
+) (
+ *gtsmodel.Poll,
+ bool,
+ gtserror.WithCode,
+) {
+ if form == nil {
+ if original != nil {
+ // No poll was given but there's an existing poll,
+ // this indicates the original needs to be deleted.
+ if err := p.deletePoll(ctx, original); err != nil {
+ return nil, true, gtserror.NewErrorInternalError(err)
+ }
+
+ // Existing was deleted.
+ return nil, true, nil
+ }
+
+ // No change in poll.
+ return nil, false, nil
+ }
+
+ switch {
+ // No existing poll.
+ case original == nil:
+
+ // Any change that effects voting, i.e. options, allow multiple
+ // or re-opening a closed poll requires deleting the existing poll.
+ case !slices.Equal(form.Options, original.Options) ||
+ (form.Multiple != *original.Multiple) ||
+ (!original.ClosedAt.IsZero() && form.ExpiresIn != 0):
+ if err := p.deletePoll(ctx, original); err != nil {
+ return nil, true, gtserror.NewErrorInternalError(err)
+ }
+
+ // Any other changes only require a model
+ // update, and at-most a new expiry handler.
+ default:
+ var cols []string
+
+ // Check if the hide counts field changed.
+ if form.HideTotals != *original.HideCounts {
+ cols = append(cols, "hide_counts")
+ original.HideCounts = &form.HideTotals
+ }
+
+ var expiresAt time.Time
+
+ // Determine expiry time if given.
+ if in := form.ExpiresIn; in > 0 {
+ expiresIn := time.Duration(in)
+ expiresAt = now.Add(expiresIn * time.Second)
+ }
+
+ // Check for expiry time.
+ if !expiresAt.IsZero() {
+
+ if !original.ExpiresAt.IsZero() {
+ // Existing had expiry, cancel scheduled handler.
+ _ = p.state.Workers.Scheduler.Cancel(original.ID)
+ }
+
+ // Since expiry is given as a duration
+ // we always treat > 0 as a change as
+ // we can't know otherwise unfortunately.
+ cols = append(cols, "expires_at")
+ original.ExpiresAt = expiresAt
+ }
+
+ if len(cols) == 0 {
+ // Were no changes to poll.
+ return original, false, nil
+ }
+
+ // Update the original poll model in the database with these columns.
+ if err := p.state.DB.UpdatePoll(ctx, original, cols...); err != nil {
+ err := gtserror.Newf("error updating poll.expires_at in db: %w", err)
+ return nil, true, gtserror.NewErrorInternalError(err)
+ }
+
+ if !expiresAt.IsZero() {
+ // Updated poll has an expiry, schedule a new expiry handler.
+ if err := p.polls.ScheduleExpiry(ctx, original); err != nil {
+ log.Errorf(ctx, "error scheduling poll expiry: %v", err)
+ }
+ }
+
+ // Existing poll was updated.
+ return original, true, nil
+ }
+
+ // If we reached here then an entirely
+ // new status poll needs to be created.
+ poll, errWithCode := p.processPoll(ctx,
+ statusID,
+ form,
+ now,
+ )
+ return poll, true, errWithCode
+}
+
+func (p *Processor) deletePoll(ctx context.Context, poll *gtsmodel.Poll) error {
+ if !poll.ExpiresAt.IsZero() && !poll.ClosedAt.IsZero() {
+ // Poll has an expiry and has not yet closed,
+ // cancel any expiry handler before deletion.
+ _ = p.state.Workers.Scheduler.Cancel(poll.ID)
+ }
+
+ // Delete the given poll from the database.
+ err := p.state.DB.DeletePollByID(ctx, poll.ID)
+ if err != nil && !errors.Is(err, db.ErrNoEntries) {
+ return gtserror.Newf("error deleting poll from db: %w", err)
+ }
+
+ return nil
+}
diff --git a/internal/processing/status/edit_test.go b/internal/processing/status/edit_test.go
new file mode 100644
index 000000000..393c3efc2
--- /dev/null
+++ b/internal/processing/status/edit_test.go
@@ -0,0 +1,544 @@
+// 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 <http://www.gnu.org/licenses/>.
+
+package status_test
+
+import (
+ "context"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/suite"
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
+ "github.com/superseriousbusiness/gotosocial/internal/util/xslices"
+)
+
+type StatusEditTestSuite struct {
+ StatusStandardTestSuite
+}
+
+func (suite *StatusEditTestSuite) TestSimpleEdit() {
+ // Create cancellable context to use for test.
+ ctx, cncl := context.WithCancel(context.Background())
+ defer cncl()
+
+ // Get a local account to use as test requester.
+ requester := suite.testAccounts["local_account_1"]
+ requester, _ = suite.state.DB.GetAccountByID(ctx, requester.ID)
+
+ // Get requester's existing status to perform an edit on.
+ status := suite.testStatuses["local_account_1_status_9"]
+ status, _ = suite.state.DB.GetStatusByID(ctx, status.ID)
+
+ // Prepare a simple status edit.
+ form := &apimodel.StatusEditRequest{
+ Status: "<p>this is some edited status text!</p>",
+ SpoilerText: "shhhhh",
+ Sensitive: true,
+ Language: "fr", // hoh hoh hoh
+ MediaIDs: nil,
+ MediaAttributes: nil,
+ Poll: nil,
+ }
+
+ // Pass the prepared form to the status processor to perform the edit.
+ apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form)
+ suite.NotNil(apiStatus)
+ suite.NoError(errWithCode)
+
+ // Check response against input form data.
+ suite.Equal(form.Status, apiStatus.Text)
+ suite.Equal(form.SpoilerText, apiStatus.SpoilerText)
+ suite.Equal(form.Sensitive, apiStatus.Sensitive)
+ suite.Equal(form.Language, *apiStatus.Language)
+ suite.NotEqual(util.FormatISO8601(status.UpdatedAt), *apiStatus.EditedAt)
+
+ // Fetched the latest version of edited status from the database.
+ latestStatus, err := suite.state.DB.GetStatusByID(ctx, status.ID)
+ suite.NoError(err)
+
+ // Check latest status against input form data.
+ suite.Equal(form.Status, latestStatus.Text)
+ suite.Equal(form.SpoilerText, latestStatus.ContentWarning)
+ suite.Equal(form.Sensitive, *latestStatus.Sensitive)
+ suite.Equal(form.Language, latestStatus.Language)
+ suite.Equal(len(status.EditIDs)+1, len(latestStatus.EditIDs))
+ suite.NotEqual(status.UpdatedAt, latestStatus.UpdatedAt)
+
+ // Populate all historical edits for this status.
+ err = suite.state.DB.PopulateStatusEdits(ctx, latestStatus)
+ suite.NoError(err)
+
+ // Check previous status edit matches original status content.
+ previousEdit := latestStatus.Edits[len(latestStatus.Edits)-1]
+ suite.Equal(status.Content, previousEdit.Content)
+ suite.Equal(status.Text, previousEdit.Text)
+ suite.Equal(status.ContentWarning, previousEdit.ContentWarning)
+ suite.Equal(*status.Sensitive, *previousEdit.Sensitive)
+ suite.Equal(status.Language, previousEdit.Language)
+ suite.Equal(status.UpdatedAt, previousEdit.CreatedAt)
+}
+
+func (suite *StatusEditTestSuite) TestEditAddPoll() {
+ // Create cancellable context to use for test.
+ ctx, cncl := context.WithCancel(context.Background())
+ defer cncl()
+
+ // Get a local account to use as test requester.
+ requester := suite.testAccounts["local_account_1"]
+ requester, _ = suite.state.DB.GetAccountByID(ctx, requester.ID)
+
+ // Get requester's existing status to perform an edit on.
+ status := suite.testStatuses["local_account_1_status_9"]
+ status, _ = suite.state.DB.GetStatusByID(ctx, status.ID)
+
+ // Prepare edit adding a status poll.
+ form := &apimodel.StatusEditRequest{
+ Status: "<p>this is some edited status text!</p>",
+ SpoilerText: "",
+ Sensitive: true,
+ Language: "fr", // hoh hoh hoh
+ MediaIDs: nil,
+ MediaAttributes: nil,
+ Poll: &apimodel.PollRequest{
+ Options: []string{"yes", "no", "spiderman"},
+ ExpiresIn: int(time.Minute),
+ Multiple: true,
+ HideTotals: false,
+ },
+ }
+
+ // Pass the prepared form to the status processor to perform the edit.
+ apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form)
+ suite.NotNil(apiStatus)
+ suite.NoError(errWithCode)
+
+ // Check response against input form data.
+ suite.Equal(form.Status, apiStatus.Text)
+ suite.Equal(form.SpoilerText, apiStatus.SpoilerText)
+ suite.Equal(form.Sensitive, apiStatus.Sensitive)
+ suite.Equal(form.Language, *apiStatus.Language)
+ suite.NotEqual(util.FormatISO8601(status.UpdatedAt), *apiStatus.EditedAt)
+ suite.NotNil(apiStatus.Poll)
+ suite.Equal(form.Poll.Options, xslices.Gather(nil, apiStatus.Poll.Options, func(opt apimodel.PollOption) string {
+ return opt.Title
+ }))
+
+ // Fetched the latest version of edited status from the database.
+ latestStatus, err := suite.state.DB.GetStatusByID(ctx, status.ID)
+ suite.NoError(err)
+
+ // Check latest status against input form data.
+ suite.Equal(form.Status, latestStatus.Text)
+ suite.Equal(form.SpoilerText, latestStatus.ContentWarning)
+ suite.Equal(form.Sensitive, *latestStatus.Sensitive)
+ suite.Equal(form.Language, latestStatus.Language)
+ suite.Equal(len(status.EditIDs)+1, len(latestStatus.EditIDs))
+ suite.NotEqual(status.UpdatedAt, latestStatus.UpdatedAt)
+ suite.NotNil(latestStatus.Poll)
+ suite.Equal(form.Poll.Options, latestStatus.Poll.Options)
+
+ // Ensure that a poll expiry handler was scheduled on status edit.
+ expiryWorker := suite.state.Workers.Scheduler.Cancel(latestStatus.PollID)
+ suite.Equal(form.Poll.ExpiresIn > 0, expiryWorker)
+
+ // Populate all historical edits for this status.
+ err = suite.state.DB.PopulateStatusEdits(ctx, latestStatus)
+ suite.NoError(err)
+
+ // Check previous status edit matches original status content.
+ previousEdit := latestStatus.Edits[len(latestStatus.Edits)-1]
+ suite.Equal(status.Content, previousEdit.Content)
+ suite.Equal(status.Text, previousEdit.Text)
+ suite.Equal(status.ContentWarning, previousEdit.ContentWarning)
+ suite.Equal(*status.Sensitive, *previousEdit.Sensitive)
+ suite.Equal(status.Language, previousEdit.Language)
+ suite.Equal(status.UpdatedAt, previousEdit.CreatedAt)
+ suite.Equal(status.Poll != nil, len(previousEdit.PollOptions) > 0)
+}
+
+func (suite *StatusEditTestSuite) TestEditAddPollNoExpiry() {
+ // Create cancellable context to use for test.
+ ctx, cncl := context.WithCancel(context.Background())
+ defer cncl()
+
+ // Get a local account to use as test requester.
+ requester := suite.testAccounts["local_account_1"]
+ requester, _ = suite.state.DB.GetAccountByID(ctx, requester.ID)
+
+ // Get requester's existing status to perform an edit on.
+ status := suite.testStatuses["local_account_1_status_9"]
+ status, _ = suite.state.DB.GetStatusByID(ctx, status.ID)
+
+ // Prepare edit adding an endless poll.
+ form := &apimodel.StatusEditRequest{
+ Status: "<p>this is some edited status text!</p>",
+ SpoilerText: "",
+ Sensitive: true,
+ Language: "fr", // hoh hoh hoh
+ MediaIDs: nil,
+ MediaAttributes: nil,
+ Poll: &apimodel.PollRequest{
+ Options: []string{"yes", "no", "spiderman"},
+ ExpiresIn: 0,
+ Multiple: true,
+ HideTotals: false,
+ },
+ }
+
+ // Pass the prepared form to the status processor to perform the edit.
+ apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form)
+ suite.NotNil(apiStatus)
+ suite.NoError(errWithCode)
+
+ // Check response against input form data.
+ suite.Equal(form.Status, apiStatus.Text)
+ suite.Equal(form.SpoilerText, apiStatus.SpoilerText)
+ suite.Equal(form.Sensitive, apiStatus.Sensitive)
+ suite.Equal(form.Language, *apiStatus.Language)
+ suite.NotEqual(util.FormatISO8601(status.UpdatedAt), *apiStatus.EditedAt)
+ suite.NotNil(apiStatus.Poll)
+ suite.Equal(form.Poll.Options, xslices.Gather(nil, apiStatus.Poll.Options, func(opt apimodel.PollOption) string {
+ return opt.Title
+ }))
+
+ // Fetched the latest version of edited status from the database.
+ latestStatus, err := suite.state.DB.GetStatusByID(ctx, status.ID)
+ suite.NoError(err)
+
+ // Check latest status against input form data.
+ suite.Equal(form.Status, latestStatus.Text)
+ suite.Equal(form.SpoilerText, latestStatus.ContentWarning)
+ suite.Equal(form.Sensitive, *latestStatus.Sensitive)
+ suite.Equal(form.Language, latestStatus.Language)
+ suite.Equal(len(status.EditIDs)+1, len(latestStatus.EditIDs))
+ suite.NotEqual(status.UpdatedAt, latestStatus.UpdatedAt)
+ suite.NotNil(latestStatus.Poll)
+ suite.Equal(form.Poll.Options, latestStatus.Poll.Options)
+
+ // Ensure that a poll expiry handler was *not* scheduled on status edit.
+ expiryWorker := suite.state.Workers.Scheduler.Cancel(latestStatus.PollID)
+ suite.Equal(form.Poll.ExpiresIn > 0, expiryWorker)
+
+ // Populate all historical edits for this status.
+ err = suite.state.DB.PopulateStatusEdits(ctx, latestStatus)
+ suite.NoError(err)
+
+ // Check previous status edit matches original status content.
+ previousEdit := latestStatus.Edits[len(latestStatus.Edits)-1]
+ suite.Equal(status.Content, previousEdit.Content)
+ suite.Equal(status.Text, previousEdit.Text)
+ suite.Equal(status.ContentWarning, previousEdit.ContentWarning)
+ suite.Equal(*status.Sensitive, *previousEdit.Sensitive)
+ suite.Equal(status.Language, previousEdit.Language)
+ suite.Equal(status.UpdatedAt, previousEdit.CreatedAt)
+ suite.Equal(status.Poll != nil, len(previousEdit.PollOptions) > 0)
+}
+
+func (suite *StatusEditTestSuite) TestEditMediaDescription() {
+ // Create cancellable context to use for test.
+ ctx, cncl := context.WithCancel(context.Background())
+ defer cncl()
+
+ // Get a local account to use as test requester.
+ requester := suite.testAccounts["local_account_1"]
+ requester, _ = suite.state.DB.GetAccountByID(ctx, requester.ID)
+
+ // Get requester's existing status to perform an edit on.
+ status := suite.testStatuses["local_account_1_status_4"]
+ status, _ = suite.state.DB.GetStatusByID(ctx, status.ID)
+
+ // Prepare edit changing media description.
+ form := &apimodel.StatusEditRequest{
+ Status: "<p>this is some edited status text!</p>",
+ SpoilerText: "this status is now missing media",
+ Sensitive: true,
+ Language: "en",
+ MediaIDs: status.AttachmentIDs,
+ MediaAttributes: []apimodel.AttachmentAttributesRequest{
+ {ID: status.AttachmentIDs[0], Description: "hello world!"},
+ {ID: status.AttachmentIDs[1], Description: "media attachment numero two"},
+ },
+ }
+
+ // Pass the prepared form to the status processor to perform the edit.
+ apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form)
+ suite.NoError(errWithCode)
+
+ // Check response against input form data.
+ suite.Equal(form.Status, apiStatus.Text)
+ suite.Equal(form.SpoilerText, apiStatus.SpoilerText)
+ suite.Equal(form.Sensitive, apiStatus.Sensitive)
+ suite.Equal(form.Language, *apiStatus.Language)
+ suite.NotEqual(util.FormatISO8601(status.UpdatedAt), *apiStatus.EditedAt)
+ suite.Equal(form.MediaIDs, xslices.Gather(nil, apiStatus.MediaAttachments, func(media *apimodel.Attachment) string {
+ return media.ID
+ }))
+ suite.Equal(
+ xslices.Gather(nil, form.MediaAttributes, func(attr apimodel.AttachmentAttributesRequest) string {
+ return attr.Description
+ }),
+ xslices.Gather(nil, apiStatus.MediaAttachments, func(media *apimodel.Attachment) string {
+ return *media.Description
+ }),
+ )
+
+ // Fetched the latest version of edited status from the database.
+ latestStatus, err := suite.state.DB.GetStatusByID(ctx, status.ID)
+ suite.NoError(err)
+
+ // Check latest status against input form data.
+ suite.Equal(form.Status, latestStatus.Text)
+ suite.Equal(form.SpoilerText, latestStatus.ContentWarning)
+ suite.Equal(form.Sensitive, *latestStatus.Sensitive)
+ suite.Equal(form.Language, latestStatus.Language)
+ suite.Equal(len(status.EditIDs)+1, len(latestStatus.EditIDs))
+ suite.NotEqual(status.UpdatedAt, latestStatus.UpdatedAt)
+ suite.Equal(form.MediaIDs, latestStatus.AttachmentIDs)
+ suite.Equal(
+ xslices.Gather(nil, form.MediaAttributes, func(attr apimodel.AttachmentAttributesRequest) string {
+ return attr.Description
+ }),
+ xslices.Gather(nil, latestStatus.Attachments, func(media *gtsmodel.MediaAttachment) string {
+ return media.Description
+ }),
+ )
+
+ // Populate all historical edits for this status.
+ err = suite.state.DB.PopulateStatusEdits(ctx, latestStatus)
+ suite.NoError(err)
+
+ // Further populate edits to get attachments.
+ for _, edit := range latestStatus.Edits {
+ err = suite.state.DB.PopulateStatusEdit(ctx, edit)
+ suite.NoError(err)
+ }
+
+ // Check previous status edit matches original status content.
+ previousEdit := latestStatus.Edits[len(latestStatus.Edits)-1]
+ suite.Equal(status.Content, previousEdit.Content)
+ suite.Equal(status.Text, previousEdit.Text)
+ suite.Equal(status.ContentWarning, previousEdit.ContentWarning)
+ suite.Equal(*status.Sensitive, *previousEdit.Sensitive)
+ suite.Equal(status.Language, previousEdit.Language)
+ suite.Equal(status.UpdatedAt, previousEdit.CreatedAt)
+ suite.Equal(status.AttachmentIDs, previousEdit.AttachmentIDs)
+ suite.Equal(
+ xslices.Gather(nil, status.Attachments, func(media *gtsmodel.MediaAttachment) string {
+ return media.Description
+ }),
+ previousEdit.AttachmentDescriptions,
+ )
+}
+
+func (suite *StatusEditTestSuite) TestEditAddMedia() {
+ // Create cancellable context to use for test.
+ ctx, cncl := context.WithCancel(context.Background())
+ defer cncl()
+
+ // Get a local account to use as test requester.
+ requester := suite.testAccounts["local_account_1"]
+ requester, _ = suite.state.DB.GetAccountByID(ctx, requester.ID)
+
+ // Get some of requester's existing media, and unattach from existing status.
+ media1 := suite.testAttachments["local_account_1_status_4_attachment_1"]
+ media2 := suite.testAttachments["local_account_1_status_4_attachment_2"]
+ media1.StatusID, media2.StatusID = "", ""
+ suite.NoError(suite.state.DB.UpdateAttachment(ctx, media1, "status_id"))
+ suite.NoError(suite.state.DB.UpdateAttachment(ctx, media2, "status_id"))
+ media1, _ = suite.state.DB.GetAttachmentByID(ctx, media1.ID)
+ media2, _ = suite.state.DB.GetAttachmentByID(ctx, media2.ID)
+
+ // Get requester's existing status to perform an edit on.
+ status := suite.testStatuses["local_account_1_status_9"]
+ status, _ = suite.state.DB.GetStatusByID(ctx, status.ID)
+
+ // Prepare edit addding status media.
+ form := &apimodel.StatusEditRequest{
+ Status: "<p>this is some edited status text!</p>",
+ SpoilerText: "this status now has media",
+ Sensitive: true,
+ Language: "en",
+ MediaIDs: []string{media1.ID, media2.ID},
+ MediaAttributes: nil,
+ }
+
+ // Pass the prepared form to the status processor to perform the edit.
+ apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form)
+ suite.NotNil(apiStatus)
+ suite.NoError(errWithCode)
+
+ // Check response against input form data.
+ suite.Equal(form.Status, apiStatus.Text)
+ suite.Equal(form.SpoilerText, apiStatus.SpoilerText)
+ suite.Equal(form.Sensitive, apiStatus.Sensitive)
+ suite.Equal(form.Language, *apiStatus.Language)
+ suite.NotEqual(util.FormatISO8601(status.UpdatedAt), *apiStatus.EditedAt)
+ suite.Equal(form.MediaIDs, xslices.Gather(nil, apiStatus.MediaAttachments, func(media *apimodel.Attachment) string {
+ return media.ID
+ }))
+
+ // Fetched the latest version of edited status from the database.
+ latestStatus, err := suite.state.DB.GetStatusByID(ctx, status.ID)
+ suite.NoError(err)
+
+ // Check latest status against input form data.
+ suite.Equal(form.Status, latestStatus.Text)
+ suite.Equal(form.SpoilerText, latestStatus.ContentWarning)
+ suite.Equal(form.Sensitive, *latestStatus.Sensitive)
+ suite.Equal(form.Language, latestStatus.Language)
+ suite.Equal(len(status.EditIDs)+1, len(latestStatus.EditIDs))
+ suite.NotEqual(status.UpdatedAt, latestStatus.UpdatedAt)
+ suite.Equal(form.MediaIDs, latestStatus.AttachmentIDs)
+
+ // Populate all historical edits for this status.
+ err = suite.state.DB.PopulateStatusEdits(ctx, latestStatus)
+ suite.NoError(err)
+
+ // Check previous status edit matches original status content.
+ previousEdit := latestStatus.Edits[len(latestStatus.Edits)-1]
+ suite.Equal(status.Content, previousEdit.Content)
+ suite.Equal(status.Text, previousEdit.Text)
+ suite.Equal(status.ContentWarning, previousEdit.ContentWarning)
+ suite.Equal(*status.Sensitive, *previousEdit.Sensitive)
+ suite.Equal(status.Language, previousEdit.Language)
+ suite.Equal(status.UpdatedAt, previousEdit.CreatedAt)
+ suite.Equal(status.AttachmentIDs, previousEdit.AttachmentIDs)
+}
+
+func (suite *StatusEditTestSuite) TestEditRemoveMedia() {
+ // Create cancellable context to use for test.
+ ctx, cncl := context.WithCancel(context.Background())
+ defer cncl()
+
+ // Get a local account to use as test requester.
+ requester := suite.testAccounts["local_account_1"]
+ requester, _ = suite.state.DB.GetAccountByID(ctx, requester.ID)
+
+ // Get requester's existing status to perform an edit on.
+ status := suite.testStatuses["local_account_1_status_4"]
+ status, _ = suite.state.DB.GetStatusByID(ctx, status.ID)
+
+ // Prepare edit removing status media.
+ form := &apimodel.StatusEditRequest{
+ Status: "<p>this is some edited status text!</p>",
+ SpoilerText: "this status is now missing media",
+ Sensitive: true,
+ Language: "en",
+ MediaIDs: nil,
+ MediaAttributes: nil,
+ }
+
+ // Pass the prepared form to the status processor to perform the edit.
+ apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form)
+ suite.NotNil(apiStatus)
+ suite.NoError(errWithCode)
+
+ // Check response against input form data.
+ suite.Equal(form.Status, apiStatus.Text)
+ suite.Equal(form.SpoilerText, apiStatus.SpoilerText)
+ suite.Equal(form.Sensitive, apiStatus.Sensitive)
+ suite.Equal(form.Language, *apiStatus.Language)
+ suite.NotEqual(util.FormatISO8601(status.UpdatedAt), *apiStatus.EditedAt)
+ suite.Equal(form.MediaIDs, xslices.Gather(nil, apiStatus.MediaAttachments, func(media *apimodel.Attachment) string {
+ return media.ID
+ }))
+
+ // Fetched the latest version of edited status from the database.
+ latestStatus, err := suite.state.DB.GetStatusByID(ctx, status.ID)
+ suite.NoError(err)
+
+ // Check latest status against input form data.
+ suite.Equal(form.Status, latestStatus.Text)
+ suite.Equal(form.SpoilerText, latestStatus.ContentWarning)
+ suite.Equal(form.Sensitive, *latestStatus.Sensitive)
+ suite.Equal(form.Language, latestStatus.Language)
+ suite.Equal(len(status.EditIDs)+1, len(latestStatus.EditIDs))
+ suite.NotEqual(status.UpdatedAt, latestStatus.UpdatedAt)
+ suite.Equal(form.MediaIDs, latestStatus.AttachmentIDs)
+
+ // Populate all historical edits for this status.
+ err = suite.state.DB.PopulateStatusEdits(ctx, latestStatus)
+ suite.NoError(err)
+
+ // Check previous status edit matches original status content.
+ previousEdit := latestStatus.Edits[len(latestStatus.Edits)-1]
+ suite.Equal(status.Content, previousEdit.Content)
+ suite.Equal(status.Text, previousEdit.Text)
+ suite.Equal(status.ContentWarning, previousEdit.ContentWarning)
+ suite.Equal(*status.Sensitive, *previousEdit.Sensitive)
+ suite.Equal(status.Language, previousEdit.Language)
+ suite.Equal(status.UpdatedAt, previousEdit.CreatedAt)
+ suite.Equal(status.AttachmentIDs, previousEdit.AttachmentIDs)
+}
+
+func (suite *StatusEditTestSuite) TestEditOthersStatus1() {
+ // Create cancellable context to use for test.
+ ctx, cncl := context.WithCancel(context.Background())
+ defer cncl()
+
+ // Get a local account to use as test requester.
+ requester := suite.testAccounts["local_account_1"]
+ requester, _ = suite.state.DB.GetAccountByID(ctx, requester.ID)
+
+ // Get remote accounts's status to attempt an edit on.
+ status := suite.testStatuses["remote_account_1_status_1"]
+ status, _ = suite.state.DB.GetStatusByID(ctx, status.ID)
+
+ // Prepare an empty request form, this
+ // should be all we need to trigger it.
+ form := &apimodel.StatusEditRequest{}
+
+ // Attempt to edit other remote account's status, this should return an error.
+ apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form)
+ suite.Nil(apiStatus)
+ suite.Equal(http.StatusNotFound, errWithCode.Code())
+ suite.Equal("status does not belong to requester", errWithCode.Error())
+ suite.Equal("Not Found: target status not found", errWithCode.Safe())
+}
+
+func (suite *StatusEditTestSuite) TestEditOthersStatus2() {
+ // Create cancellable context to use for test.
+ ctx, cncl := context.WithCancel(context.Background())
+ defer cncl()
+
+ // Get a local account to use as test requester.
+ requester := suite.testAccounts["local_account_1"]
+ requester, _ = suite.state.DB.GetAccountByID(ctx, requester.ID)
+
+ // Get other local accounts's status to attempt edit on.
+ status := suite.testStatuses["local_account_2_status_1"]
+ status, _ = suite.state.DB.GetStatusByID(ctx, status.ID)
+
+ // Prepare an empty request form, this
+ // should be all we need to trigger it.
+ form := &apimodel.StatusEditRequest{}
+
+ // Attempt to edit other local account's status, this should return an error.
+ apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form)
+ suite.Nil(apiStatus)
+ suite.Equal(http.StatusNotFound, errWithCode.Code())
+ suite.Equal("status does not belong to requester", errWithCode.Error())
+ suite.Equal("Not Found: target status not found", errWithCode.Safe())
+}
+
+func TestStatusEditTestSuite(t *testing.T) {
+ suite.Run(t, new(StatusEditTestSuite))
+}
diff --git a/internal/processing/status/get.go b/internal/processing/status/get.go
index 470b93a8f..812f01683 100644
--- a/internal/processing/status/get.go
+++ b/internal/processing/status/get.go
@@ -19,47 +19,16 @@ package status
import (
"context"
+ "errors"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
- "github.com/superseriousbusiness/gotosocial/internal/util"
)
-// HistoryGet gets edit history for the target status, taking account of privacy settings and blocks etc.
-// TODO: currently this just returns the latest version of the status.
-func (p *Processor) HistoryGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) ([]*apimodel.StatusEdit, gtserror.WithCode) {
- targetStatus, errWithCode := p.c.GetVisibleTargetStatus(ctx,
- requestingAccount,
- targetStatusID,
- nil, // default freshness
- )
- if errWithCode != nil {
- return nil, errWithCode
- }
-
- apiStatus, errWithCode := p.c.GetAPIStatus(ctx, requestingAccount, targetStatus)
- if errWithCode != nil {
- return nil, errWithCode
- }
-
- return []*apimodel.StatusEdit{
- {
- Content: apiStatus.Content,
- SpoilerText: apiStatus.SpoilerText,
- Sensitive: apiStatus.Sensitive,
- CreatedAt: util.FormatISO8601(targetStatus.UpdatedAt),
- Account: apiStatus.Account,
- Poll: apiStatus.Poll,
- MediaAttachments: apiStatus.MediaAttachments,
- Emojis: apiStatus.Emojis,
- },
- }, nil
-}
-
// Get gets the given status, taking account of privacy settings and blocks etc.
func (p *Processor) Get(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) {
- targetStatus, errWithCode := p.c.GetVisibleTargetStatus(ctx,
+ target, errWithCode := p.c.GetVisibleTargetStatus(ctx,
requestingAccount,
targetStatusID,
nil, // default freshness
@@ -67,44 +36,25 @@ func (p *Processor) Get(ctx context.Context, requestingAccount *gtsmodel.Account
if errWithCode != nil {
return nil, errWithCode
}
- return p.c.GetAPIStatus(ctx, requestingAccount, targetStatus)
+ return p.c.GetAPIStatus(ctx, requestingAccount, target)
}
// SourceGet returns the *apimodel.StatusSource version of the targetStatusID.
// Status must belong to the requester, and must not be a boost.
-func (p *Processor) SourceGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*apimodel.StatusSource, gtserror.WithCode) {
- targetStatus, errWithCode := p.c.GetVisibleTargetStatus(ctx,
- requestingAccount,
- targetStatusID,
- nil, // default freshness
- )
+func (p *Processor) SourceGet(ctx context.Context, requester *gtsmodel.Account, statusID string) (*apimodel.StatusSource, gtserror.WithCode) {
+ status, errWithCode := p.c.GetOwnStatus(ctx, requester, statusID)
if errWithCode != nil {
return nil, errWithCode
}
-
- // Redirect to wrapped status if boost.
- targetStatus, errWithCode = p.c.UnwrapIfBoost(
- ctx,
- requestingAccount,
- targetStatus,
- )
- if errWithCode != nil {
- return nil, errWithCode
- }
-
- if targetStatus.AccountID != requestingAccount.ID {
- err := gtserror.Newf(
- "status %s does not belong to account %s",
- targetStatusID, requestingAccount.ID,
+ if status.BoostOfID != "" {
+ return nil, gtserror.NewErrorNotFound(
+ errors.New("status is a boost wrapper"),
+ "target status not found",
)
- return nil, gtserror.NewErrorNotFound(err)
}
-
- statusSource, err := p.converter.StatusToAPIStatusSource(ctx, targetStatus)
- if err != nil {
- err = gtserror.Newf("error converting status: %w", err)
- return nil, gtserror.NewErrorInternalError(err)
- }
-
- return statusSource, nil
+ return &apimodel.StatusSource{
+ ID: status.ID,
+ Text: status.Text,
+ SpoilerText: status.ContentWarning,
+ }, nil
}
diff --git a/internal/processing/workers/fromfediapi.go b/internal/processing/workers/fromfediapi.go
index 0d6ec1836..096e285f6 100644
--- a/internal/processing/workers/fromfediapi.go
+++ b/internal/processing/workers/fromfediapi.go
@@ -762,7 +762,7 @@ func (p *fediAPI) UpdateAccount(ctx context.Context, fMsg *messages.FromFediAPI)
account,
apubAcc,
- // Force refresh within 10s window.
+ // Force refresh within 5s window.
//
// Missing account updates could be
// detrimental to federation if they
@@ -917,17 +917,25 @@ func (p *fediAPI) UpdateStatus(ctx context.Context, fMsg *messages.FromFediAPI)
return gtserror.Newf("cannot cast %T -> *gtsmodel.Status", fMsg.GTSModel)
}
+ var freshness *dereferencing.FreshnessWindow
+
// Cast the updated ActivityPub statusable object .
apStatus, _ := fMsg.APObject.(ap.Statusable)
+ if apStatus != nil {
+ // If an AP object was provided, we
+ // allow very fast refreshes that likely
+ // indicate a status edit after post.
+ freshness = dereferencing.Freshest
+ }
+
// Fetch up-to-date attach status attachments, etc.
status, _, err := p.federate.RefreshStatus(
ctx,
fMsg.Receiving.Username,
existing,
apStatus,
- // Force refresh within 5min window.
- dereferencing.Fresh,
+ freshness,
)
if err != nil {
log.Errorf(ctx, "error refreshing status: %v", err)