gotosocial/internal/processing/status/edit.go
kim fe8d5f2307
[feature] add support for clients editing statuses and fetching status revision history (#3628)
* start adding client support for making status edits and viewing history

* modify 'freshest' freshness window to be 5s, add typeutils test for status -> api edits

* only populate the status edits when specifically requested

* start adding some simple processor status edit tests

* add test editing status but adding a poll

* test edits appropriately adding poll expiry handlers

* finish adding status edit tests

* store both new and old revision emojis in status

* add code comment

* ensure the requester's account is populated before status edits

* add code comments for status edit tests

* update status edit form swagger comments

* remove unused function

* fix status source test

* add more code comments, move media description check back to media process in status create

* fix tests, add necessary form struct tag
2024-12-23 17:54:44 +00:00

556 lines
15 KiB
Go

// 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
}