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