mirror of
https://github.com/superseriousbusiness/gotosocial
synced 2025-06-05 21:59:39 +02:00
[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
This commit is contained in:
351
internal/processing/status/common.go
Normal file
351
internal/processing/status/common.go
Normal file
@ -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
|
||||
}
|
Reference in New Issue
Block a user