mirror of
				https://github.com/superseriousbusiness/gotosocial.git
				synced 2025-10-30 11:42:24 -05:00 
			
		
		
		
	An implementation of [`scheduled_statuses`](https://docs.joinmastodon.org/methods/scheduled_statuses/). Will fix #1006. this is heavily WIP and I need to reorganize some of the code, working on this made me somehow familiar with the codebase and led to my other recent contributions i told some fops on fedi i'd work on this so i have no choice but to complete it 🤷♀️ btw iirc my avatar presents me working on this branch Signed-off-by: nicole mikołajczyk <git@mkljczk.pl> Reviewed-on: https://codeberg.org/superseriousbusiness/gotosocial/pulls/4274 Co-authored-by: nicole mikołajczyk <git@mkljczk.pl> Co-committed-by: nicole mikołajczyk <git@mkljczk.pl>
		
			
				
	
	
		
			378 lines
		
	
	
	
		
			11 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			378 lines
		
	
	
	
		
			11 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 "code.superseriousbusiness.org/gotosocial/internal/api/model"
 | |
| 	"code.superseriousbusiness.org/gotosocial/internal/config"
 | |
| 	"code.superseriousbusiness.org/gotosocial/internal/db"
 | |
| 	"code.superseriousbusiness.org/gotosocial/internal/gtserror"
 | |
| 	"code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
 | |
| 	"code.superseriousbusiness.org/gotosocial/internal/id"
 | |
| 	"code.superseriousbusiness.org/gotosocial/internal/text"
 | |
| 	"code.superseriousbusiness.org/gotosocial/internal/typeutils"
 | |
| 	"code.superseriousbusiness.org/gotosocial/internal/util/xslices"
 | |
| 	"code.superseriousbusiness.org/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
 | |
| }
 | |
| 
 | |
| // Returns the final content type to use when creating or editing a status.
 | |
| func processContentType(
 | |
| 	requestContentType apimodel.StatusContentType,
 | |
| 	existingStatus *gtsmodel.Status,
 | |
| 	accountDefaultContentType string,
 | |
| ) gtsmodel.StatusContentType {
 | |
| 	switch {
 | |
| 	// Content type set in the request, return the new value.
 | |
| 	case requestContentType != "":
 | |
| 		return typeutils.APIContentTypeToContentType(requestContentType)
 | |
| 
 | |
| 	// No content type in the request, return the existing
 | |
| 	// status's current content type if we know of one.
 | |
| 	case existingStatus != nil && existingStatus.ContentType != 0:
 | |
| 		return existingStatus.ContentType
 | |
| 
 | |
| 	// We aren't editing an existing status, or if we are
 | |
| 	// it's an old one that doesn't have a saved content
 | |
| 	// type. Use the user's default content type setting.
 | |
| 	case accountDefaultContentType != "":
 | |
| 		return typeutils.APIContentTypeToContentType(apimodel.StatusContentType(accountDefaultContentType))
 | |
| 
 | |
| 	// uhh.. Fall back to global default.
 | |
| 	default:
 | |
| 		return gtsmodel.StatusContentTypeDefault
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func (p *Processor) processContent(
 | |
| 	ctx context.Context,
 | |
| 	author *gtsmodel.Account,
 | |
| 	statusID string,
 | |
| 	contentType gtsmodel.StatusContentType,
 | |
| 	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,
 | |
| 		)
 | |
| 	}
 | |
| 
 | |
| 	var (
 | |
| 		// format is the currently set text formatting
 | |
| 		// function, according to the provided content-type.
 | |
| 		format text.FormatFunc
 | |
| 		// formatCW is like format, but for content warning.
 | |
| 		formatCW text.FormatFunc
 | |
| 	)
 | |
| 
 | |
| 	switch contentType {
 | |
| 
 | |
| 	// Format status according to text/plain.
 | |
| 	case gtsmodel.StatusContentTypePlain:
 | |
| 		format = p.formatter.FromPlain
 | |
| 		formatCW = p.formatter.FromPlainBasic
 | |
| 
 | |
| 	// Format status according to text/markdown.
 | |
| 	case gtsmodel.StatusContentTypeMarkdown:
 | |
| 		format = p.formatter.FromMarkdown
 | |
| 		formatCW = p.formatter.FromMarkdownBasic
 | |
| 
 | |
| 	// 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
 | |
| 
 | |
| 	// Sanitize content warning and format.
 | |
| 	cwRes := formatInput(formatCW, contentWarning)
 | |
| 
 | |
| 	// Gather results of the formatted.
 | |
| 	status.ContentWarning = cwRes.HTML
 | |
| 	status.Emojis = append(status.Emojis, cwRes.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 {
 | |
| 
 | |
| 			// Strip each poll option and format.
 | |
| 			//
 | |
| 			// For polls just use basic formatting.
 | |
| 			option = text.StripHTMLFromText(option)
 | |
| 			optionRes := formatInput(p.formatter.FromPlainBasic, 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,
 | |
| 	scheduledStatusID *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 && (scheduledStatusID == nil || media.ScheduledStatusID != *scheduledStatusID))) {
 | |
| 			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
 | |
| }
 |