start adding client support for making status edits and viewing history

This commit is contained in:
kim 2024-12-16 13:03:19 +00:00
commit c8a465f9a0
15 changed files with 1544 additions and 431 deletions

View file

@ -83,9 +83,10 @@ func New(processor *processing.Processor) *Module {
}
func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) {
// create / get / delete status
// create / get / edit / delete status
attachHandler(http.MethodPost, BasePath, m.StatusCreatePOSTHandler)
attachHandler(http.MethodGet, BasePathWithID, m.StatusGETHandler)
attachHandler(http.MethodPut, BasePathWithID, m.StatusEditPUTHandler)
attachHandler(http.MethodDelete, BasePathWithID, m.StatusDELETEHandler)
// fave stuff

View file

@ -27,11 +27,9 @@ import (
"github.com/go-playground/form/v4"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/internal/validate"
)
// StatusCreatePOSTHandler swagger:operation POST /api/v1/statuses statusCreate
@ -272,9 +270,9 @@ func (m *Module) StatusCreatePOSTHandler(c *gin.Context) {
return
}
form, err := parseStatusCreateForm(c)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
form, errWithCode := parseStatusCreateForm(c)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
@ -287,11 +285,6 @@ func (m *Module) StatusCreatePOSTHandler(c *gin.Context) {
// }
// form.Status += "\n\nsent from " + user + "'s iphone\n"
if errWithCode := validateStatusCreateForm(form); errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiStatus, errWithCode := m.processor.Status().Create(
c.Request.Context(),
authed.Account,
@ -303,7 +296,7 @@ func (m *Module) StatusCreatePOSTHandler(c *gin.Context) {
return
}
c.JSON(http.StatusOK, apiStatus)
apiutil.JSON(c, http.StatusOK, apiStatus)
}
// intPolicyFormBinding satisfies gin's binding.Binding interface.
@ -328,108 +321,69 @@ func (intPolicyFormBinding) Bind(req *http.Request, obj any) error {
return decoder.Decode(obj, req.Form)
}
func parseStatusCreateForm(c *gin.Context) (*apimodel.StatusCreateRequest, error) {
func parseStatusCreateForm(c *gin.Context) (*apimodel.StatusCreateRequest, gtserror.WithCode) {
form := new(apimodel.StatusCreateRequest)
switch ct := c.ContentType(); ct {
case binding.MIMEJSON:
// Just bind with default json binding.
if err := c.ShouldBindWith(form, binding.JSON); err != nil {
return nil, err
return nil, gtserror.NewErrorBadRequest(
err,
err.Error(),
)
}
case binding.MIMEPOSTForm:
// Bind with default form binding first.
if err := c.ShouldBindWith(form, binding.FormPost); err != nil {
return nil, err
return nil, gtserror.NewErrorBadRequest(
err,
err.Error(),
)
}
// Now do custom binding.
intReqForm := new(apimodel.StatusInteractionPolicyForm)
if err := c.ShouldBindWith(intReqForm, intPolicyFormBinding{}); err != nil {
return nil, err
return nil, gtserror.NewErrorBadRequest(
err,
err.Error(),
)
}
form.InteractionPolicy = intReqForm.InteractionPolicy
case binding.MIMEMultipartPOSTForm:
// Bind with default form binding first.
if err := c.ShouldBindWith(form, binding.FormMultipart); err != nil {
return nil, err
return nil, gtserror.NewErrorBadRequest(
err,
err.Error(),
)
}
// Now do custom binding.
intReqForm := new(apimodel.StatusInteractionPolicyForm)
if err := c.ShouldBindWith(intReqForm, intPolicyFormBinding{}); err != nil {
return nil, err
return nil, gtserror.NewErrorBadRequest(
err,
err.Error(),
)
}
form.InteractionPolicy = intReqForm.InteractionPolicy
default:
err := fmt.Errorf(
"content-type %s not supported for this endpoint; supported content-types are %s, %s, %s",
ct, binding.MIMEJSON, binding.MIMEPOSTForm, binding.MIMEMultipartPOSTForm,
)
return nil, err
}
return form, nil
}
// validateStatusCreateForm checks the form for disallowed
// combinations of attachments, overlength inputs, etc.
//
// Side effect: normalizes the post's language tag.
func validateStatusCreateForm(form *apimodel.StatusCreateRequest) gtserror.WithCode {
var (
chars = len([]rune(form.Status)) + len([]rune(form.SpoilerText))
maxChars = config.GetStatusesMaxChars()
mediaFiles = len(form.MediaIDs)
maxMediaFiles = config.GetStatusesMediaMaxFiles()
hasMedia = mediaFiles != 0
hasPoll = form.Poll != nil
)
if chars == 0 && !hasMedia && !hasPoll {
// Status must contain *some* kind of content.
const text = "no status content, content warning, media, or poll provided"
return gtserror.NewErrorBadRequest(errors.New(text), text)
}
if chars > maxChars {
text := fmt.Sprintf(
"status too long, %d characters provided (including content warning) but limit is %d",
chars, maxChars,
)
return gtserror.NewErrorBadRequest(errors.New(text), text)
}
if mediaFiles > maxMediaFiles {
text := fmt.Sprintf(
"too many media files attached to status, %d attached but limit is %d",
mediaFiles, maxMediaFiles,
)
return gtserror.NewErrorBadRequest(errors.New(text), text)
}
if form.Poll != nil {
if errWithCode := validateStatusPoll(form); errWithCode != nil {
return errWithCode
}
text := fmt.Sprintf("content-type %s not supported for this endpoint; supported content-types are %s, %s, %s",
ct, binding.MIMEJSON, binding.MIMEPOSTForm, binding.MIMEMultipartPOSTForm)
return nil, gtserror.NewErrorNotAcceptable(errors.New(text), text)
}
// Check not scheduled status.
if form.ScheduledAt != "" {
const text = "scheduled_at is not yet implemented"
return gtserror.NewErrorNotImplemented(errors.New(text), text)
}
// Validate + normalize
// language tag if provided.
if form.Language != "" {
lang, err := validate.Language(form.Language)
if err != nil {
return gtserror.NewErrorBadRequest(err, err.Error())
}
form.Language = lang
return nil, gtserror.NewErrorNotImplemented(errors.New(text), text)
}
// Check if the deprecated "federated" field was
@ -438,42 +392,9 @@ func validateStatusCreateForm(form *apimodel.StatusCreateRequest) gtserror.WithC
form.LocalOnly = util.Ptr(!*form.Federated) // nolint:staticcheck
}
return nil
}
// Normalize poll expiry time if a poll was given.
if form.Poll != nil && form.Poll.ExpiresInI != nil {
func validateStatusPoll(form *apimodel.StatusCreateRequest) gtserror.WithCode {
var (
maxPollOptions = config.GetStatusesPollMaxOptions()
pollOptions = len(form.Poll.Options)
maxPollOptionChars = config.GetStatusesPollOptionMaxChars()
)
if pollOptions == 0 {
const text = "poll with no options"
return gtserror.NewErrorBadRequest(errors.New(text), text)
}
if pollOptions > maxPollOptions {
text := fmt.Sprintf(
"too many poll options provided, %d provided but limit is %d",
pollOptions, maxPollOptions,
)
return gtserror.NewErrorBadRequest(errors.New(text), text)
}
for _, option := range form.Poll.Options {
optionChars := len([]rune(option))
if optionChars > maxPollOptionChars {
text := fmt.Sprintf(
"poll option too long, %d characters provided but limit is %d",
optionChars, maxPollOptionChars,
)
return gtserror.NewErrorBadRequest(errors.New(text), text)
}
}
// Normalize poll expiry if necessary.
if form.Poll.ExpiresInI != nil {
// If we parsed this as JSON, expires_in
// may be either a float64 or a string.
expiresIn, err := apiutil.ParseDuration(
@ -481,13 +402,10 @@ func validateStatusPoll(form *apimodel.StatusCreateRequest) gtserror.WithCode {
"expires_in",
)
if err != nil {
return gtserror.NewErrorBadRequest(err, err.Error())
}
if expiresIn != nil {
form.Poll.ExpiresIn = *expiresIn
return nil, gtserror.NewErrorBadRequest(err, err.Error())
}
form.Poll.ExpiresIn = util.PtrOrZero(expiresIn)
}
return nil
return form, nil
}

View file

@ -95,5 +95,5 @@ func (m *Module) StatusDELETEHandler(c *gin.Context) {
return
}
c.JSON(http.StatusOK, apiStatus)
apiutil.JSON(c, http.StatusOK, apiStatus)
}

View file

@ -0,0 +1,249 @@
// 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 statuses
import (
"errors"
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
// StatusEditPUTHandler swagger:operation PUT /api/v1/statuses statusEdit
//
// Edit an existing status using the given form field parameters.
//
// The parameters can also be given in the body of the request, as JSON, if the content-type is set to 'application/json'.
//
// ---
// tags:
// - statuses
//
// consumes:
// - application/json
// - application/x-www-form-urlencoded
//
// parameters:
// -
// name: status
// x-go-name: Status
// description: |-
// Text content of the status.
// If media_ids is provided, this becomes optional.
// Attaching a poll is optional while status is provided.
// type: string
// in: formData
// -
// name: media_ids
// x-go-name: MediaIDs
// description: |-
// Array of Attachment ids to be attached as media.
// If provided, status becomes optional, and poll cannot be used.
//
// If the status is being submitted as a form, the key is 'media_ids[]',
// but if it's json or xml, the key is 'media_ids'.
// type: array
// items:
// type: string
// in: formData
// -
// name: poll[options][]
// x-go-name: PollOptions
// description: |-
// Array of possible poll answers.
// If provided, media_ids cannot be used, and poll[expires_in] must be provided.
// type: array
// items:
// type: string
// in: formData
// -
// name: poll[expires_in]
// x-go-name: PollExpiresIn
// description: |-
// Duration the poll should be open, in seconds.
// If provided, media_ids cannot be used, and poll[options] must be provided.
// type: integer
// format: int64
// in: formData
// -
// name: poll[multiple]
// x-go-name: PollMultiple
// description: Allow multiple choices on this poll.
// type: boolean
// default: false
// in: formData
// -
// name: poll[hide_totals]
// x-go-name: PollHideTotals
// description: Hide vote counts until the poll ends.
// type: boolean
// default: true
// in: formData
// -
// name: sensitive
// x-go-name: Sensitive
// description: Status and attached media should be marked as sensitive.
// type: boolean
// in: formData
// -
// name: spoiler_text
// x-go-name: SpoilerText
// description: |-
// Text to be shown as a warning or subject before the actual content.
// Statuses are generally collapsed behind this field.
// type: string
// in: formData
// -
// name: language
// x-go-name: Language
// description: ISO 639 language code for this status.
// type: string
// in: formData
// -
// name: content_type
// x-go-name: ContentType
// description: Content type to use when parsing this status.
// type: string
// enum:
// - text/plain
// - text/markdown
// in: formData
//
// produces:
// - application/json
//
// security:
// - OAuth2 Bearer:
// - write:statuses
//
// responses:
// '200':
// description: "The latest status revision."
// schema:
// "$ref": "#/definitions/status"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '403':
// description: forbidden
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) StatusEditPUTHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
form, errWithCode := parseStatusEditForm(c)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiStatus, errWithCode := m.processor.Status().Edit(
c.Request.Context(),
authed.Account,
c.Param(IDKey),
form,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiutil.JSON(c, http.StatusOK, apiStatus)
}
func parseStatusEditForm(c *gin.Context) (*apimodel.StatusEditRequest, gtserror.WithCode) {
form := new(apimodel.StatusEditRequest)
switch ct := c.ContentType(); ct {
case binding.MIMEJSON:
// Just bind with default json binding.
if err := c.ShouldBindWith(form, binding.JSON); err != nil {
return nil, gtserror.NewErrorBadRequest(
err,
err.Error(),
)
}
case binding.MIMEPOSTForm:
// Bind with default form binding first.
if err := c.ShouldBindWith(form, binding.FormPost); err != nil {
return nil, gtserror.NewErrorBadRequest(
err,
err.Error(),
)
}
case binding.MIMEMultipartPOSTForm:
// Bind with default form binding first.
if err := c.ShouldBindWith(form, binding.FormMultipart); err != nil {
return nil, gtserror.NewErrorBadRequest(
err,
err.Error(),
)
}
default:
text := fmt.Sprintf("content-type %s not supported for this endpoint; supported content-types are %s, %s, %s",
ct, binding.MIMEJSON, binding.MIMEPOSTForm, binding.MIMEMultipartPOSTForm)
return nil, gtserror.NewErrorNotAcceptable(errors.New(text), text)
}
// Normalize poll expiry time if a poll was given.
if form.Poll != nil && form.Poll.ExpiresInI != nil {
// If we parsed this as JSON, expires_in
// may be either a float64 or a string.
expiresIn, err := apiutil.ParseDuration(
form.Poll.ExpiresInI,
"expires_in",
)
if err != nil {
return nil, gtserror.NewErrorBadRequest(err, err.Error())
}
form.Poll.ExpiresIn = util.PtrOrZero(expiresIn)
}
return form, nil
}

View file

@ -23,12 +23,15 @@ import "mime/multipart"
//
// swagger: ignore
type AttachmentRequest struct {
// Media file.
File *multipart.FileHeader `form:"file" binding:"required"`
// Description of the media file. Optional.
// This will be used as alt-text for users of screenreaders etc.
// example: This is an image of some kittens, they are very cute and fluffy.
Description string `form:"description"`
// Focus of the media file. Optional.
// If present, it should be in the form of two comma-separated floats between -1 and 1.
// example: -0.5,0.565
@ -39,16 +42,38 @@ type AttachmentRequest struct {
//
// swagger:ignore
type AttachmentUpdateRequest struct {
// Description of the media file.
// This will be used as alt-text for users of screenreaders etc.
// allowEmptyValue: true
Description *string `form:"description" json:"description" xml:"description"`
// Focus of the media file.
// If present, it should be in the form of two comma-separated floats between -1 and 1.
// allowEmptyValue: true
Focus *string `form:"focus" json:"focus" xml:"focus"`
}
// AttachmentAttributesRequest models an edit request for a attachment attributes.
//
// swagger:ignore
type AttachmentAttributesRequest struct {
// The ID of the attachment.
// example: 01FC31DZT1AYWDZ8XTCRWRBYRK
ID string `json:"id"`
// Description of the media file.
// This will be used as alt-text for users of screenreaders etc.
// allowEmptyValue: true
Description string `form:"description" json:"description"`
// Focus of the media file.
// If present, it should be in the form of two comma-separated floats between -1 and 1.
// allowEmptyValue: true
Focus string `form:"focus" json:"focus"`
}
// Attachment models a media attachment.
//
// swagger:model attachment

View file

@ -197,36 +197,50 @@ type StatusReblogged struct {
//
// swagger:ignore
type StatusCreateRequest struct {
// Text content of the status.
// If media_ids is provided, this becomes optional.
// Attaching a poll is optional while status is provided.
Status string `form:"status" json:"status"`
// Array of Attachment ids to be attached as media.
// If provided, status becomes optional, and poll cannot be used.
MediaIDs []string `form:"media_ids[]" json:"media_ids"`
// Poll to include with this status.
Poll *PollRequest `form:"poll" json:"poll"`
// ID of the status being replied to, if status is a reply.
InReplyToID string `form:"in_reply_to_id" json:"in_reply_to_id"`
// Status and attached media should be marked as sensitive.
Sensitive bool `form:"sensitive" json:"sensitive"`
// Text to be shown as a warning or subject before the actual content.
// Statuses are generally collapsed behind this field.
SpoilerText string `form:"spoiler_text" json:"spoiler_text"`
// Visibility of the posted status.
Visibility Visibility `form:"visibility" json:"visibility"`
// Set to "true" if this status should not be federated, ie. it should be a "local only" status.
// Set to "true" if this status should not be
// federated,ie. it should be a "local only" status.
LocalOnly *bool `form:"local_only" json:"local_only"`
// Deprecated: Only used if LocalOnly is not set.
Federated *bool `form:"federated" json:"federated"`
// ISO 8601 Datetime at which to schedule a status.
// Providing this parameter will cause ScheduledStatus to be returned instead of Status.
// Must be at least 5 minutes in the future.
ScheduledAt string `form:"scheduled_at" json:"scheduled_at"`
// ISO 639 language code for this status.
Language string `form:"language" json:"language"`
// Content type to use when parsing this status.
ContentType StatusContentType `form:"content_type" json:"content_type"`
// Interaction policy to use for this status.
InteractionPolicy *InteractionPolicy `form:"-" json:"interaction_policy"`
}
@ -236,6 +250,7 @@ type StatusCreateRequest struct {
//
// swagger:ignore
type StatusInteractionPolicyForm struct {
// Interaction policy to use for this status.
InteractionPolicy *InteractionPolicy `form:"interaction_policy" json:"-"`
}
@ -250,13 +265,18 @@ const (
// VisibilityNone is visible to nobody. This is only used for the visibility of web statuses.
VisibilityNone Visibility = "none"
// VisibilityPublic is visible to everyone, and will be available via the web even for nonauthenticated users.
VisibilityPublic Visibility = "public"
// VisibilityUnlisted is visible to everyone, but only on home timelines, lists, etc.
VisibilityUnlisted Visibility = "unlisted"
// VisibilityPrivate is visible only to followers of the account that posted the status.
VisibilityPrivate Visibility = "private"
// VisibilityMutualsOnly is visible only to mutual followers of the account that posted the status.
VisibilityMutualsOnly Visibility = "mutuals_only"
// VisibilityDirect is visible only to accounts tagged in the status. It is equivalent to a direct message.
VisibilityDirect Visibility = "direct"
)
@ -268,7 +288,8 @@ const (
// swagger:type string
type StatusContentType string
// Content type to use when parsing submitted status into an html-formatted status
// Content type to use when parsing submitted
// status into an html-formatted status.
const (
StatusContentTypePlain StatusContentType = "text/plain"
StatusContentTypeMarkdown StatusContentType = "text/markdown"
@ -280,11 +301,14 @@ const (
//
// swagger:model statusSource
type StatusSource struct {
// ID of the status.
// example: 01FBVD42CQ3ZEEVMW180SBX03B
ID string `json:"id"`
// Plain-text source of a status.
Text string `json:"text"`
// Plain-text version of spoiler text.
SpoilerText string `json:"spoiler_text"`
}
@ -294,27 +318,69 @@ type StatusSource struct {
//
// swagger:model statusEdit
type StatusEdit struct {
// The content of this status at this revision.
// Should be HTML, but might also be plaintext in some cases.
// example: <p>Hey this is a status!</p>
Content string `json:"content"`
// Subject, summary, or content warning for the status at this revision.
// example: warning nsfw
SpoilerText string `json:"spoiler_text"`
// Status marked sensitive at this revision.
// example: false
Sensitive bool `json:"sensitive"`
// The date when this revision was created (ISO 8601 Datetime).
// example: 2021-07-30T09:20:25+00:00
CreatedAt string `json:"created_at"`
// The account that authored this status.
Account *Account `json:"account"`
// The poll attached to the status at this revision.
// Note that edits changing the poll options will be collapsed together into one edit, since this action resets the poll.
// nullable: true
Poll *Poll `json:"poll"`
// Media that is attached to this status.
MediaAttachments []*Attachment `json:"media_attachments"`
// Custom emoji to be used when rendering status content.
Emojis []Emoji `json:"emojis"`
}
// StatusEditRequest models status edit parameters.
//
// swagger:ignore
type StatusEditRequest struct {
// Text content of the status.
// If media_ids is provided, this becomes optional.
// Attaching a poll is optional while status is provided.
Status string `form:"status" json:"status"`
// Text to be shown as a warning or subject before the actual content.
// Statuses are generally collapsed behind this field.
SpoilerText string `form:"spoiler_text" json:"spoiler_text"`
// Content type to use when parsing this status.
ContentType StatusContentType `form:"content_type" json:"content_type"`
// Status and attached media should be marked as sensitive.
Sensitive bool `form:"sensitive" json:"sensitive"`
// ISO 639 language code for this status.
Language string `form:"language" json:"language"`
// Array of Attachment ids to be attached as media.
// If provided, status becomes optional, and poll cannot be used.
MediaIDs []string `form:"media_ids[]" json:"media_ids"`
// ...
MediaAttributes []AttachmentAttributesRequest `form:"media_attributes[]" json:"media_attributes"`
// Poll to include with this status.
Poll *PollRequest `form:"poll" json:"poll"`
}

View file

@ -35,6 +35,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/media"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/internal/util/xslices"
)
// statusFresh returns true if the given status is still
@ -1000,12 +1001,21 @@ func (d *Dereferencer) fetchStatusEmojis(
// Set latest emojis.
status.Emojis = emojis
// Iterate over and set changed emoji IDs.
// Extract IDs from latest slice of emojis.
status.EmojiIDs = make([]string, len(emojis))
for i, emoji := range emojis {
status.EmojiIDs[i] = emoji.ID
}
// Combine both old and new emojis, as statuses.emojis
// keeps track of emojis for both old and current edits.
status.EmojiIDs = append(status.EmojiIDs, existing.EmojiIDs...)
status.Emojis = append(status.Emojis, existing.Emojis...)
status.EmojiIDs = xslices.Deduplicate(status.EmojiIDs)
status.Emojis = xslices.DeduplicateFunc(status.Emojis,
func(e *gtsmodel.Emoji) string { return e.ID },
)
return true, nil
}
@ -1118,10 +1128,10 @@ func (d *Dereferencer) handleStatusEdit(
var edited bool
// Preallocate max slice length.
cols = make([]string, 0, 13)
cols = make([]string, 1, 13)
// Always update `fetched_at`.
cols = append(cols, "fetched_at")
cols[0] = "fetched_at"
// Check for edited status content.
if existing.Content != status.Content {
@ -1230,7 +1240,8 @@ func (d *Dereferencer) handleStatusEdit(
// Poll only set if existing contained them.
edit.PollOptions = existing.Poll.Options
if !*existing.Poll.HideCounts || pollChanged {
if pollChanged || !*existing.Poll.HideCounts ||
!existing.Poll.ClosedAt.IsZero() {
// If the counts are allowed to be
// shown, or poll has changed, then
// include poll vote counts in edit.

View file

@ -27,6 +27,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
@ -42,7 +43,7 @@ func (p *Processor) RulesGet(
apiRules := make([]*apimodel.AdminInstanceRule, len(rules))
for i := range rules {
apiRules[i] = p.converter.InstanceRuleToAdminAPIRule(&rules[i])
apiRules[i] = typeutils.InstanceRuleToAdminAPIRule(&rules[i])
}
return apiRules, nil
@ -58,7 +59,7 @@ func (p *Processor) RuleGet(ctx context.Context, id string) (*apimodel.AdminInst
return nil, gtserror.NewErrorInternalError(err)
}
return p.converter.InstanceRuleToAdminAPIRule(rule), nil
return typeutils.InstanceRuleToAdminAPIRule(rule), nil
}
// RuleCreate adds a new rule to the instance.
@ -77,7 +78,7 @@ func (p *Processor) RuleCreate(ctx context.Context, form *apimodel.InstanceRuleC
return nil, gtserror.NewErrorInternalError(err)
}
return p.converter.InstanceRuleToAdminAPIRule(rule), nil
return typeutils.InstanceRuleToAdminAPIRule(rule), nil
}
// RuleUpdate updates text for an existing rule.
@ -99,7 +100,7 @@ func (p *Processor) RuleUpdate(ctx context.Context, id string, form *apimodel.In
return nil, gtserror.NewErrorInternalError(err)
}
return p.converter.InstanceRuleToAdminAPIRule(updatedRule), nil
return typeutils.InstanceRuleToAdminAPIRule(updatedRule), nil
}
// RuleDelete deletes an existing rule.
@ -120,5 +121,5 @@ func (p *Processor) RuleDelete(ctx context.Context, id string) (*apimodel.AdminI
return nil, gtserror.NewErrorInternalError(err)
}
return p.converter.InstanceRuleToAdminAPIRule(deletedRule), nil
return typeutils.InstanceRuleToAdminAPIRule(deletedRule), nil
}

View file

@ -31,6 +31,47 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/log"
)
// GetOwnStatus ...
func (p *Processor) GetOwnStatus(
ctx context.Context,
requester *gtsmodel.Account,
targetID string,
) (
*gtsmodel.Status,
gtserror.WithCode,
) {
target, err := p.state.DB.GetStatusByID(ctx, targetID)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("error getting from db: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
if target == nil {
const text = "target status not found"
return nil, gtserror.NewErrorNotFound(
errors.New(text),
text,
)
}
switch {
case target == nil:
const text = "target status not found"
return nil, gtserror.NewErrorNotFound(
errors.New(text),
text,
)
case target.AccountID != requester.ID:
return nil, gtserror.NewErrorNotFound(
errors.New("status does not belong to requester"),
"target status not found",
)
}
return target, nil
}
// GetTargetStatusBy fetches the target status with db load
// function, given the authorized (or, nil) requester's
// account. This returns an approprate gtserror.WithCode

View file

@ -29,6 +29,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/text"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/internal/validate"
)
@ -133,7 +134,7 @@ func (p *Processor) InstanceGetRules(ctx context.Context) ([]apimodel.InstanceRu
return nil, gtserror.NewErrorInternalError(fmt.Errorf("db error fetching instance: %s", err))
}
return p.converter.InstanceRulesToAPIRules(i.Rules), nil
return typeutils.InstanceRulesToAPIRules(i.Rules), nil
}
func (p *Processor) InstancePatch(ctx context.Context, form *apimodel.InstanceSettingsUpdateRequest) (*apimodel.InstanceV1, gtserror.WithCode) {

View file

@ -0,0 +1,305 @@
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"
)
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
}
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
}
// 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
}
// 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)
}
// 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 := &gtsmodel.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
}

View file

@ -19,29 +19,22 @@ package status
import (
"context"
"errors"
"fmt"
"time"
"github.com/superseriousbusiness/gotosocial/internal/ap"
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/log"
"github.com/superseriousbusiness/gotosocial/internal/messages"
"github.com/superseriousbusiness/gotosocial/internal/text"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
"github.com/superseriousbusiness/gotosocial/internal/uris"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/internal/util/xslices"
)
// Create processes the given form to create a new status, returning the api model representation of that status if it's OK.
//
// Precondition: the form's fields should have already been validated and normalized by the caller.
// Note this also handles validation of incoming form field data.
func (p *Processor) Create(
ctx context.Context,
requester *gtsmodel.Account,
@ -51,6 +44,16 @@ func (p *Processor) Create(
*apimodel.Status,
gtserror.WithCode,
) {
// Validate incoming form status content.
if errWithCode := validateStatusContent(
form.Status,
form.SpoilerText,
form.MediaIDs,
form.Poll,
); errWithCode != nil {
return nil, errWithCode
}
// Ensure account populated; we'll need settings.
if err := p.state.DB.PopulateAccount(ctx, requester); err != nil {
log.Errorf(ctx, "error(s) populating account, will continue: %s", err)
@ -59,6 +62,30 @@ func (p *Processor) Create(
// Generate new ID for status.
statusID := id.NewULID()
// Process incoming status 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 incoming status attachments.
media, errWithCode := p.processMedia(ctx,
requester.ID,
statusID,
form.MediaIDs,
)
if errWithCode != nil {
return nil, errWithCode
}
// Generate necessary URIs for username, to build status URIs.
accountURIs := uris.GenerateURIsForAccount(requester.Username)
@ -78,16 +105,36 @@ func (p *Processor) Create(
ActivityStreamsType: ap.ObjectNote,
Sensitive: &form.Sensitive,
CreatedWithApplicationID: application.ID,
Text: form.Status,
// Set validated language.
Language: content.Language,
// Set formatted status content.
Content: content.Content,
ContentWarning: content.ContentWarning,
Text: form.Status, // raw
// Set gathered mentions.
MentionIDs: content.MentionIDs,
Mentions: content.Mentions,
// Set gathered emojis.
EmojiIDs: content.EmojiIDs,
Emojis: content.Emojis,
// Set gathered tags.
TagIDs: content.TagIDs,
Tags: content.Tags,
// Set gathered media.
AttachmentIDs: form.MediaIDs,
Attachments: media,
// Assume not pending approval; this may
// change when permissivity is checked.
PendingApproval: util.Ptr(false),
}
// Process any attached poll.
p.processPoll(status, form.Poll)
// Check + attach in-reply-to status.
if errWithCode := p.processInReplyTo(ctx,
requester,
@ -101,10 +148,6 @@ func (p *Processor) Create(
return nil, errWithCode
}
if errWithCode := p.processMediaIDs(ctx, form, requester.ID, status); errWithCode != nil {
return nil, errWithCode
}
if err := p.processVisibility(ctx, form, requester.Settings.Privacy, status); err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
@ -115,36 +158,49 @@ func (p *Processor) Create(
return nil, errWithCode
}
if err := processLanguage(form, requester.Settings.Language, status); err != nil {
return nil, gtserror.NewErrorInternalError(err)
if status.ContentWarning != "" && len(status.AttachmentIDs) > 0 {
// If a content-warning is set, and
// the status contains media, always
// set the status sensitive flag.
status.Sensitive = util.Ptr(true)
}
if err := p.processContent(ctx, p.parseMention, form, status); err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
if status.Poll != nil {
// Try to insert the new status poll in the database.
if err := p.state.DB.PutPoll(ctx, status.Poll); err != nil {
err := gtserror.Newf("error inserting poll in db: %w", err)
return nil, gtserror.NewErrorInternalError(err)
if form.Poll != nil {
// Process poll, inserting into database.
poll, errWithCode := p.processPoll(ctx,
statusID,
form.Poll,
now,
)
if errWithCode != nil {
return nil, errWithCode
}
// Set poll and its ID
// on status before insert.
status.PollID = poll.ID
status.Poll = poll
poll.Status = status
// Update the status' ActivityPub type to Question.
status.ActivityStreamsType = ap.ActivityQuestion
}
// Insert this new status in the database.
// Insert this newly prepared status into the database.
if err := p.state.DB.PutStatus(ctx, status); err != nil {
err := gtserror.Newf("error inserting status in db: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
if status.Poll != nil && !status.Poll.ExpiresAt.IsZero() {
// Now that the status is inserted, and side effects queued,
// attempt to schedule an expiry handler for the status poll.
// Now that the status is inserted, attempt to
// schedule an expiry handler for the status poll.
if err := p.polls.ScheduleExpiry(ctx, status.Poll); err != nil {
log.Errorf(ctx, "error scheduling poll expiry: %v", err)
}
}
// send it back to the client API worker for async side-effects.
// Send it to the client API worker for async side-effects.
p.state.Workers.Client.Queue.Push(&messages.FromClientAPI{
APObjectType: ap.ObjectNote,
APActivityType: ap.ActivityCreate,
@ -172,43 +228,6 @@ func (p *Processor) Create(
return p.c.GetAPIStatus(ctx, requester, status)
}
func (p *Processor) processPoll(status *gtsmodel.Status, poll *apimodel.PollRequest) {
if poll == nil {
// No poll set.
// Nothing to do.
return
}
var expiresAt time.Time
// Now will have been set
// as the status creation.
now := status.CreatedAt
// Update the status AS type to "Question".
status.ActivityStreamsType = ap.ActivityQuestion
// Set an expiry time if one given.
if in := poll.ExpiresIn; in > 0 {
expiresIn := time.Duration(in)
expiresAt = now.Add(expiresIn * time.Second)
}
// Create new poll for status.
status.Poll = &gtsmodel.Poll{
ID: id.NewULID(),
Multiple: &poll.Multiple,
HideCounts: &poll.HideTotals,
Options: poll.Options,
StatusID: status.ID,
Status: status,
ExpiresAt: expiresAt,
}
// Set poll ID on the status.
status.PollID = status.Poll.ID
}
func (p *Processor) processInReplyTo(ctx context.Context, requester *gtsmodel.Account, status *gtsmodel.Status, inReplyToID string) gtserror.WithCode {
if inReplyToID == "" {
// Not a reply.
@ -332,53 +351,6 @@ func (p *Processor) processThreadID(ctx context.Context, status *gtsmodel.Status
return nil
}
func (p *Processor) processMediaIDs(ctx context.Context, form *apimodel.StatusCreateRequest, thisAccountID string, status *gtsmodel.Status) gtserror.WithCode {
if form.MediaIDs == nil {
return nil
}
// Get minimum allowed char descriptions.
minChars := config.GetMediaDescriptionMinChars()
attachments := []*gtsmodel.MediaAttachment{}
attachmentIDs := []string{}
for _, mediaID := range form.MediaIDs {
attachment, err := p.state.DB.GetAttachmentByID(ctx, mediaID)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("error fetching media from db: %w", err)
return gtserror.NewErrorInternalError(err)
}
if attachment == nil {
text := fmt.Sprintf("media %s not found", mediaID)
return gtserror.NewErrorBadRequest(errors.New(text), text)
}
if attachment.AccountID != thisAccountID {
text := fmt.Sprintf("media %s does not belong to account", mediaID)
return gtserror.NewErrorBadRequest(errors.New(text), text)
}
if attachment.StatusID != "" || attachment.ScheduledStatusID != "" {
text := fmt.Sprintf("media %s already attached to status", mediaID)
return gtserror.NewErrorBadRequest(errors.New(text), text)
}
if length := len([]rune(attachment.Description)); length < minChars {
text := fmt.Sprintf("media %s description too short, at least %d required", mediaID, minChars)
return gtserror.NewErrorBadRequest(errors.New(text), text)
}
attachments = append(attachments, attachment)
attachmentIDs = append(attachmentIDs, attachment.ID)
}
status.Attachments = attachments
status.AttachmentIDs = attachmentIDs
return nil
}
func (p *Processor) processVisibility(
ctx context.Context,
form *apimodel.StatusCreateRequest,
@ -474,99 +446,3 @@ func processInteractionPolicy(
// setting it explicitly to save space.
return nil
}
func processLanguage(form *apimodel.StatusCreateRequest, accountDefaultLanguage string, status *gtsmodel.Status) error {
if form.Language != "" {
status.Language = form.Language
} else {
status.Language = accountDefaultLanguage
}
if status.Language == "" {
return errors.New("no language given either in status create form or account default")
}
return nil
}
func (p *Processor) processContent(ctx context.Context, parseMention gtsmodel.ParseMentionFunc, form *apimodel.StatusCreateRequest, status *gtsmodel.Status) error {
if form.ContentType == "" {
// If content type wasn't specified, use the author's preferred content-type.
contentType := apimodel.StatusContentType(status.Account.Settings.StatusContentType)
form.ContentType = contentType
}
// format is the currently set text formatting
// function, according to the provided content-type.
var format text.FormatFunc
// 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, parseMention, status.AccountID, status.ID, input)
}
switch form.ContentType {
// None given / set,
// use default (plain).
case "":
fallthrough
// Format status according to text/plain.
case apimodel.StatusContentTypePlain:
format = p.formatter.FromPlain
// Format status according to text/markdown.
case apimodel.StatusContentTypeMarkdown:
format = p.formatter.FromMarkdown
// Unknown.
default:
return fmt.Errorf("invalid status format: %q", form.ContentType)
}
// Sanitize status text and format.
contentRes := formatInput(format, form.Status)
// Collect formatted results.
status.Content = contentRes.HTML
status.Mentions = append(status.Mentions, contentRes.Mentions...)
status.Emojis = append(status.Emojis, contentRes.Emojis...)
status.Tags = append(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.
spoiler := text.SanitizeToPlaintext(form.SpoilerText)
warningRes := formatInput(format, spoiler)
// Collect formatted results.
status.ContentWarning = warningRes.HTML
status.Emojis = append(status.Emojis, warningRes.Emojis...)
if status.Poll != nil {
for i := range status.Poll.Options {
// Sanitize each option title name and format.
option := text.SanitizeToPlaintext(status.Poll.Options[i])
optionRes := formatInput(format, option)
// Collect each formatted result.
status.Poll.Options[i] = optionRes.HTML
status.Emojis = append(status.Emojis, optionRes.Emojis...)
}
}
// Gather all the database IDs from each of the gathered status mentions, tags, and emojis.
status.MentionIDs = xslices.Gather(nil, status.Mentions, func(mention *gtsmodel.Mention) string { return mention.ID })
status.TagIDs = xslices.Gather(nil, status.Tags, func(tag *gtsmodel.Tag) string { return tag.ID })
status.EmojiIDs = xslices.Gather(nil, status.Emojis, func(emoji *gtsmodel.Emoji) string { return emoji.ID })
if status.ContentWarning != "" && len(status.AttachmentIDs) > 0 {
// If a content-warning is set, and
// the status contains media, always
// set the status sensitive flag.
status.Sensitive = util.Ptr(true)
}
return nil
}

View file

@ -0,0 +1,572 @@
// 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"
"slices"
"strconv"
"strings"
"time"
"github.com/superseriousbusiness/gotosocial/internal/ap"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"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"
)
// 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",
)
}
// 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) {
// Update attached status emojis.
cols = append(cols, "emojis")
status.EmojiIDs = content.EmojiIDs
status.Emojis = content.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 = now
// 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
}
edits, err := p.converter.StatusToAPIEdits(ctx, target)
if err != nil {
err := gtserror.Newf("error converting status: %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 string request.
fx, fy, errWithCode := 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
}
func parseFocus(focus string) (focusx, focusy float32, errWithCode gtserror.WithCode) {
if focus == "" {
return
}
spl := strings.Split(focus, ",")
if len(spl) != 2 {
const text = "missing comma separator"
errWithCode = gtserror.NewErrorBadRequest(
errors.New(text),
text,
)
return
}
xStr := spl[0]
yStr := spl[1]
fx, err := strconv.ParseFloat(xStr, 32)
if err != nil || fx > 1 || fx < -1 {
text := fmt.Sprintf("invalid x focus: %s", xStr)
errWithCode = gtserror.NewErrorBadRequest(
errors.New(text),
text,
)
return
}
fy, err := strconv.ParseFloat(yStr, 32)
if err != nil || fy > 1 || fy < -1 {
text := fmt.Sprintf("invalid y focus: %s", xStr)
errWithCode = gtserror.NewErrorBadRequest(
errors.New(text),
text,
)
return
}
focusx = float32(fx)
focusy = float32(fy)
return
}

View file

@ -19,47 +19,16 @@ package status
import (
"context"
"errors"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
// HistoryGet gets edit history for the target status, taking account of privacy settings and blocks etc.
// TODO: currently this just returns the latest version of the status.
func (p *Processor) HistoryGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) ([]*apimodel.StatusEdit, gtserror.WithCode) {
targetStatus, errWithCode := p.c.GetVisibleTargetStatus(ctx,
requestingAccount,
targetStatusID,
nil, // default freshness
)
if errWithCode != nil {
return nil, errWithCode
}
apiStatus, errWithCode := p.c.GetAPIStatus(ctx, requestingAccount, targetStatus)
if errWithCode != nil {
return nil, errWithCode
}
return []*apimodel.StatusEdit{
{
Content: apiStatus.Content,
SpoilerText: apiStatus.SpoilerText,
Sensitive: apiStatus.Sensitive,
CreatedAt: util.FormatISO8601(targetStatus.UpdatedAt),
Account: apiStatus.Account,
Poll: apiStatus.Poll,
MediaAttachments: apiStatus.MediaAttachments,
Emojis: apiStatus.Emojis,
},
}, nil
}
// Get gets the given status, taking account of privacy settings and blocks etc.
func (p *Processor) Get(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) {
targetStatus, errWithCode := p.c.GetVisibleTargetStatus(ctx,
target, errWithCode := p.c.GetVisibleTargetStatus(ctx,
requestingAccount,
targetStatusID,
nil, // default freshness
@ -67,44 +36,25 @@ func (p *Processor) Get(ctx context.Context, requestingAccount *gtsmodel.Account
if errWithCode != nil {
return nil, errWithCode
}
return p.c.GetAPIStatus(ctx, requestingAccount, targetStatus)
return p.c.GetAPIStatus(ctx, requestingAccount, target)
}
// SourceGet returns the *apimodel.StatusSource version of the targetStatusID.
// Status must belong to the requester, and must not be a boost.
func (p *Processor) SourceGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*apimodel.StatusSource, gtserror.WithCode) {
targetStatus, errWithCode := p.c.GetVisibleTargetStatus(ctx,
requestingAccount,
targetStatusID,
nil, // default freshness
)
func (p *Processor) SourceGet(ctx context.Context, requester *gtsmodel.Account, statusID string) (*apimodel.StatusSource, gtserror.WithCode) {
status, errWithCode := p.c.GetOwnStatus(ctx, requester, statusID)
if errWithCode != nil {
return nil, errWithCode
}
// Redirect to wrapped status if boost.
targetStatus, errWithCode = p.c.UnwrapIfBoost(
ctx,
requestingAccount,
targetStatus,
)
if errWithCode != nil {
return nil, errWithCode
}
if targetStatus.AccountID != requestingAccount.ID {
err := gtserror.Newf(
"status %s does not belong to account %s",
targetStatusID, requestingAccount.ID,
if status.BoostOfID != "" {
return nil, gtserror.NewErrorNotFound(
errors.New("status is a boost wrapper"),
"target status not found",
)
return nil, gtserror.NewErrorNotFound(err)
}
statusSource, err := p.converter.StatusToAPIStatusSource(ctx, targetStatus)
if err != nil {
err = gtserror.Newf("error converting status: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
return statusSource, nil
return &apimodel.StatusSource{
ID: status.ID,
Text: status.Text,
SpoilerText: status.ContentWarning,
}, nil
}

View file

@ -1216,21 +1216,6 @@ func (c *Converter) StatusToWebStatus(
return webStatus, nil
}
// StatusToAPIStatusSource returns the *apimodel.StatusSource of the given status.
// Callers should check beforehand whether a requester has permission to view the
// source of the status, and ensure they're passing only a local status into this function.
func (c *Converter) StatusToAPIStatusSource(ctx context.Context, s *gtsmodel.Status) (*apimodel.StatusSource, error) {
// TODO: remove this when edit support is added.
text := "**STATUS EDITS ARE NOT CURRENTLY SUPPORTED IN GOTOSOCIAL (coming in 2024)**\n" +
"You can review the original text of your status below, but you will not be able to submit this edit.\n\n---\n\n" + s.Text
return &apimodel.StatusSource{
ID: s.ID,
Text: text,
SpoilerText: s.ContentWarning,
}, nil
}
// statusToFrontend is a package internal function for
// parsing a status into its initial frontend representation.
//
@ -1472,6 +1457,120 @@ func (c *Converter) baseStatusToFrontend(
return apiStatus, nil
}
// StatusToAPIEdits ...
func (c *Converter) StatusToAPIEdits(ctx context.Context, status *gtsmodel.Status) ([]*apimodel.StatusEdit, error) {
var media map[string]*gtsmodel.MediaAttachment
// Gather attachments of status AND edits.
attachmentIDs := status.AllAttachmentIDs()
if len(attachmentIDs) > 0 {
// Fetch all of the gathered status attachments from the database.
attachments, err := c.state.DB.GetAttachmentsByIDs(ctx, attachmentIDs)
if err != nil {
return nil, gtserror.Newf("error getting attachments from db: %w", err)
}
// Generate a lookup map in 'media' of status attachments by their IDs.
media = util.KeyBy(attachments, func(m *gtsmodel.MediaAttachment) string {
return m.ID
})
}
// Convert the status author account to API model.
apiAccount, err := c.AccountToAPIAccountPublic(ctx,
status.Account,
)
if err != nil {
return nil, gtserror.Newf("error converting account: %w", err)
}
// Convert status emojis to their API models.
apiEmojis, err := c.convertEmojisToAPIEmojis(ctx,
nil,
status.EmojiIDs,
)
if err != nil {
return nil, gtserror.Newf("error converting emojis: %w", err)
}
// Iterate through status edits, starting at newest (highest index).
apiEdits := make([]*apimodel.StatusEdit, 0, len(status.Edits))
for i := len(status.Edits) - 1; i >= 0; i-- {
edit := status.Edits[i]
// Iterate through edit attachment IDs, getting model from 'media' lookup.
apiAttachments := make([]*apimodel.Attachment, 0, len(edit.AttachmentIDs))
for _, id := range edit.AttachmentIDs {
attachment, ok := media[id]
if !ok {
continue
}
// Convert each media attachment to frontend API model.
apiAttachment, err := c.AttachmentToAPIAttachment(ctx,
attachment,
)
if err != nil {
log.Error(ctx, "error converting attachment: %v", err)
continue
}
// Append converted media attachment to return slice.
apiAttachments = append(apiAttachments, &apiAttachment)
}
// If media descriptions are set, update API model descriptions.
if len(edit.AttachmentIDs) == len(edit.AttachmentDescriptions) {
var j int
for i, id := range edit.AttachmentIDs {
descr := edit.AttachmentDescriptions[i]
for ; j < len(apiAttachments); j++ {
if apiAttachments[j].ID == id {
apiAttachments[j].Description = &descr
break
}
}
}
}
// Attach status poll if set.
var apiPoll *apimodel.Poll
if len(edit.PollOptions) > 0 {
apiPoll = new(apimodel.Poll)
// Iterate through poll options and attach to API poll model.
apiPoll.Options = make([]apimodel.PollOption, len(edit.PollOptions))
for i, option := range edit.PollOptions {
apiPoll.Options[i] = apimodel.PollOption{
Title: option,
}
}
// If poll votes are attached, set vote counts.
if len(edit.PollVotes) == len(apiPoll.Options) {
for i, votes := range edit.PollVotes {
apiPoll.Options[i].VotesCount = &votes
}
}
}
// Append this status edit to the return slice.
apiEdits = append(apiEdits, &apimodel.StatusEdit{
CreatedAt: util.FormatISO8601(edit.CreatedAt),
Content: edit.Content,
SpoilerText: edit.ContentWarning,
Sensitive: util.PtrOrZero(edit.Sensitive),
Account: apiAccount,
Poll: apiPoll,
MediaAttachments: apiAttachments,
Emojis: apiEmojis,
})
}
return apiEdits, nil
}
// VisToAPIVis converts a gts visibility into its api equivalent
func (c *Converter) VisToAPIVis(ctx context.Context, m gtsmodel.Visibility) apimodel.Visibility {
switch m {
@ -1488,7 +1587,7 @@ func (c *Converter) VisToAPIVis(ctx context.Context, m gtsmodel.Visibility) apim
}
// InstanceRuleToAdminAPIRule converts a local instance rule into its api equivalent for serving at /api/v1/admin/instance/rules/:id
func (c *Converter) InstanceRuleToAPIRule(r gtsmodel.Rule) apimodel.InstanceRule {
func InstanceRuleToAPIRule(r gtsmodel.Rule) apimodel.InstanceRule {
return apimodel.InstanceRule{
ID: r.ID,
Text: r.Text,
@ -1496,18 +1595,16 @@ func (c *Converter) InstanceRuleToAPIRule(r gtsmodel.Rule) apimodel.InstanceRule
}
// InstanceRulesToAPIRules converts all local instance rules into their api equivalent for serving at /api/v1/instance/rules
func (c *Converter) InstanceRulesToAPIRules(r []gtsmodel.Rule) []apimodel.InstanceRule {
func InstanceRulesToAPIRules(r []gtsmodel.Rule) []apimodel.InstanceRule {
rules := make([]apimodel.InstanceRule, len(r))
for i, v := range r {
rules[i] = c.InstanceRuleToAPIRule(v)
rules[i] = InstanceRuleToAPIRule(v)
}
return rules
}
// InstanceRuleToAdminAPIRule converts a local instance rule into its api equivalent for serving at /api/v1/admin/instance/rules/:id
func (c *Converter) InstanceRuleToAdminAPIRule(r *gtsmodel.Rule) *apimodel.AdminInstanceRule {
func InstanceRuleToAdminAPIRule(r *gtsmodel.Rule) *apimodel.AdminInstanceRule {
return &apimodel.AdminInstanceRule{
ID: r.ID,
CreatedAt: util.FormatISO8601(r.CreatedAt),
@ -1540,7 +1637,7 @@ func (c *Converter) InstanceToAPIV1Instance(ctx context.Context, i *gtsmodel.Ins
ApprovalRequired: true, // approval always required
InvitesEnabled: false, // todo: not supported yet
MaxTootChars: uint(config.GetStatusesMaxChars()), // #nosec G115 -- Already validated.
Rules: c.InstanceRulesToAPIRules(i.Rules),
Rules: InstanceRulesToAPIRules(i.Rules),
Terms: i.Terms,
TermsRaw: i.TermsText,
}
@ -1674,7 +1771,7 @@ func (c *Converter) InstanceToAPIV2Instance(ctx context.Context, i *gtsmodel.Ins
CustomCSS: i.CustomCSS,
Usage: apimodel.InstanceV2Usage{}, // todo: not implemented
Languages: config.GetInstanceLanguages().TagStrs(),
Rules: c.InstanceRulesToAPIRules(i.Rules),
Rules: InstanceRulesToAPIRules(i.Rules),
Terms: i.Terms,
TermsText: i.TermsText,
}