summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/api/swagger.yaml106
-rw-r--r--internal/api/client/statuses/status.go3
-rw-r--r--internal/api/client/statuses/statuscreate.go156
-rw-r--r--internal/api/client/statuses/statusdelete.go2
-rw-r--r--internal/api/client/statuses/statusedit.go249
-rw-r--r--internal/api/client/statuses/statusedit_test.go32
-rw-r--r--internal/api/client/statuses/statussource_test.go2
-rw-r--r--internal/api/model/attachment.go25
-rw-r--r--internal/api/model/status.go70
-rw-r--r--internal/api/util/parseform.go42
-rw-r--r--internal/db/bundb/status.go28
-rw-r--r--internal/db/status.go4
-rw-r--r--internal/federation/dereferencing/dereferencer.go4
-rw-r--r--internal/federation/dereferencing/status.go26
-rw-r--r--internal/processing/admin/rule.go11
-rw-r--r--internal/processing/common/status.go34
-rw-r--r--internal/processing/instance.go3
-rw-r--r--internal/processing/media/create.go22
-rw-r--r--internal/processing/media/update.go42
-rw-r--r--internal/processing/media/util.go62
-rw-r--r--internal/processing/status/common.go351
-rw-r--r--internal/processing/status/create.go300
-rw-r--r--internal/processing/status/create_test.go2
-rw-r--r--internal/processing/status/edit.go555
-rw-r--r--internal/processing/status/edit_test.go544
-rw-r--r--internal/processing/status/get.go78
-rw-r--r--internal/processing/workers/fromfediapi.go14
-rw-r--r--internal/typeutils/internaltofrontend.go172
-rw-r--r--internal/typeutils/internaltofrontend_test.go130
29 files changed, 2546 insertions, 523 deletions
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 @@ 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
-}
-
-func validateStatusPoll(form *apimodel.StatusCreateRequest) gtserror.WithCode {
- var (
- maxPollOptions = config.GetStatusesPollMaxOptions()
- pollOptions = len(form.Poll.Options)
- maxPollOptionChars = config.GetStatusesPollOptionMaxChars()
- )
+ // Normalize poll expiry time if a poll was given.
+ if form.Poll != nil && form.Poll.ExpiresInI != nil {
- 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 <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
+
+}
diff --git a/internal/api/client/statuses/statusedit_test.go b/internal/api/client/statuses/statusedit_test.go
new file mode 100644
index 000000000..43b283d6d
--- /dev/null
+++ b/internal/api/client/statuses/statusedit_test.go
@@ -0,0 +1,32 @@
+// 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_test
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/suite"
+)
+
+type StatusEditTestSuite struct {
+ StatusStandardTestSuite
+}
+
+func TestStatusEditTestSuite(t *testing.T) {
+ suite.Run(t, new(StatusEditTestSuite))
+}
diff --git a/internal/api/client/statuses/statussource_test.go b/internal/api/client/statuses/statussource_test.go
index 28b1e6852..797a462ed 100644
--- a/internal/api/client/statuses/statussource_test.go
+++ b/internal/api/client/statuses/statussource_test.go
@@ -91,7 +91,7 @@ func (suite *StatusSourceTestSuite) TestGetSource() {
suite.Equal(`{
"id": "01F8MHAMCHF6Y650WCRSCP4WMY",
- "text": "**STATUS EDITS ARE NOT CURRENTLY SUPPORTED IN GOTOSOCIAL (coming in 2024)**\nYou can review the original text of your status below, but you will not be able to submit this edit.\n\n---\n\nhello everyone!",
+ "text": "hello everyone!",
"spoiler_text": "introduction post"
}`, dst.String())
}
diff --git a/internal/api/model/attachment.go b/internal/api/model/attachment.go
index f037a09aa..1d910343c 100644
--- a/internal/api/model/attachment.go
+++ b/internal/api/model/attachment.go
@@ -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 attachment attributes.
+//
+// swagger:ignore
+type AttachmentAttributesRequest struct {
+
+ // The ID of the attachment.
+ // example: 01FC31DZT1AYWDZ8XTCRWRBYRK
+ ID string `form:"id" 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
diff --git a/internal/api/model/status.go b/internal/api/model/status.go
index 724134b77..ea9fbaa35 100644
--- a/internal/api/model/status.go
+++ b/internal/api/model/status.go
@@ -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"`
+
+ // 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 @@ var (
// 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 @@ var (
//
// 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 @@ 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 {
@@ -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 @@ 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
}
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 @@ import (
"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 @@ 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) {
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 @@ import (
"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 @@ import (
"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, see <http://www.gnu.org/licenses/>.
-
-package media
-
-import (
- "fmt"
- "strconv"
- "strings"
-)
-
-func parseFocus(focus string) (focusx, focusy float32, err error) {
- if focus == "" {
- return
- }
- spl := strings.Split(focus, ",")
- if len(spl) != 2 {
- err = fmt.Errorf("improperly formatted focus %s", focus)
- return
- }
- xStr := spl[0]
- yStr := spl[1]
- if xStr == "" || yStr == "" {
- err = fmt.Errorf("improperly formatted focus %s", focus)
- return
- }
- fx, err := strconv.ParseFloat(xStr, 32)
- if err != nil {
- err = fmt.Errorf("improperly formatted focus %s: %s", focus, err)
- return
- }
- if fx > 1 || fx < -1 {
- err = fmt.Errorf("improperly formatted focus %s", focus)
- return
- }
- focusx = float32(fx)
- fy, err := strconv.ParseFloat(yStr, 32)
- if err != nil {
- err = fmt.Errorf("improperly formatted focus %s: %s", focus, err)
- return
- }
- if fy > 1 || fy < -1 {
- err = fmt.Errorf("improperly formatted focus %s", focus)
- return
- }
- focusy = float32(fy)
- return
-}
diff --git a/internal/processing/status/common.go b/internal/processing/status/common.go
new file mode 100644
index 000000000..3f2b7b6cb
--- /dev/null
+++ b/internal/processing/status/common.go
@@ -0,0 +1,351 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package status
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/id"
+ "github.com/superseriousbusiness/gotosocial/internal/text"
+ "github.com/superseriousbusiness/gotosocial/internal/util/xslices"
+ "github.com/superseriousbusiness/gotosocial/internal/validate"
+)
+
+// validateStatusContent will validate the common
+// content fields across status write endpoints against
+// current server configuration (e.g. max char counts).
+func validateStatusContent(
+ status string,
+ spoiler string,
+ mediaIDs []string,
+ poll *apimodel.PollRequest,
+) gtserror.WithCode {
+ totalChars := len([]rune(status)) +
+ len([]rune(spoiler))
+
+ if totalChars == 0 && len(mediaIDs) == 0 && poll == nil {
+ const text = "status contains no text, media or poll"
+ return gtserror.NewErrorBadRequest(errors.New(text), text)
+ }
+
+ if max := config.GetStatusesMaxChars(); totalChars > max {
+ text := fmt.Sprintf("text with spoiler exceed max chars (%d)", max)
+ return gtserror.NewErrorBadRequest(errors.New(text), text)
+ }
+
+ if max := config.GetStatusesMediaMaxFiles(); len(mediaIDs) > max {
+ text := fmt.Sprintf("media files exceed max count (%d)", max)
+ return gtserror.NewErrorBadRequest(errors.New(text), text)
+ }
+
+ if poll != nil {
+ switch max := config.GetStatusesPollMaxOptions(); {
+ case len(poll.Options) == 0:
+ const text = "poll cannot have no options"
+ return gtserror.NewErrorBadRequest(errors.New(text), text)
+
+ case len(poll.Options) > max:
+ text := fmt.Sprintf("poll options exceed max count (%d)", max)
+ return gtserror.NewErrorBadRequest(errors.New(text), text)
+ }
+
+ max := config.GetStatusesPollOptionMaxChars()
+ for i, option := range poll.Options {
+ switch l := len([]rune(option)); {
+ case l == 0:
+ const text = "poll option cannot be empty"
+ return gtserror.NewErrorBadRequest(errors.New(text), text)
+
+ case l > max:
+ text := fmt.Sprintf("poll option %d exceed max chars (%d)", i, max)
+ return gtserror.NewErrorBadRequest(errors.New(text), text)
+ }
+ }
+ }
+
+ return nil
+}
+
+// statusContent encompasses the set of common processed
+// status content fields from status write operations for
+// an easily returnable type, without needing to allocate
+// an entire gtsmodel.Status{} model.
+type statusContent struct {
+ Content string
+ ContentWarning string
+ PollOptions []string
+ Language string
+ MentionIDs []string
+ Mentions []*gtsmodel.Mention
+ EmojiIDs []string
+ Emojis []*gtsmodel.Emoji
+ TagIDs []string
+ Tags []*gtsmodel.Tag
+}
+
+func (p *Processor) processContent(
+ ctx context.Context,
+ author *gtsmodel.Account,
+ statusID string,
+ contentType string,
+ content string,
+ contentWarning string,
+ language string,
+ poll *apimodel.PollRequest,
+) (
+ *statusContent,
+ gtserror.WithCode,
+) {
+ if language == "" {
+ // Ensure we have a status language.
+ language = author.Settings.Language
+ if language == "" {
+ const text = "account default language unset"
+ return nil, gtserror.NewErrorInternalError(
+ errors.New(text),
+ )
+ }
+ }
+
+ var err error
+
+ // Validate + normalize determined language.
+ language, err = validate.Language(language)
+ if err != nil {
+ text := fmt.Sprintf("invalid language tag: %v", err)
+ return nil, gtserror.NewErrorBadRequest(
+ errors.New(text),
+ text,
+ )
+ }
+
+ // format is the currently set text formatting
+ // function, according to the provided content-type.
+ var format text.FormatFunc
+
+ if contentType == "" {
+ // If content type wasn't specified, use
+ // the author's preferred content-type.
+ contentType = author.Settings.StatusContentType
+ }
+
+ switch contentType {
+
+ // Format status according to text/plain.
+ case "", string(apimodel.StatusContentTypePlain):
+ format = p.formatter.FromPlain
+
+ // Format status according to text/markdown.
+ case string(apimodel.StatusContentTypeMarkdown):
+ format = p.formatter.FromMarkdown
+
+ // Unknown.
+ default:
+ const text = "invalid status format"
+ return nil, gtserror.NewErrorBadRequest(
+ errors.New(text),
+ text,
+ )
+ }
+
+ // Allocate a structure to hold the
+ // majority of formatted content without
+ // needing to alloc a whole gtsmodel.Status{}.
+ var status statusContent
+ status.Language = language
+
+ // formatInput is a shorthand function to format the given input string with the
+ // currently set 'formatFunc', passing in all required args and returning result.
+ formatInput := func(formatFunc text.FormatFunc, input string) *text.FormatResult {
+ return formatFunc(ctx, p.parseMention, author.ID, statusID, input)
+ }
+
+ // Sanitize input status text and format.
+ contentRes := formatInput(format, content)
+
+ // Gather results of formatted.
+ status.Content = contentRes.HTML
+ status.Mentions = contentRes.Mentions
+ status.Emojis = contentRes.Emojis
+ status.Tags = contentRes.Tags
+
+ // From here-on-out just use emoji-only
+ // plain-text formatting as the FormatFunc.
+ format = p.formatter.FromPlainEmojiOnly
+
+ // Sanitize content warning and format.
+ warning := text.SanitizeToPlaintext(contentWarning)
+ warningRes := formatInput(format, warning)
+
+ // Gather results of the formatted.
+ status.ContentWarning = warningRes.HTML
+ status.Emojis = append(status.Emojis, warningRes.Emojis...)
+
+ if poll != nil {
+ // Pre-allocate slice of poll options of expected length.
+ status.PollOptions = make([]string, len(poll.Options))
+ for i, option := range poll.Options {
+
+ // Sanitize each poll option and format.
+ option = text.SanitizeToPlaintext(option)
+ optionRes := formatInput(format, option)
+
+ // Gather results of the formatted.
+ status.PollOptions[i] = optionRes.HTML
+ status.Emojis = append(status.Emojis, optionRes.Emojis...)
+ }
+
+ // Also update options on the form.
+ poll.Options = status.PollOptions
+ }
+
+ // We may have received multiple copies of the same emoji, deduplicate these first.
+ status.Emojis = xslices.DeduplicateFunc(status.Emojis, func(e *gtsmodel.Emoji) string {
+ return e.ID
+ })
+
+ // Gather up the IDs of mentions from parsed content.
+ status.MentionIDs = xslices.Gather(nil, status.Mentions,
+ func(m *gtsmodel.Mention) string {
+ return m.ID
+ },
+ )
+
+ // Gather up the IDs of tags from parsed content.
+ status.TagIDs = xslices.Gather(nil, status.Tags,
+ func(t *gtsmodel.Tag) string {
+ return t.ID
+ },
+ )
+
+ // Gather up the IDs of emojis in updated content.
+ status.EmojiIDs = xslices.Gather(nil, status.Emojis,
+ func(e *gtsmodel.Emoji) string {
+ return e.ID
+ },
+ )
+
+ return &status, nil
+}
+
+func (p *Processor) processMedia(
+ ctx context.Context,
+ authorID string,
+ statusID string,
+ mediaIDs []string,
+) (
+ []*gtsmodel.MediaAttachment,
+ gtserror.WithCode,
+) {
+ // No media provided!
+ if len(mediaIDs) == 0 {
+ return nil, nil
+ }
+
+ // Get configured min/max supported descr chars.
+ minChars := config.GetMediaDescriptionMinChars()
+ maxChars := config.GetMediaDescriptionMaxChars()
+
+ // Pre-allocate slice of media attachments of expected length.
+ attachments := make([]*gtsmodel.MediaAttachment, len(mediaIDs))
+ for i, id := range mediaIDs {
+
+ // Look for media attachment by ID in database.
+ media, err := p.state.DB.GetAttachmentByID(ctx, id)
+ if err != nil && !errors.Is(err, db.ErrNoEntries) {
+ err := gtserror.Newf("error getting media from db: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ // Check media exists and is owned by author
+ // (this masks finding out media ownership info).
+ if media == nil || media.AccountID != authorID {
+ text := fmt.Sprintf("media not found: %s", id)
+ return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
+ }
+
+ // Check media isn't already attached to another status.
+ if (media.StatusID != "" && media.StatusID != statusID) ||
+ (media.ScheduledStatusID != "" && media.ScheduledStatusID != statusID) {
+ text := fmt.Sprintf("media already attached to status: %s", id)
+ return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
+ }
+
+ // Check media description chars within range,
+ // this needs to be done here as lots of clients
+ // only update media description on status post.
+ switch chars := len([]rune(media.Description)); {
+ case chars < minChars:
+ text := fmt.Sprintf("media description less than min chars (%d)", minChars)
+ return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
+
+ case chars > maxChars:
+ text := fmt.Sprintf("media description exceeds max chars (%d)", maxChars)
+ return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
+ }
+
+ // Set media at index.
+ attachments[i] = media
+ }
+
+ return attachments, nil
+}
+
+func (p *Processor) processPoll(
+ ctx context.Context,
+ statusID string,
+ form *apimodel.PollRequest,
+ now time.Time, // used for expiry time
+) (
+ *gtsmodel.Poll,
+ gtserror.WithCode,
+) {
+ var expiresAt time.Time
+
+ // Set an expiry time if one given.
+ if in := form.ExpiresIn; in > 0 {
+ expiresIn := time.Duration(in)
+ expiresAt = now.Add(expiresIn * time.Second)
+ }
+
+ // Create new poll model.
+ poll := &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
+}
diff --git a/internal/processing/status/create.go b/internal/processing/status/create.go
index 340cf9ff3..af9831b9c 100644
--- a/internal/processing/status/create.go
+++ b/internal/processing/status/create.go
@@ -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,7 +44,17 @@ func (p *Processor) Create(
*apimodel.Status,
gtserror.WithCode,
) {
- // Ensure account populated; we'll need settings.
+ // 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 their 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 err := p.processContent(ctx, p.parseMention, form, 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 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
-}
diff --git a/internal/processing/status/create_test.go b/internal/processing/status/create_test.go
index 84168880e..d0a5c7f92 100644
--- a/internal/processing/status/create_test.go
+++ b/internal/processing/status/create_test.go
@@ -170,7 +170,7 @@ func (suite *StatusCreateTestSuite) TestProcessMediaDescriptionTooShort() {
}
apiStatus, err := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm)
- suite.EqualError(err, "media 01F8MH8RMYQ6MSNY3JM2XT1CQ5 description too short, at least 100 required")
+ suite.EqualError(err, "media description less than min chars (100)")
suite.Nil(apiStatus)
}
diff --git a/internal/processing/status/edit.go b/internal/processing/status/edit.go
new file mode 100644
index 000000000..d16092a57
--- /dev/null
+++ b/internal/processing/status/edit.go
@@ -0,0 +1,555 @@
+// 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"
+ "time"
+
+ "github.com/superseriousbusiness/gotosocial/internal/ap"
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+ "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/util/xslices"
+)
+
+// 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",
+ )
+ }
+
+ // Ensure account populated; we'll need their settings.
+ if err := p.state.DB.PopulateAccount(ctx, requester); err != nil {
+ log.Errorf(ctx, "error(s) populating account, will continue: %s", err)
+ }
+
+ // We need the status populated including all historical edits.
+ if err := p.state.DB.PopulateStatusEdits(ctx, status); err != nil {
+ err := gtserror.Newf("error getting status edits from db: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ // 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) {
+ // 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, content.Emojis...)
+ status.Emojis = xslices.DeduplicateFunc(status.Emojis, emojiByID)
+ status.EmojiIDs = xslices.Gather(status.EmojiIDs[:0], status.Emojis, emojiByID)
+
+ // Update attached status emojis.
+ cols = append(cols, "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 = status.UpdatedAt
+
+ // 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
+ }
+
+ if err := p.state.DB.PopulateStatusEdits(ctx, target); err != nil {
+ err := gtserror.Newf("error getting status edits from db: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ edits, err := p.converter.StatusToAPIEdits(ctx, target)
+ if err != nil {
+ err := gtserror.Newf("error converting status edits: %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 parameters from string.
+ fx, fy, errWithCode := apiutil.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
+}
diff --git a/internal/processing/status/edit_test.go b/internal/processing/status/edit_test.go
new file mode 100644
index 000000000..393c3efc2
--- /dev/null
+++ b/internal/processing/status/edit_test.go
@@ -0,0 +1,544 @@
+// 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_test
+
+import (
+ "context"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/suite"
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
+ "github.com/superseriousbusiness/gotosocial/internal/util/xslices"
+)
+
+type StatusEditTestSuite struct {
+ StatusStandardTestSuite
+}
+
+func (suite *StatusEditTestSuite) TestSimpleEdit() {
+ // 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 a simple status edit.
+ form := &apimodel.StatusEditRequest{
+ Status: "<p>this is some edited status text!</p>",
+ 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: "<p>this is some edited status text!</p>",
+ 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: "<p>this is some edited status text!</p>",
+ 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: "<p>this is some edited status text!</p>",
+ 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: "<p>this is some edited status text!</p>",
+ 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: "<p>this is some edited status text!</p>",
+ 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 @@ 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
}
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, &gtsmodel.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))
}