diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml
index 81b32bb72..6f6ecb6b0 100644
--- a/docs/api/swagger.yaml
+++ b/docs/api/swagger.yaml
@@ -9550,6 +9550,112 @@ paths:
summary: Create a new status using the given form field parameters.
tags:
- statuses
+ put:
+ consumes:
+ - application/json
+ - application/x-www-form-urlencoded
+ description: The parameters can also be given in the body of the request, as JSON, if the content-type is set to 'application/json'.
+ operationId: statusEdit
+ parameters:
+ - description: |-
+ Text content of the status.
+ If media_ids is provided, this becomes optional.
+ Attaching a poll is optional while status is provided.
+ in: formData
+ name: status
+ type: string
+ x-go-name: Status
+ - 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'.
+ in: formData
+ items:
+ type: string
+ name: media_ids
+ type: array
+ x-go-name: MediaIDs
+ - description: |-
+ Array of possible poll answers.
+ If provided, media_ids cannot be used, and poll[expires_in] must be provided.
+ in: formData
+ items:
+ type: string
+ name: poll[options][]
+ type: array
+ x-go-name: PollOptions
+ - description: |-
+ Duration the poll should be open, in seconds.
+ If provided, media_ids cannot be used, and poll[options] must be provided.
+ format: int64
+ in: formData
+ name: poll[expires_in]
+ type: integer
+ x-go-name: PollExpiresIn
+ - default: false
+ description: Allow multiple choices on this poll.
+ in: formData
+ name: poll[multiple]
+ type: boolean
+ x-go-name: PollMultiple
+ - default: true
+ description: Hide vote counts until the poll ends.
+ in: formData
+ name: poll[hide_totals]
+ type: boolean
+ x-go-name: PollHideTotals
+ - description: Status and attached media should be marked as sensitive.
+ in: formData
+ name: sensitive
+ type: boolean
+ x-go-name: Sensitive
+ - description: |-
+ Text to be shown as a warning or subject before the actual content.
+ Statuses are generally collapsed behind this field.
+ in: formData
+ name: spoiler_text
+ type: string
+ x-go-name: SpoilerText
+ - description: ISO 639 language code for this status.
+ in: formData
+ name: language
+ type: string
+ x-go-name: Language
+ - description: Content type to use when parsing this status.
+ enum:
+ - text/plain
+ - text/markdown
+ in: formData
+ name: content_type
+ type: string
+ x-go-name: ContentType
+ produces:
+ - application/json
+ 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
+ security:
+ - OAuth2 Bearer:
+ - write:statuses
+ summary: Edit an existing status using the given form field parameters.
+ tags:
+ - statuses
/api/v1/statuses/{id}:
delete:
description: |-
diff --git a/internal/api/client/statuses/status.go b/internal/api/client/statuses/status.go
index 33af9c456..88b34cbf5 100644
--- a/internal/api/client/statuses/status.go
+++ b/internal/api/client/statuses/status.go
@@ -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
diff --git a/internal/api/client/statuses/statuscreate.go b/internal/api/client/statuses/statuscreate.go
index 8198d5358..c83cdbad7 100644
--- a/internal/api/client/statuses/statuscreate.go
+++ b/internal/api/client/statuses/statuscreate.go
@@ -27,11 +27,9 @@
"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
}
diff --git a/internal/api/client/statuses/statusdelete.go b/internal/api/client/statuses/statusdelete.go
index 7ee240dff..fa62d6893 100644
--- a/internal/api/client/statuses/statusdelete.go
+++ b/internal/api/client/statuses/statusdelete.go
@@ -95,5 +95,5 @@ func (m *Module) StatusDELETEHandler(c *gin.Context) {
return
}
- c.JSON(http.StatusOK, apiStatus)
+ apiutil.JSON(c, http.StatusOK, apiStatus)
}
diff --git a/internal/api/client/statuses/statusedit.go b/internal/api/client/statuses/statusedit.go
new file mode 100644
index 000000000..dfd7d651e
--- /dev/null
+++ b/internal/api/client/statuses/statusedit.go
@@ -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
Hey this is a status!
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"` + + // Array of Attachment attributes to be updated in attached media. + MediaAttributes []AttachmentAttributesRequest `form:"media_attributes[]" json:"media_attributes"` + + // Poll to include with this status. + Poll *PollRequest `form:"poll" json:"poll"` +} diff --git a/internal/api/util/parseform.go b/internal/api/util/parseform.go index 3eab065f2..8bb10012c 100644 --- a/internal/api/util/parseform.go +++ b/internal/api/util/parseform.go @@ -18,13 +18,55 @@ package util import ( + "errors" "fmt" "strconv" + "strings" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/util" ) +// ParseFocus parses a media attachment focus parameters from incoming API string. +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 +} + // ParseDuration parses the given raw interface belonging // the given fieldName as an integer duration. func ParseDuration(rawI any, fieldName string) (*int, error) { diff --git a/internal/db/bundb/status.go b/internal/db/bundb/status.go index fa31f3459..fea5594dd 100644 --- a/internal/db/bundb/status.go +++ b/internal/db/bundb/status.go @@ -297,17 +297,6 @@ func (s *statusDB) PopulateStatus(ctx context.Context, status *gtsmodel.Status) } } - if !status.EditsPopulated() { - // Status edits are out-of-date with IDs, repopulate. - status.Edits, err = s.state.DB.GetStatusEditsByIDs( - gtscontext.SetBarebones(ctx), - status.EditIDs, - ) - if err != nil { - errs.Appendf("error populating status edits: %w", err) - } - } - if status.CreatedWithApplicationID != "" && status.CreatedWithApplication == nil { // Populate the status' expected CreatedWithApplication (not always set). status.CreatedWithApplication, err = s.state.DB.GetApplicationByID( @@ -322,6 +311,23 @@ func (s *statusDB) PopulateStatus(ctx context.Context, status *gtsmodel.Status) return errs.Combine() } +func (s *statusDB) PopulateStatusEdits(ctx context.Context, status *gtsmodel.Status) error { + var err error + + if !status.EditsPopulated() { + // Status edits are out-of-date with IDs, repopulate. + status.Edits, err = s.state.DB.GetStatusEditsByIDs( + gtscontext.SetBarebones(ctx), + status.EditIDs, + ) + if err != nil { + return gtserror.Newf("error populating status edits: %w", err) + } + } + + return nil +} + func (s *statusDB) PutStatus(ctx context.Context, status *gtsmodel.Status) error { return s.state.Caches.DB.Status.Store(status, func() error { // It is safe to run this database transaction within cache.Store diff --git a/internal/db/status.go b/internal/db/status.go index ade900728..6bf9653c8 100644 --- a/internal/db/status.go +++ b/internal/db/status.go @@ -41,8 +41,12 @@ type Status interface { GetStatusBoost(ctx context.Context, boostOfID string, byAccountID string) (*gtsmodel.Status, error) // PopulateStatus ensures that all sub-models of a status are populated (e.g. mentions, attachments, etc). + // Except for edits, to fetch these please call PopulateStatusEdits() . PopulateStatus(ctx context.Context, status *gtsmodel.Status) error + // PopulateStatusEdits ensures that status' edits are fully popualted. + PopulateStatusEdits(ctx context.Context, status *gtsmodel.Status) error + // PutStatus stores one status in the database. PutStatus(ctx context.Context, status *gtsmodel.Status) error diff --git a/internal/federation/dereferencing/dereferencer.go b/internal/federation/dereferencing/dereferencer.go index 3bff0d1a2..5e7b2b9c0 100644 --- a/internal/federation/dereferencing/dereferencer.go +++ b/internal/federation/dereferencing/dereferencer.go @@ -66,7 +66,7 @@ // causing loads of dereferencing calls. Fresh = util.Ptr(FreshnessWindow(5 * time.Minute)) - // 10 seconds. + // 5 seconds. // // Freshest is useful when you want an // immediately up to date model of something @@ -74,7 +74,7 @@ // // Be careful using this one; it can cause // lots of unnecessary traffic if used unwisely. - Freshest = util.Ptr(FreshnessWindow(10 * time.Second)) + Freshest = util.Ptr(FreshnessWindow(5 * time.Second)) ) // Dereferencer wraps logic and functionality for doing dereferencing diff --git a/internal/federation/dereferencing/status.go b/internal/federation/dereferencing/status.go index d19669891..0a75a4802 100644 --- a/internal/federation/dereferencing/status.go +++ b/internal/federation/dereferencing/status.go @@ -35,6 +35,7 @@ "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 { @@ -1187,6 +1197,13 @@ func (d *Dereferencer) handleStatusEdit( // Attached emojis changed. cols = append(cols, "emojis") // i.e. EmojiIDs + // We specifically store both *new* AND *old* edit + // revision emojis in the statuses.emojis column. + emojiByID := func(e *gtsmodel.Emoji) string { return e.ID } + status.Emojis = append(status.Emojis, existing.Emojis...) + status.Emojis = xslices.DeduplicateFunc(status.Emojis, emojiByID) + status.EmojiIDs = xslices.Gather(status.EmojiIDs[:0], status.Emojis, emojiByID) + // Emojis changed doesn't necessarily // indicate an edit, it may just not have // been previously populated properly. @@ -1230,7 +1247,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. diff --git a/internal/processing/admin/rule.go b/internal/processing/admin/rule.go index d1ee63cc8..8134c21cd 100644 --- a/internal/processing/admin/rule.go +++ b/internal/processing/admin/rule.go @@ -27,6 +27,7 @@ "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 } diff --git a/internal/processing/common/status.go b/internal/processing/common/status.go index da5cf1290..01f2ab72d 100644 --- a/internal/processing/common/status.go +++ b/internal/processing/common/status.go @@ -31,6 +31,40 @@ "github.com/superseriousbusiness/gotosocial/internal/log" ) +// GetOwnStatus fetches the given status with ID, +// and ensures that it belongs to given requester. +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) + } + + 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 diff --git a/internal/processing/instance.go b/internal/processing/instance.go index fab71b1de..2f4c40416 100644 --- a/internal/processing/instance.go +++ b/internal/processing/instance.go @@ -29,6 +29,7 @@ "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) { diff --git a/internal/processing/media/create.go b/internal/processing/media/create.go index ca1f1c3c6..5ea630618 100644 --- a/internal/processing/media/create.go +++ b/internal/processing/media/create.go @@ -25,6 +25,7 @@ "codeberg.org/gruf/go-iotools" 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/gtsmodel" @@ -45,10 +46,21 @@ func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, form } // Parse focus details from API form input. - focusX, focusY, err := parseFocus(form.Focus) - if err != nil { - text := fmt.Sprintf("could not parse focus value %s: %s", form.Focus, err) - return nil, gtserror.NewErrorBadRequest(errors.New(text), text) + focusX, focusY, errWithCode := apiutil.ParseFocus(form.Focus) + if errWithCode != nil { + return nil, errWithCode + } + + // If description provided, + // process and validate it. + // + // This may not yet be set as it + // is often set on status post. + if form.Description != "" { + form.Description, errWithCode = processDescription(form.Description) + if errWithCode != nil { + return nil, errWithCode + } } // Open multipart file reader. @@ -58,7 +70,7 @@ func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, form return nil, gtserror.NewErrorInternalError(err) } - // Wrap the multipart file reader to ensure is limited to max. + // Wrap multipart file reader to ensure is limited to max size. rc, _, _ := iotools.UpdateReadCloserLimit(mpfile, maxszInt64) // Create local media and write to instance storage. diff --git a/internal/processing/media/update.go b/internal/processing/media/update.go index d3a9cfe61..c8592395f 100644 --- a/internal/processing/media/update.go +++ b/internal/processing/media/update.go @@ -23,6 +23,8 @@ "fmt" 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/db" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" @@ -47,17 +49,27 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, media var updatingColumns []string if form.Description != nil { - attachment.Description = text.SanitizeToPlaintext(*form.Description) + // Sanitize and validate incoming description. + description, errWithCode := processDescription( + *form.Description, + ) + if errWithCode != nil { + return nil, errWithCode + } + + attachment.Description = description updatingColumns = append(updatingColumns, "description") } if form.Focus != nil { - focusx, focusy, err := parseFocus(*form.Focus) - if err != nil { - return nil, gtserror.NewErrorBadRequest(err) + // Parse focus details from API form input. + focusX, focusY, errWithCode := apiutil.ParseFocus(*form.Focus) + if errWithCode != nil { + return nil, errWithCode } - attachment.FileMeta.Focus.X = focusx - attachment.FileMeta.Focus.Y = focusy + + attachment.FileMeta.Focus.X = focusX + attachment.FileMeta.Focus.Y = focusY updatingColumns = append(updatingColumns, "focus_x", "focus_y") } @@ -72,3 +84,21 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, media return &a, nil } + +// processDescription will sanitize and valid description against server configuration. +func processDescription(description string) (string, gtserror.WithCode) { + description = text.SanitizeToPlaintext(description) + chars := len([]rune(description)) + + if min := config.GetMediaDescriptionMinChars(); chars < min { + text := fmt.Sprintf("media description less than min chars (%d)", min) + return "", gtserror.NewErrorBadRequest(errors.New(text), text) + } + + if max := config.GetMediaDescriptionMaxChars(); chars > max { + text := fmt.Sprintf("media description exceeds max chars (%d)", max) + return "", gtserror.NewErrorBadRequest(errors.New(text), text) + } + + return description, nil +} diff --git a/internal/processing/media/util.go b/internal/processing/media/util.go deleted file mode 100644 index 0ca2697fd..000000000 --- a/internal/processing/media/util.go +++ /dev/null @@ -1,62 +0,0 @@ -// 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, seethis is some edited status text!
", + SpoilerText: "shhhhh", + Sensitive: true, + Language: "fr", // hoh hoh hoh + MediaIDs: nil, + MediaAttributes: nil, + Poll: nil, + } + + // Pass the prepared form to the status processor to perform the edit. + apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form) + suite.NotNil(apiStatus) + suite.NoError(errWithCode) + + // Check response against input form data. + suite.Equal(form.Status, apiStatus.Text) + suite.Equal(form.SpoilerText, apiStatus.SpoilerText) + suite.Equal(form.Sensitive, apiStatus.Sensitive) + suite.Equal(form.Language, *apiStatus.Language) + suite.NotEqual(util.FormatISO8601(status.UpdatedAt), *apiStatus.EditedAt) + + // Fetched the latest version of edited status from the database. + latestStatus, err := suite.state.DB.GetStatusByID(ctx, status.ID) + suite.NoError(err) + + // Check latest status against input form data. + suite.Equal(form.Status, latestStatus.Text) + suite.Equal(form.SpoilerText, latestStatus.ContentWarning) + suite.Equal(form.Sensitive, *latestStatus.Sensitive) + suite.Equal(form.Language, latestStatus.Language) + suite.Equal(len(status.EditIDs)+1, len(latestStatus.EditIDs)) + suite.NotEqual(status.UpdatedAt, latestStatus.UpdatedAt) + + // Populate all historical edits for this status. + err = suite.state.DB.PopulateStatusEdits(ctx, latestStatus) + suite.NoError(err) + + // Check previous status edit matches original status content. + previousEdit := latestStatus.Edits[len(latestStatus.Edits)-1] + suite.Equal(status.Content, previousEdit.Content) + suite.Equal(status.Text, previousEdit.Text) + suite.Equal(status.ContentWarning, previousEdit.ContentWarning) + suite.Equal(*status.Sensitive, *previousEdit.Sensitive) + suite.Equal(status.Language, previousEdit.Language) + suite.Equal(status.UpdatedAt, previousEdit.CreatedAt) +} + +func (suite *StatusEditTestSuite) TestEditAddPoll() { + // Create cancellable context to use for test. + ctx, cncl := context.WithCancel(context.Background()) + defer cncl() + + // Get a local account to use as test requester. + requester := suite.testAccounts["local_account_1"] + requester, _ = suite.state.DB.GetAccountByID(ctx, requester.ID) + + // Get requester's existing status to perform an edit on. + status := suite.testStatuses["local_account_1_status_9"] + status, _ = suite.state.DB.GetStatusByID(ctx, status.ID) + + // Prepare edit adding a status poll. + form := &apimodel.StatusEditRequest{ + Status: "this is some edited status text!
", + SpoilerText: "", + Sensitive: true, + Language: "fr", // hoh hoh hoh + MediaIDs: nil, + MediaAttributes: nil, + Poll: &apimodel.PollRequest{ + Options: []string{"yes", "no", "spiderman"}, + ExpiresIn: int(time.Minute), + Multiple: true, + HideTotals: false, + }, + } + + // Pass the prepared form to the status processor to perform the edit. + apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form) + suite.NotNil(apiStatus) + suite.NoError(errWithCode) + + // Check response against input form data. + suite.Equal(form.Status, apiStatus.Text) + suite.Equal(form.SpoilerText, apiStatus.SpoilerText) + suite.Equal(form.Sensitive, apiStatus.Sensitive) + suite.Equal(form.Language, *apiStatus.Language) + suite.NotEqual(util.FormatISO8601(status.UpdatedAt), *apiStatus.EditedAt) + suite.NotNil(apiStatus.Poll) + suite.Equal(form.Poll.Options, xslices.Gather(nil, apiStatus.Poll.Options, func(opt apimodel.PollOption) string { + return opt.Title + })) + + // Fetched the latest version of edited status from the database. + latestStatus, err := suite.state.DB.GetStatusByID(ctx, status.ID) + suite.NoError(err) + + // Check latest status against input form data. + suite.Equal(form.Status, latestStatus.Text) + suite.Equal(form.SpoilerText, latestStatus.ContentWarning) + suite.Equal(form.Sensitive, *latestStatus.Sensitive) + suite.Equal(form.Language, latestStatus.Language) + suite.Equal(len(status.EditIDs)+1, len(latestStatus.EditIDs)) + suite.NotEqual(status.UpdatedAt, latestStatus.UpdatedAt) + suite.NotNil(latestStatus.Poll) + suite.Equal(form.Poll.Options, latestStatus.Poll.Options) + + // Ensure that a poll expiry handler was scheduled on status edit. + expiryWorker := suite.state.Workers.Scheduler.Cancel(latestStatus.PollID) + suite.Equal(form.Poll.ExpiresIn > 0, expiryWorker) + + // Populate all historical edits for this status. + err = suite.state.DB.PopulateStatusEdits(ctx, latestStatus) + suite.NoError(err) + + // Check previous status edit matches original status content. + previousEdit := latestStatus.Edits[len(latestStatus.Edits)-1] + suite.Equal(status.Content, previousEdit.Content) + suite.Equal(status.Text, previousEdit.Text) + suite.Equal(status.ContentWarning, previousEdit.ContentWarning) + suite.Equal(*status.Sensitive, *previousEdit.Sensitive) + suite.Equal(status.Language, previousEdit.Language) + suite.Equal(status.UpdatedAt, previousEdit.CreatedAt) + suite.Equal(status.Poll != nil, len(previousEdit.PollOptions) > 0) +} + +func (suite *StatusEditTestSuite) TestEditAddPollNoExpiry() { + // Create cancellable context to use for test. + ctx, cncl := context.WithCancel(context.Background()) + defer cncl() + + // Get a local account to use as test requester. + requester := suite.testAccounts["local_account_1"] + requester, _ = suite.state.DB.GetAccountByID(ctx, requester.ID) + + // Get requester's existing status to perform an edit on. + status := suite.testStatuses["local_account_1_status_9"] + status, _ = suite.state.DB.GetStatusByID(ctx, status.ID) + + // Prepare edit adding an endless poll. + form := &apimodel.StatusEditRequest{ + Status: "this is some edited status text!
", + SpoilerText: "", + Sensitive: true, + Language: "fr", // hoh hoh hoh + MediaIDs: nil, + MediaAttributes: nil, + Poll: &apimodel.PollRequest{ + Options: []string{"yes", "no", "spiderman"}, + ExpiresIn: 0, + Multiple: true, + HideTotals: false, + }, + } + + // Pass the prepared form to the status processor to perform the edit. + apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form) + suite.NotNil(apiStatus) + suite.NoError(errWithCode) + + // Check response against input form data. + suite.Equal(form.Status, apiStatus.Text) + suite.Equal(form.SpoilerText, apiStatus.SpoilerText) + suite.Equal(form.Sensitive, apiStatus.Sensitive) + suite.Equal(form.Language, *apiStatus.Language) + suite.NotEqual(util.FormatISO8601(status.UpdatedAt), *apiStatus.EditedAt) + suite.NotNil(apiStatus.Poll) + suite.Equal(form.Poll.Options, xslices.Gather(nil, apiStatus.Poll.Options, func(opt apimodel.PollOption) string { + return opt.Title + })) + + // Fetched the latest version of edited status from the database. + latestStatus, err := suite.state.DB.GetStatusByID(ctx, status.ID) + suite.NoError(err) + + // Check latest status against input form data. + suite.Equal(form.Status, latestStatus.Text) + suite.Equal(form.SpoilerText, latestStatus.ContentWarning) + suite.Equal(form.Sensitive, *latestStatus.Sensitive) + suite.Equal(form.Language, latestStatus.Language) + suite.Equal(len(status.EditIDs)+1, len(latestStatus.EditIDs)) + suite.NotEqual(status.UpdatedAt, latestStatus.UpdatedAt) + suite.NotNil(latestStatus.Poll) + suite.Equal(form.Poll.Options, latestStatus.Poll.Options) + + // Ensure that a poll expiry handler was *not* scheduled on status edit. + expiryWorker := suite.state.Workers.Scheduler.Cancel(latestStatus.PollID) + suite.Equal(form.Poll.ExpiresIn > 0, expiryWorker) + + // Populate all historical edits for this status. + err = suite.state.DB.PopulateStatusEdits(ctx, latestStatus) + suite.NoError(err) + + // Check previous status edit matches original status content. + previousEdit := latestStatus.Edits[len(latestStatus.Edits)-1] + suite.Equal(status.Content, previousEdit.Content) + suite.Equal(status.Text, previousEdit.Text) + suite.Equal(status.ContentWarning, previousEdit.ContentWarning) + suite.Equal(*status.Sensitive, *previousEdit.Sensitive) + suite.Equal(status.Language, previousEdit.Language) + suite.Equal(status.UpdatedAt, previousEdit.CreatedAt) + suite.Equal(status.Poll != nil, len(previousEdit.PollOptions) > 0) +} + +func (suite *StatusEditTestSuite) TestEditMediaDescription() { + // Create cancellable context to use for test. + ctx, cncl := context.WithCancel(context.Background()) + defer cncl() + + // Get a local account to use as test requester. + requester := suite.testAccounts["local_account_1"] + requester, _ = suite.state.DB.GetAccountByID(ctx, requester.ID) + + // Get requester's existing status to perform an edit on. + status := suite.testStatuses["local_account_1_status_4"] + status, _ = suite.state.DB.GetStatusByID(ctx, status.ID) + + // Prepare edit changing media description. + form := &apimodel.StatusEditRequest{ + Status: "this is some edited status text!
", + SpoilerText: "this status is now missing media", + Sensitive: true, + Language: "en", + MediaIDs: status.AttachmentIDs, + MediaAttributes: []apimodel.AttachmentAttributesRequest{ + {ID: status.AttachmentIDs[0], Description: "hello world!"}, + {ID: status.AttachmentIDs[1], Description: "media attachment numero two"}, + }, + } + + // Pass the prepared form to the status processor to perform the edit. + apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form) + suite.NoError(errWithCode) + + // Check response against input form data. + suite.Equal(form.Status, apiStatus.Text) + suite.Equal(form.SpoilerText, apiStatus.SpoilerText) + suite.Equal(form.Sensitive, apiStatus.Sensitive) + suite.Equal(form.Language, *apiStatus.Language) + suite.NotEqual(util.FormatISO8601(status.UpdatedAt), *apiStatus.EditedAt) + suite.Equal(form.MediaIDs, xslices.Gather(nil, apiStatus.MediaAttachments, func(media *apimodel.Attachment) string { + return media.ID + })) + suite.Equal( + xslices.Gather(nil, form.MediaAttributes, func(attr apimodel.AttachmentAttributesRequest) string { + return attr.Description + }), + xslices.Gather(nil, apiStatus.MediaAttachments, func(media *apimodel.Attachment) string { + return *media.Description + }), + ) + + // Fetched the latest version of edited status from the database. + latestStatus, err := suite.state.DB.GetStatusByID(ctx, status.ID) + suite.NoError(err) + + // Check latest status against input form data. + suite.Equal(form.Status, latestStatus.Text) + suite.Equal(form.SpoilerText, latestStatus.ContentWarning) + suite.Equal(form.Sensitive, *latestStatus.Sensitive) + suite.Equal(form.Language, latestStatus.Language) + suite.Equal(len(status.EditIDs)+1, len(latestStatus.EditIDs)) + suite.NotEqual(status.UpdatedAt, latestStatus.UpdatedAt) + suite.Equal(form.MediaIDs, latestStatus.AttachmentIDs) + suite.Equal( + xslices.Gather(nil, form.MediaAttributes, func(attr apimodel.AttachmentAttributesRequest) string { + return attr.Description + }), + xslices.Gather(nil, latestStatus.Attachments, func(media *gtsmodel.MediaAttachment) string { + return media.Description + }), + ) + + // Populate all historical edits for this status. + err = suite.state.DB.PopulateStatusEdits(ctx, latestStatus) + suite.NoError(err) + + // Further populate edits to get attachments. + for _, edit := range latestStatus.Edits { + err = suite.state.DB.PopulateStatusEdit(ctx, edit) + suite.NoError(err) + } + + // Check previous status edit matches original status content. + previousEdit := latestStatus.Edits[len(latestStatus.Edits)-1] + suite.Equal(status.Content, previousEdit.Content) + suite.Equal(status.Text, previousEdit.Text) + suite.Equal(status.ContentWarning, previousEdit.ContentWarning) + suite.Equal(*status.Sensitive, *previousEdit.Sensitive) + suite.Equal(status.Language, previousEdit.Language) + suite.Equal(status.UpdatedAt, previousEdit.CreatedAt) + suite.Equal(status.AttachmentIDs, previousEdit.AttachmentIDs) + suite.Equal( + xslices.Gather(nil, status.Attachments, func(media *gtsmodel.MediaAttachment) string { + return media.Description + }), + previousEdit.AttachmentDescriptions, + ) +} + +func (suite *StatusEditTestSuite) TestEditAddMedia() { + // Create cancellable context to use for test. + ctx, cncl := context.WithCancel(context.Background()) + defer cncl() + + // Get a local account to use as test requester. + requester := suite.testAccounts["local_account_1"] + requester, _ = suite.state.DB.GetAccountByID(ctx, requester.ID) + + // Get some of requester's existing media, and unattach from existing status. + media1 := suite.testAttachments["local_account_1_status_4_attachment_1"] + media2 := suite.testAttachments["local_account_1_status_4_attachment_2"] + media1.StatusID, media2.StatusID = "", "" + suite.NoError(suite.state.DB.UpdateAttachment(ctx, media1, "status_id")) + suite.NoError(suite.state.DB.UpdateAttachment(ctx, media2, "status_id")) + media1, _ = suite.state.DB.GetAttachmentByID(ctx, media1.ID) + media2, _ = suite.state.DB.GetAttachmentByID(ctx, media2.ID) + + // Get requester's existing status to perform an edit on. + status := suite.testStatuses["local_account_1_status_9"] + status, _ = suite.state.DB.GetStatusByID(ctx, status.ID) + + // Prepare edit addding status media. + form := &apimodel.StatusEditRequest{ + Status: "this is some edited status text!
", + SpoilerText: "this status now has media", + Sensitive: true, + Language: "en", + MediaIDs: []string{media1.ID, media2.ID}, + MediaAttributes: nil, + } + + // Pass the prepared form to the status processor to perform the edit. + apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form) + suite.NotNil(apiStatus) + suite.NoError(errWithCode) + + // Check response against input form data. + suite.Equal(form.Status, apiStatus.Text) + suite.Equal(form.SpoilerText, apiStatus.SpoilerText) + suite.Equal(form.Sensitive, apiStatus.Sensitive) + suite.Equal(form.Language, *apiStatus.Language) + suite.NotEqual(util.FormatISO8601(status.UpdatedAt), *apiStatus.EditedAt) + suite.Equal(form.MediaIDs, xslices.Gather(nil, apiStatus.MediaAttachments, func(media *apimodel.Attachment) string { + return media.ID + })) + + // Fetched the latest version of edited status from the database. + latestStatus, err := suite.state.DB.GetStatusByID(ctx, status.ID) + suite.NoError(err) + + // Check latest status against input form data. + suite.Equal(form.Status, latestStatus.Text) + suite.Equal(form.SpoilerText, latestStatus.ContentWarning) + suite.Equal(form.Sensitive, *latestStatus.Sensitive) + suite.Equal(form.Language, latestStatus.Language) + suite.Equal(len(status.EditIDs)+1, len(latestStatus.EditIDs)) + suite.NotEqual(status.UpdatedAt, latestStatus.UpdatedAt) + suite.Equal(form.MediaIDs, latestStatus.AttachmentIDs) + + // Populate all historical edits for this status. + err = suite.state.DB.PopulateStatusEdits(ctx, latestStatus) + suite.NoError(err) + + // Check previous status edit matches original status content. + previousEdit := latestStatus.Edits[len(latestStatus.Edits)-1] + suite.Equal(status.Content, previousEdit.Content) + suite.Equal(status.Text, previousEdit.Text) + suite.Equal(status.ContentWarning, previousEdit.ContentWarning) + suite.Equal(*status.Sensitive, *previousEdit.Sensitive) + suite.Equal(status.Language, previousEdit.Language) + suite.Equal(status.UpdatedAt, previousEdit.CreatedAt) + suite.Equal(status.AttachmentIDs, previousEdit.AttachmentIDs) +} + +func (suite *StatusEditTestSuite) TestEditRemoveMedia() { + // Create cancellable context to use for test. + ctx, cncl := context.WithCancel(context.Background()) + defer cncl() + + // Get a local account to use as test requester. + requester := suite.testAccounts["local_account_1"] + requester, _ = suite.state.DB.GetAccountByID(ctx, requester.ID) + + // Get requester's existing status to perform an edit on. + status := suite.testStatuses["local_account_1_status_4"] + status, _ = suite.state.DB.GetStatusByID(ctx, status.ID) + + // Prepare edit removing status media. + form := &apimodel.StatusEditRequest{ + Status: "this is some edited status text!
", + SpoilerText: "this status is now missing media", + Sensitive: true, + Language: "en", + MediaIDs: nil, + MediaAttributes: nil, + } + + // Pass the prepared form to the status processor to perform the edit. + apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form) + suite.NotNil(apiStatus) + suite.NoError(errWithCode) + + // Check response against input form data. + suite.Equal(form.Status, apiStatus.Text) + suite.Equal(form.SpoilerText, apiStatus.SpoilerText) + suite.Equal(form.Sensitive, apiStatus.Sensitive) + suite.Equal(form.Language, *apiStatus.Language) + suite.NotEqual(util.FormatISO8601(status.UpdatedAt), *apiStatus.EditedAt) + suite.Equal(form.MediaIDs, xslices.Gather(nil, apiStatus.MediaAttachments, func(media *apimodel.Attachment) string { + return media.ID + })) + + // Fetched the latest version of edited status from the database. + latestStatus, err := suite.state.DB.GetStatusByID(ctx, status.ID) + suite.NoError(err) + + // Check latest status against input form data. + suite.Equal(form.Status, latestStatus.Text) + suite.Equal(form.SpoilerText, latestStatus.ContentWarning) + suite.Equal(form.Sensitive, *latestStatus.Sensitive) + suite.Equal(form.Language, latestStatus.Language) + suite.Equal(len(status.EditIDs)+1, len(latestStatus.EditIDs)) + suite.NotEqual(status.UpdatedAt, latestStatus.UpdatedAt) + suite.Equal(form.MediaIDs, latestStatus.AttachmentIDs) + + // Populate all historical edits for this status. + err = suite.state.DB.PopulateStatusEdits(ctx, latestStatus) + suite.NoError(err) + + // Check previous status edit matches original status content. + previousEdit := latestStatus.Edits[len(latestStatus.Edits)-1] + suite.Equal(status.Content, previousEdit.Content) + suite.Equal(status.Text, previousEdit.Text) + suite.Equal(status.ContentWarning, previousEdit.ContentWarning) + suite.Equal(*status.Sensitive, *previousEdit.Sensitive) + suite.Equal(status.Language, previousEdit.Language) + suite.Equal(status.UpdatedAt, previousEdit.CreatedAt) + suite.Equal(status.AttachmentIDs, previousEdit.AttachmentIDs) +} + +func (suite *StatusEditTestSuite) TestEditOthersStatus1() { + // Create cancellable context to use for test. + ctx, cncl := context.WithCancel(context.Background()) + defer cncl() + + // Get a local account to use as test requester. + requester := suite.testAccounts["local_account_1"] + requester, _ = suite.state.DB.GetAccountByID(ctx, requester.ID) + + // Get remote accounts's status to attempt an edit on. + status := suite.testStatuses["remote_account_1_status_1"] + status, _ = suite.state.DB.GetStatusByID(ctx, status.ID) + + // Prepare an empty request form, this + // should be all we need to trigger it. + form := &apimodel.StatusEditRequest{} + + // Attempt to edit other remote account's status, this should return an error. + apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form) + suite.Nil(apiStatus) + suite.Equal(http.StatusNotFound, errWithCode.Code()) + suite.Equal("status does not belong to requester", errWithCode.Error()) + suite.Equal("Not Found: target status not found", errWithCode.Safe()) +} + +func (suite *StatusEditTestSuite) TestEditOthersStatus2() { + // Create cancellable context to use for test. + ctx, cncl := context.WithCancel(context.Background()) + defer cncl() + + // Get a local account to use as test requester. + requester := suite.testAccounts["local_account_1"] + requester, _ = suite.state.DB.GetAccountByID(ctx, requester.ID) + + // Get other local accounts's status to attempt edit on. + status := suite.testStatuses["local_account_2_status_1"] + status, _ = suite.state.DB.GetStatusByID(ctx, status.ID) + + // Prepare an empty request form, this + // should be all we need to trigger it. + form := &apimodel.StatusEditRequest{} + + // Attempt to edit other local account's status, this should return an error. + apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form) + suite.Nil(apiStatus) + suite.Equal(http.StatusNotFound, errWithCode.Code()) + suite.Equal("status does not belong to requester", errWithCode.Error()) + suite.Equal("Not Found: target status not found", errWithCode.Safe()) +} + +func TestStatusEditTestSuite(t *testing.T) { + suite.Run(t, new(StatusEditTestSuite)) +} diff --git a/internal/processing/status/get.go b/internal/processing/status/get.go index 470b93a8f..812f01683 100644 --- a/internal/processing/status/get.go +++ b/internal/processing/status/get.go @@ -19,47 +19,16 @@ 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 } diff --git a/internal/processing/workers/fromfediapi.go b/internal/processing/workers/fromfediapi.go index 0d6ec1836..096e285f6 100644 --- a/internal/processing/workers/fromfediapi.go +++ b/internal/processing/workers/fromfediapi.go @@ -762,7 +762,7 @@ func (p *fediAPI) UpdateAccount(ctx context.Context, fMsg *messages.FromFediAPI) account, apubAcc, - // Force refresh within 10s window. + // Force refresh within 5s window. // // Missing account updates could be // detrimental to federation if they @@ -917,17 +917,25 @@ func (p *fediAPI) UpdateStatus(ctx context.Context, fMsg *messages.FromFediAPI) return gtserror.Newf("cannot cast %T -> *gtsmodel.Status", fMsg.GTSModel) } + var freshness *dereferencing.FreshnessWindow + // Cast the updated ActivityPub statusable object . apStatus, _ := fMsg.APObject.(ap.Statusable) + if apStatus != nil { + // If an AP object was provided, we + // allow very fast refreshes that likely + // indicate a status edit after post. + freshness = dereferencing.Freshest + } + // Fetch up-to-date attach status attachments, etc. status, _, err := p.federate.RefreshStatus( ctx, fMsg.Receiving.Username, existing, apStatus, - // Force refresh within 5min window. - dereferencing.Fresh, + freshness, ) if err != nil { log.Errorf(ctx, "error refreshing status: %v", err) diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index e0276a53b..3208fcb51 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -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,149 @@ func (c *Converter) baseStatusToFrontend( return apiStatus, nil } +// StatusToAPIEdits converts a status and its historical edits (if any) to a slice of API model status edits. +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, + // this includes all status emojis both current + // and historic, so it gets passed to each edit. + apiEmojis, err := c.convertEmojisToAPIEmojis(ctx, + nil, + status.EmojiIDs, + ) + if err != nil { + return nil, gtserror.Newf("error converting emojis: %w", err) + } + + var votes []int + var options []string + + if status.Poll != nil { + // Extract status poll options. + options = status.Poll.Options + + // Show votes only if closed / allowed. + if !status.Poll.ClosedAt.IsZero() || + !*status.Poll.HideCounts { + votes = status.Poll.Votes + } + } + + // Append status itself to final slot in the edits + // so we can add its revision using the below loop. + edits := append(status.Edits, >smodel.StatusEdit{ //nolint:gocritic + Content: status.Content, + ContentWarning: status.ContentWarning, + Sensitive: status.Sensitive, + PollOptions: options, + PollVotes: votes, + AttachmentIDs: status.AttachmentIDs, + AttachmentDescriptions: nil, // no change from current + CreatedAt: status.UpdatedAt, + }) + + // Iterate through status edits, starting at newest. + apiEdits := make([]*apimodel.StatusEdit, 0, len(edits)) + for i := len(edits) - 1; i >= 0; i-- { + edit := 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, // same models used for whole status + all edits + }) + } + + 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 +1616,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 +1624,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 +1666,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 +1800,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, } diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go index 0ec9ea05f..39a9bd9d4 100644 --- a/internal/typeutils/internaltofrontend_test.go +++ b/internal/typeutils/internaltofrontend_test.go @@ -3737,6 +3737,136 @@ func (suite *InternalToFrontendTestSuite) TestConversationToAPI() { }`, string(b)) } +func (suite *InternalToFrontendTestSuite) TestStatusToAPIEdits() { + ctx, cncl := context.WithCancel(context.Background()) + defer cncl() + + statusID := suite.testStatuses["local_account_1_status_9"].ID + + status, err := suite.state.DB.GetStatusByID(ctx, statusID) + suite.NoError(err) + + err = suite.state.DB.PopulateStatusEdits(ctx, status) + suite.NoError(err) + + apiEdits, err := suite.typeconverter.StatusToAPIEdits(ctx, status) + suite.NoError(err) + + b, err := json.MarshalIndent(apiEdits, "", " ") + suite.NoError(err) + + suite.Equal(`[ + { + "content": "\u003cp\u003ethis is the latest revision of the status, with a content-warning\u003c/p\u003e", + "spoiler_text": "edited status", + "sensitive": false, + "created_at": "2024-11-01T09:02:00.000Z", + "account": { + "id": "01F8MH1H7YV1Z7D2C8K2730QBF", + "username": "the_mighty_zork", + "acct": "the_mighty_zork", + "display_name": "original zork (he/they)", + "locked": false, + "discoverable": true, + "bot": false, + "created_at": "2022-05-20T11:09:18.000Z", + "note": "\u003cp\u003ehey yo this is my profile!\u003c/p\u003e", + "url": "http://localhost:8080/@the_mighty_zork", + "avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg", + "avatar_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.webp", + "avatar_description": "a green goblin looking nasty", + "avatar_media_id": "01F8MH58A357CV5K7R7TJMSH6S", + "header": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", + "header_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.webp", + "header_description": "A very old-school screenshot of the original team fortress mod for quake", + "header_media_id": "01PFPMWK2FF0D9WMHEJHR07C3Q", + "followers_count": 2, + "following_count": 2, + "statuses_count": 9, + "last_status_at": "2024-11-01", + "emojis": [], + "fields": [], + "enable_rss": true + }, + "poll": null, + "media_attachments": [], + "emojis": [] + }, + { + "content": "\u003cp\u003ethis is the first status edit! now with content-warning\u003c/p\u003e", + "spoiler_text": "edited status", + "sensitive": false, + "created_at": "2024-11-01T09:01:00.000Z", + "account": { + "id": "01F8MH1H7YV1Z7D2C8K2730QBF", + "username": "the_mighty_zork", + "acct": "the_mighty_zork", + "display_name": "original zork (he/they)", + "locked": false, + "discoverable": true, + "bot": false, + "created_at": "2022-05-20T11:09:18.000Z", + "note": "\u003cp\u003ehey yo this is my profile!\u003c/p\u003e", + "url": "http://localhost:8080/@the_mighty_zork", + "avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg", + "avatar_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.webp", + "avatar_description": "a green goblin looking nasty", + "avatar_media_id": "01F8MH58A357CV5K7R7TJMSH6S", + "header": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", + "header_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.webp", + "header_description": "A very old-school screenshot of the original team fortress mod for quake", + "header_media_id": "01PFPMWK2FF0D9WMHEJHR07C3Q", + "followers_count": 2, + "following_count": 2, + "statuses_count": 9, + "last_status_at": "2024-11-01", + "emojis": [], + "fields": [], + "enable_rss": true + }, + "poll": null, + "media_attachments": [], + "emojis": [] + }, + { + "content": "\u003cp\u003ethis is the original status\u003c/p\u003e", + "spoiler_text": "", + "sensitive": false, + "created_at": "2024-11-01T09:00:00.000Z", + "account": { + "id": "01F8MH1H7YV1Z7D2C8K2730QBF", + "username": "the_mighty_zork", + "acct": "the_mighty_zork", + "display_name": "original zork (he/they)", + "locked": false, + "discoverable": true, + "bot": false, + "created_at": "2022-05-20T11:09:18.000Z", + "note": "\u003cp\u003ehey yo this is my profile!\u003c/p\u003e", + "url": "http://localhost:8080/@the_mighty_zork", + "avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg", + "avatar_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.webp", + "avatar_description": "a green goblin looking nasty", + "avatar_media_id": "01F8MH58A357CV5K7R7TJMSH6S", + "header": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", + "header_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.webp", + "header_description": "A very old-school screenshot of the original team fortress mod for quake", + "header_media_id": "01PFPMWK2FF0D9WMHEJHR07C3Q", + "followers_count": 2, + "following_count": 2, + "statuses_count": 9, + "last_status_at": "2024-11-01", + "emojis": [], + "fields": [], + "enable_rss": true + }, + "poll": null, + "media_attachments": [], + "emojis": [] + } +]`, string(b)) +} + func TestInternalToFrontendTestSuite(t *testing.T) { suite.Run(t, new(InternalToFrontendTestSuite)) }