diff options
Diffstat (limited to 'internal/processing/status/edit.go')
-rw-r--r-- | internal/processing/status/edit.go | 555 |
1 files changed, 555 insertions, 0 deletions
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 +} |