mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2024-12-27 09:36:31 +00:00
fe8d5f2307
* 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
352 lines
10 KiB
Go
352 lines
10 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"
|
|
"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
|
|
}
|