diff options
author | 2024-12-23 17:54:44 +0000 | |
---|---|---|
committer | 2024-12-23 17:54:44 +0000 | |
commit | fe8d5f23072c40a407723904eb5c54234879d58a (patch) | |
tree | df80063f3238997de7144932d2d713321164ac1b /internal/processing/status/common.go | |
parent | [chore] Stub /api/v1/announcements implementation (#3630) (diff) | |
download | gotosocial-fe8d5f23072c40a407723904eb5c54234879d58a.tar.xz |
[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
Diffstat (limited to 'internal/processing/status/common.go')
-rw-r--r-- | internal/processing/status/common.go | 351 |
1 files changed, 351 insertions, 0 deletions
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 := >smodel.Poll{ + ID: id.NewULIDFromTime(now), + Multiple: &form.Multiple, + HideCounts: &form.HideTotals, + Options: form.Options, + StatusID: statusID, + ExpiresAt: expiresAt, + } + + // Insert the newly created poll model in the database. + if err := p.state.DB.PutPoll(ctx, poll); err != nil { + err := gtserror.Newf("error inserting poll in db: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return poll, nil +} |