mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2024-12-25 03:32:12 +00:00
[feature] add support for clients editing statuses and fetching status revision history (#3628)
* start adding client support for making status edits and viewing history * modify 'freshest' freshness window to be 5s, add typeutils test for status -> api edits * only populate the status edits when specifically requested * start adding some simple processor status edit tests * add test editing status but adding a poll * test edits appropriately adding poll expiry handlers * finish adding status edit tests * store both new and old revision emojis in status * add code comment * ensure the requester's account is populated before status edits * add code comments for status edit tests * update status edit form swagger comments * remove unused function * fix status source test * add more code comments, move media description check back to media process in status create * fix tests, add necessary form struct tag
This commit is contained in:
parent
1aa7f70660
commit
fe8d5f2307
|
@ -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: |-
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -27,11 +27,9 @@
|
|||
"github.com/go-playground/form/v4"
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/validate"
|
||||
)
|
||||
|
||||
// StatusCreatePOSTHandler swagger:operation POST /api/v1/statuses statusCreate
|
||||
|
@ -272,9 +270,9 @@ func (m *Module) StatusCreatePOSTHandler(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
form, err := parseStatusCreateForm(c)
|
||||
if err != nil {
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
||||
form, errWithCode := parseStatusCreateForm(c)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -287,11 +285,6 @@ func (m *Module) StatusCreatePOSTHandler(c *gin.Context) {
|
|||
// }
|
||||
// form.Status += "\n\nsent from " + user + "'s iphone\n"
|
||||
|
||||
if errWithCode := validateStatusCreateForm(form); errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
apiStatus, errWithCode := m.processor.Status().Create(
|
||||
c.Request.Context(),
|
||||
authed.Account,
|
||||
|
@ -303,7 +296,7 @@ func (m *Module) StatusCreatePOSTHandler(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, apiStatus)
|
||||
apiutil.JSON(c, http.StatusOK, apiStatus)
|
||||
}
|
||||
|
||||
// intPolicyFormBinding satisfies gin's binding.Binding interface.
|
||||
|
@ -328,108 +321,69 @@ func (intPolicyFormBinding) Bind(req *http.Request, obj any) error {
|
|||
return decoder.Decode(obj, req.Form)
|
||||
}
|
||||
|
||||
func parseStatusCreateForm(c *gin.Context) (*apimodel.StatusCreateRequest, error) {
|
||||
func parseStatusCreateForm(c *gin.Context) (*apimodel.StatusCreateRequest, gtserror.WithCode) {
|
||||
form := new(apimodel.StatusCreateRequest)
|
||||
|
||||
switch ct := c.ContentType(); ct {
|
||||
case binding.MIMEJSON:
|
||||
// Just bind with default json binding.
|
||||
if err := c.ShouldBindWith(form, binding.JSON); err != nil {
|
||||
return nil, err
|
||||
return nil, gtserror.NewErrorBadRequest(
|
||||
err,
|
||||
err.Error(),
|
||||
)
|
||||
}
|
||||
|
||||
case binding.MIMEPOSTForm:
|
||||
// Bind with default form binding first.
|
||||
if err := c.ShouldBindWith(form, binding.FormPost); err != nil {
|
||||
return nil, err
|
||||
return nil, gtserror.NewErrorBadRequest(
|
||||
err,
|
||||
err.Error(),
|
||||
)
|
||||
}
|
||||
|
||||
// Now do custom binding.
|
||||
intReqForm := new(apimodel.StatusInteractionPolicyForm)
|
||||
if err := c.ShouldBindWith(intReqForm, intPolicyFormBinding{}); err != nil {
|
||||
return nil, err
|
||||
return nil, gtserror.NewErrorBadRequest(
|
||||
err,
|
||||
err.Error(),
|
||||
)
|
||||
}
|
||||
|
||||
form.InteractionPolicy = intReqForm.InteractionPolicy
|
||||
|
||||
case binding.MIMEMultipartPOSTForm:
|
||||
// Bind with default form binding first.
|
||||
if err := c.ShouldBindWith(form, binding.FormMultipart); err != nil {
|
||||
return nil, err
|
||||
return nil, gtserror.NewErrorBadRequest(
|
||||
err,
|
||||
err.Error(),
|
||||
)
|
||||
}
|
||||
|
||||
// Now do custom binding.
|
||||
intReqForm := new(apimodel.StatusInteractionPolicyForm)
|
||||
if err := c.ShouldBindWith(intReqForm, intPolicyFormBinding{}); err != nil {
|
||||
return nil, err
|
||||
return nil, gtserror.NewErrorBadRequest(
|
||||
err,
|
||||
err.Error(),
|
||||
)
|
||||
}
|
||||
|
||||
form.InteractionPolicy = intReqForm.InteractionPolicy
|
||||
|
||||
default:
|
||||
err := fmt.Errorf(
|
||||
"content-type %s not supported for this endpoint; supported content-types are %s, %s, %s",
|
||||
ct, binding.MIMEJSON, binding.MIMEPOSTForm, binding.MIMEMultipartPOSTForm,
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return form, nil
|
||||
}
|
||||
|
||||
// validateStatusCreateForm checks the form for disallowed
|
||||
// combinations of attachments, overlength inputs, etc.
|
||||
//
|
||||
// Side effect: normalizes the post's language tag.
|
||||
func validateStatusCreateForm(form *apimodel.StatusCreateRequest) gtserror.WithCode {
|
||||
var (
|
||||
chars = len([]rune(form.Status)) + len([]rune(form.SpoilerText))
|
||||
maxChars = config.GetStatusesMaxChars()
|
||||
mediaFiles = len(form.MediaIDs)
|
||||
maxMediaFiles = config.GetStatusesMediaMaxFiles()
|
||||
hasMedia = mediaFiles != 0
|
||||
hasPoll = form.Poll != nil
|
||||
)
|
||||
|
||||
if chars == 0 && !hasMedia && !hasPoll {
|
||||
// Status must contain *some* kind of content.
|
||||
const text = "no status content, content warning, media, or poll provided"
|
||||
return gtserror.NewErrorBadRequest(errors.New(text), text)
|
||||
}
|
||||
|
||||
if chars > maxChars {
|
||||
text := fmt.Sprintf(
|
||||
"status too long, %d characters provided (including content warning) but limit is %d",
|
||||
chars, maxChars,
|
||||
)
|
||||
return gtserror.NewErrorBadRequest(errors.New(text), text)
|
||||
}
|
||||
|
||||
if mediaFiles > maxMediaFiles {
|
||||
text := fmt.Sprintf(
|
||||
"too many media files attached to status, %d attached but limit is %d",
|
||||
mediaFiles, maxMediaFiles,
|
||||
)
|
||||
return gtserror.NewErrorBadRequest(errors.New(text), text)
|
||||
}
|
||||
|
||||
if form.Poll != nil {
|
||||
if errWithCode := validateStatusPoll(form); errWithCode != nil {
|
||||
return errWithCode
|
||||
}
|
||||
text := fmt.Sprintf("content-type %s not supported for this endpoint; supported content-types are %s, %s, %s",
|
||||
ct, binding.MIMEJSON, binding.MIMEPOSTForm, binding.MIMEMultipartPOSTForm)
|
||||
return nil, gtserror.NewErrorNotAcceptable(errors.New(text), text)
|
||||
}
|
||||
|
||||
// Check not scheduled status.
|
||||
if form.ScheduledAt != "" {
|
||||
const text = "scheduled_at is not yet implemented"
|
||||
return gtserror.NewErrorNotImplemented(errors.New(text), text)
|
||||
}
|
||||
|
||||
// Validate + normalize
|
||||
// language tag if provided.
|
||||
if form.Language != "" {
|
||||
lang, err := validate.Language(form.Language)
|
||||
if err != nil {
|
||||
return gtserror.NewErrorBadRequest(err, err.Error())
|
||||
}
|
||||
form.Language = lang
|
||||
return nil, gtserror.NewErrorNotImplemented(errors.New(text), text)
|
||||
}
|
||||
|
||||
// Check if the deprecated "federated" field was
|
||||
|
@ -438,42 +392,9 @@ func validateStatusCreateForm(form *apimodel.StatusCreateRequest) gtserror.WithC
|
|||
form.LocalOnly = util.Ptr(!*form.Federated) // nolint:staticcheck
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
// Normalize poll expiry time if a poll was given.
|
||||
if form.Poll != nil && form.Poll.ExpiresInI != nil {
|
||||
|
||||
func validateStatusPoll(form *apimodel.StatusCreateRequest) gtserror.WithCode {
|
||||
var (
|
||||
maxPollOptions = config.GetStatusesPollMaxOptions()
|
||||
pollOptions = len(form.Poll.Options)
|
||||
maxPollOptionChars = config.GetStatusesPollOptionMaxChars()
|
||||
)
|
||||
|
||||
if pollOptions == 0 {
|
||||
const text = "poll with no options"
|
||||
return gtserror.NewErrorBadRequest(errors.New(text), text)
|
||||
}
|
||||
|
||||
if pollOptions > maxPollOptions {
|
||||
text := fmt.Sprintf(
|
||||
"too many poll options provided, %d provided but limit is %d",
|
||||
pollOptions, maxPollOptions,
|
||||
)
|
||||
return gtserror.NewErrorBadRequest(errors.New(text), text)
|
||||
}
|
||||
|
||||
for _, option := range form.Poll.Options {
|
||||
optionChars := len([]rune(option))
|
||||
if optionChars > maxPollOptionChars {
|
||||
text := fmt.Sprintf(
|
||||
"poll option too long, %d characters provided but limit is %d",
|
||||
optionChars, maxPollOptionChars,
|
||||
)
|
||||
return gtserror.NewErrorBadRequest(errors.New(text), text)
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize poll expiry if necessary.
|
||||
if form.Poll.ExpiresInI != nil {
|
||||
// If we parsed this as JSON, expires_in
|
||||
// may be either a float64 or a string.
|
||||
expiresIn, err := apiutil.ParseDuration(
|
||||
|
@ -481,13 +402,10 @@ func validateStatusPoll(form *apimodel.StatusCreateRequest) gtserror.WithCode {
|
|||
"expires_in",
|
||||
)
|
||||
if err != nil {
|
||||
return gtserror.NewErrorBadRequest(err, err.Error())
|
||||
return nil, gtserror.NewErrorBadRequest(err, err.Error())
|
||||
}
|
||||
form.Poll.ExpiresIn = util.PtrOrZero(expiresIn)
|
||||
}
|
||||
|
||||
if expiresIn != nil {
|
||||
form.Poll.ExpiresIn = *expiresIn
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return form, nil
|
||||
}
|
||||
|
|
|
@ -95,5 +95,5 @@ func (m *Module) StatusDELETEHandler(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, apiStatus)
|
||||
apiutil.JSON(c, http.StatusOK, apiStatus)
|
||||
}
|
||||
|
|
249
internal/api/client/statuses/statusedit.go
Normal file
249
internal/api/client/statuses/statusedit.go
Normal file
|
@ -0,0 +1,249 @@
|
|||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package statuses
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
)
|
||||
|
||||
// StatusEditPUTHandler swagger:operation PUT /api/v1/statuses statusEdit
|
||||
//
|
||||
// Edit an existing status using the given form field parameters.
|
||||
//
|
||||
// The parameters can also be given in the body of the request, as JSON, if the content-type is set to 'application/json'.
|
||||
//
|
||||
// ---
|
||||
// tags:
|
||||
// - statuses
|
||||
//
|
||||
// consumes:
|
||||
// - application/json
|
||||
// - application/x-www-form-urlencoded
|
||||
//
|
||||
// parameters:
|
||||
// -
|
||||
// name: status
|
||||
// x-go-name: Status
|
||||
// description: |-
|
||||
// Text content of the status.
|
||||
// If media_ids is provided, this becomes optional.
|
||||
// Attaching a poll is optional while status is provided.
|
||||
// type: string
|
||||
// in: formData
|
||||
// -
|
||||
// name: media_ids
|
||||
// x-go-name: MediaIDs
|
||||
// description: |-
|
||||
// Array of Attachment ids to be attached as media.
|
||||
// If provided, status becomes optional, and poll cannot be used.
|
||||
//
|
||||
// If the status is being submitted as a form, the key is 'media_ids[]',
|
||||
// but if it's json or xml, the key is 'media_ids'.
|
||||
// type: array
|
||||
// items:
|
||||
// type: string
|
||||
// in: formData
|
||||
// -
|
||||
// name: poll[options][]
|
||||
// x-go-name: PollOptions
|
||||
// description: |-
|
||||
// Array of possible poll answers.
|
||||
// If provided, media_ids cannot be used, and poll[expires_in] must be provided.
|
||||
// type: array
|
||||
// items:
|
||||
// type: string
|
||||
// in: formData
|
||||
// -
|
||||
// name: poll[expires_in]
|
||||
// x-go-name: PollExpiresIn
|
||||
// description: |-
|
||||
// Duration the poll should be open, in seconds.
|
||||
// If provided, media_ids cannot be used, and poll[options] must be provided.
|
||||
// type: integer
|
||||
// format: int64
|
||||
// in: formData
|
||||
// -
|
||||
// name: poll[multiple]
|
||||
// x-go-name: PollMultiple
|
||||
// description: Allow multiple choices on this poll.
|
||||
// type: boolean
|
||||
// default: false
|
||||
// in: formData
|
||||
// -
|
||||
// name: poll[hide_totals]
|
||||
// x-go-name: PollHideTotals
|
||||
// description: Hide vote counts until the poll ends.
|
||||
// type: boolean
|
||||
// default: true
|
||||
// in: formData
|
||||
// -
|
||||
// name: sensitive
|
||||
// x-go-name: Sensitive
|
||||
// description: Status and attached media should be marked as sensitive.
|
||||
// type: boolean
|
||||
// in: formData
|
||||
// -
|
||||
// name: spoiler_text
|
||||
// x-go-name: SpoilerText
|
||||
// description: |-
|
||||
// Text to be shown as a warning or subject before the actual content.
|
||||
// Statuses are generally collapsed behind this field.
|
||||
// type: string
|
||||
// in: formData
|
||||
// -
|
||||
// name: language
|
||||
// x-go-name: Language
|
||||
// description: ISO 639 language code for this status.
|
||||
// type: string
|
||||
// in: formData
|
||||
// -
|
||||
// name: content_type
|
||||
// x-go-name: ContentType
|
||||
// description: Content type to use when parsing this status.
|
||||
// type: string
|
||||
// enum:
|
||||
// - text/plain
|
||||
// - text/markdown
|
||||
// in: formData
|
||||
//
|
||||
// produces:
|
||||
// - application/json
|
||||
//
|
||||
// security:
|
||||
// - OAuth2 Bearer:
|
||||
// - write:statuses
|
||||
//
|
||||
// responses:
|
||||
// '200':
|
||||
// description: "The latest status revision."
|
||||
// schema:
|
||||
// "$ref": "#/definitions/status"
|
||||
// '400':
|
||||
// description: bad request
|
||||
// '401':
|
||||
// description: unauthorized
|
||||
// '403':
|
||||
// description: forbidden
|
||||
// '404':
|
||||
// description: not found
|
||||
// '406':
|
||||
// description: not acceptable
|
||||
// '500':
|
||||
// description: internal server error
|
||||
func (m *Module) StatusEditPUTHandler(c *gin.Context) {
|
||||
authed, err := oauth.Authed(c, true, true, true, true)
|
||||
if err != nil {
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if authed.Account.IsMoving() {
|
||||
apiutil.ForbiddenAfterMove(c)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
form, errWithCode := parseStatusEditForm(c)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
apiStatus, errWithCode := m.processor.Status().Edit(
|
||||
c.Request.Context(),
|
||||
authed.Account,
|
||||
c.Param(IDKey),
|
||||
form,
|
||||
)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
apiutil.JSON(c, http.StatusOK, apiStatus)
|
||||
}
|
||||
|
||||
func parseStatusEditForm(c *gin.Context) (*apimodel.StatusEditRequest, gtserror.WithCode) {
|
||||
form := new(apimodel.StatusEditRequest)
|
||||
|
||||
switch ct := c.ContentType(); ct {
|
||||
case binding.MIMEJSON:
|
||||
// Just bind with default json binding.
|
||||
if err := c.ShouldBindWith(form, binding.JSON); err != nil {
|
||||
return nil, gtserror.NewErrorBadRequest(
|
||||
err,
|
||||
err.Error(),
|
||||
)
|
||||
}
|
||||
|
||||
case binding.MIMEPOSTForm:
|
||||
// Bind with default form binding first.
|
||||
if err := c.ShouldBindWith(form, binding.FormPost); err != nil {
|
||||
return nil, gtserror.NewErrorBadRequest(
|
||||
err,
|
||||
err.Error(),
|
||||
)
|
||||
}
|
||||
|
||||
case binding.MIMEMultipartPOSTForm:
|
||||
// Bind with default form binding first.
|
||||
if err := c.ShouldBindWith(form, binding.FormMultipart); err != nil {
|
||||
return nil, gtserror.NewErrorBadRequest(
|
||||
err,
|
||||
err.Error(),
|
||||
)
|
||||
}
|
||||
|
||||
default:
|
||||
text := fmt.Sprintf("content-type %s not supported for this endpoint; supported content-types are %s, %s, %s",
|
||||
ct, binding.MIMEJSON, binding.MIMEPOSTForm, binding.MIMEMultipartPOSTForm)
|
||||
return nil, gtserror.NewErrorNotAcceptable(errors.New(text), text)
|
||||
}
|
||||
|
||||
// Normalize poll expiry time if a poll was given.
|
||||
if form.Poll != nil && form.Poll.ExpiresInI != nil {
|
||||
|
||||
// If we parsed this as JSON, expires_in
|
||||
// may be either a float64 or a string.
|
||||
expiresIn, err := apiutil.ParseDuration(
|
||||
form.Poll.ExpiresInI,
|
||||
"expires_in",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorBadRequest(err, err.Error())
|
||||
}
|
||||
form.Poll.ExpiresIn = util.PtrOrZero(expiresIn)
|
||||
}
|
||||
|
||||
return form, nil
|
||||
|
||||
}
|
32
internal/api/client/statuses/statusedit_test.go
Normal file
32
internal/api/client/statuses/statusedit_test.go
Normal file
|
@ -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))
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
|
|
|
@ -23,12 +23,15 @@
|
|||
//
|
||||
// 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
|
||||
|
|
|
@ -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 @@ type StatusInteractionPolicyForm struct {
|
|||
// 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 @@ type StatusInteractionPolicyForm struct {
|
|||
// 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 @@ type StatusInteractionPolicyForm struct {
|
|||
//
|
||||
// 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"`
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -66,7 +66,7 @@
|
|||
// causing loads of dereferencing calls.
|
||||
Fresh = util.Ptr(FreshnessWindow(5 * time.Minute))
|
||||
|
||||
// 10 seconds.
|
||||
// 5 seconds.
|
||||
//
|
||||
// Freshest is useful when you want an
|
||||
// immediately up to date model of something
|
||||
|
@ -74,7 +74,7 @@
|
|||
//
|
||||
// Be careful using this one; it can cause
|
||||
// lots of unnecessary traffic if used unwisely.
|
||||
Freshest = util.Ptr(FreshnessWindow(10 * time.Second))
|
||||
Freshest = util.Ptr(FreshnessWindow(5 * time.Second))
|
||||
)
|
||||
|
||||
// Dereferencer wraps logic and functionality for doing dereferencing
|
||||
|
|
|
@ -35,6 +35,7 @@
|
|||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util/xslices"
|
||||
)
|
||||
|
||||
// statusFresh returns true if the given status is still
|
||||
|
@ -1000,12 +1001,21 @@ func (d *Dereferencer) fetchStatusEmojis(
|
|||
// Set latest emojis.
|
||||
status.Emojis = emojis
|
||||
|
||||
// Iterate over and set changed emoji IDs.
|
||||
// Extract IDs from latest slice of emojis.
|
||||
status.EmojiIDs = make([]string, len(emojis))
|
||||
for i, emoji := range emojis {
|
||||
status.EmojiIDs[i] = emoji.ID
|
||||
}
|
||||
|
||||
// Combine both old and new emojis, as statuses.emojis
|
||||
// keeps track of emojis for both old and current edits.
|
||||
status.EmojiIDs = append(status.EmojiIDs, existing.EmojiIDs...)
|
||||
status.Emojis = append(status.Emojis, existing.Emojis...)
|
||||
status.EmojiIDs = xslices.Deduplicate(status.EmojiIDs)
|
||||
status.Emojis = xslices.DeduplicateFunc(status.Emojis,
|
||||
func(e *gtsmodel.Emoji) string { return e.ID },
|
||||
)
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
|
@ -1118,10 +1128,10 @@ func (d *Dereferencer) handleStatusEdit(
|
|||
var edited bool
|
||||
|
||||
// Preallocate max slice length.
|
||||
cols = make([]string, 0, 13)
|
||||
cols = make([]string, 1, 13)
|
||||
|
||||
// Always update `fetched_at`.
|
||||
cols = append(cols, "fetched_at")
|
||||
cols[0] = "fetched_at"
|
||||
|
||||
// Check for edited status content.
|
||||
if existing.Content != status.Content {
|
||||
|
@ -1187,6 +1197,13 @@ func (d *Dereferencer) handleStatusEdit(
|
|||
// Attached emojis changed.
|
||||
cols = append(cols, "emojis") // i.e. EmojiIDs
|
||||
|
||||
// We specifically store both *new* AND *old* edit
|
||||
// revision emojis in the statuses.emojis column.
|
||||
emojiByID := func(e *gtsmodel.Emoji) string { return e.ID }
|
||||
status.Emojis = append(status.Emojis, existing.Emojis...)
|
||||
status.Emojis = xslices.DeduplicateFunc(status.Emojis, emojiByID)
|
||||
status.EmojiIDs = xslices.Gather(status.EmojiIDs[:0], status.Emojis, emojiByID)
|
||||
|
||||
// Emojis changed doesn't necessarily
|
||||
// indicate an edit, it may just not have
|
||||
// been previously populated properly.
|
||||
|
@ -1230,7 +1247,8 @@ func (d *Dereferencer) handleStatusEdit(
|
|||
// Poll only set if existing contained them.
|
||||
edit.PollOptions = existing.Poll.Options
|
||||
|
||||
if !*existing.Poll.HideCounts || pollChanged {
|
||||
if pollChanged || !*existing.Poll.HideCounts ||
|
||||
!existing.Poll.ClosedAt.IsZero() {
|
||||
// If the counts are allowed to be
|
||||
// shown, or poll has changed, then
|
||||
// include poll vote counts in edit.
|
||||
|
|
|
@ -27,6 +27,7 @@
|
|||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/id"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
)
|
||||
|
||||
|
@ -42,7 +43,7 @@ func (p *Processor) RulesGet(
|
|||
apiRules := make([]*apimodel.AdminInstanceRule, len(rules))
|
||||
|
||||
for i := range rules {
|
||||
apiRules[i] = p.converter.InstanceRuleToAdminAPIRule(&rules[i])
|
||||
apiRules[i] = typeutils.InstanceRuleToAdminAPIRule(&rules[i])
|
||||
}
|
||||
|
||||
return apiRules, nil
|
||||
|
@ -58,7 +59,7 @@ func (p *Processor) RuleGet(ctx context.Context, id string) (*apimodel.AdminInst
|
|||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
return p.converter.InstanceRuleToAdminAPIRule(rule), nil
|
||||
return typeutils.InstanceRuleToAdminAPIRule(rule), nil
|
||||
}
|
||||
|
||||
// RuleCreate adds a new rule to the instance.
|
||||
|
@ -77,7 +78,7 @@ func (p *Processor) RuleCreate(ctx context.Context, form *apimodel.InstanceRuleC
|
|||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
return p.converter.InstanceRuleToAdminAPIRule(rule), nil
|
||||
return typeutils.InstanceRuleToAdminAPIRule(rule), nil
|
||||
}
|
||||
|
||||
// RuleUpdate updates text for an existing rule.
|
||||
|
@ -99,7 +100,7 @@ func (p *Processor) RuleUpdate(ctx context.Context, id string, form *apimodel.In
|
|||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
return p.converter.InstanceRuleToAdminAPIRule(updatedRule), nil
|
||||
return typeutils.InstanceRuleToAdminAPIRule(updatedRule), nil
|
||||
}
|
||||
|
||||
// RuleDelete deletes an existing rule.
|
||||
|
@ -120,5 +121,5 @@ func (p *Processor) RuleDelete(ctx context.Context, id string) (*apimodel.AdminI
|
|||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
return p.converter.InstanceRuleToAdminAPIRule(deletedRule), nil
|
||||
return typeutils.InstanceRuleToAdminAPIRule(deletedRule), nil
|
||||
}
|
||||
|
|
|
@ -31,6 +31,40 @@
|
|||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||
)
|
||||
|
||||
// GetOwnStatus fetches the given status with ID,
|
||||
// and ensures that it belongs to given requester.
|
||||
func (p *Processor) GetOwnStatus(
|
||||
ctx context.Context,
|
||||
requester *gtsmodel.Account,
|
||||
targetID string,
|
||||
) (
|
||||
*gtsmodel.Status,
|
||||
gtserror.WithCode,
|
||||
) {
|
||||
target, err := p.state.DB.GetStatusByID(ctx, targetID)
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
err := gtserror.Newf("error getting from db: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
switch {
|
||||
case target == nil:
|
||||
const text = "target status not found"
|
||||
return nil, gtserror.NewErrorNotFound(
|
||||
errors.New(text),
|
||||
text,
|
||||
)
|
||||
|
||||
case target.AccountID != requester.ID:
|
||||
return nil, gtserror.NewErrorNotFound(
|
||||
errors.New("status does not belong to requester"),
|
||||
"target status not found",
|
||||
)
|
||||
}
|
||||
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// GetTargetStatusBy fetches the target status with db load
|
||||
// function, given the authorized (or, nil) requester's
|
||||
// account. This returns an approprate gtserror.WithCode
|
||||
|
|
|
@ -29,6 +29,7 @@
|
|||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/text"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/validate"
|
||||
)
|
||||
|
@ -133,7 +134,7 @@ func (p *Processor) InstanceGetRules(ctx context.Context) ([]apimodel.InstanceRu
|
|||
return nil, gtserror.NewErrorInternalError(fmt.Errorf("db error fetching instance: %s", err))
|
||||
}
|
||||
|
||||
return p.converter.InstanceRulesToAPIRules(i.Rules), nil
|
||||
return typeutils.InstanceRulesToAPIRules(i.Rules), nil
|
||||
}
|
||||
|
||||
func (p *Processor) InstancePatch(ctx context.Context, form *apimodel.InstanceSettingsUpdateRequest) (*apimodel.InstanceV1, gtserror.WithCode) {
|
||||
|
|
|
@ -25,6 +25,7 @@
|
|||
|
||||
"codeberg.org/gruf/go-iotools"
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
|
@ -45,10 +46,21 @@ func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, form
|
|||
}
|
||||
|
||||
// Parse focus details from API form input.
|
||||
focusX, focusY, err := parseFocus(form.Focus)
|
||||
if err != nil {
|
||||
text := fmt.Sprintf("could not parse focus value %s: %s", form.Focus, err)
|
||||
return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
|
||||
focusX, focusY, errWithCode := apiutil.ParseFocus(form.Focus)
|
||||
if errWithCode != nil {
|
||||
return nil, errWithCode
|
||||
}
|
||||
|
||||
// If description provided,
|
||||
// process and validate it.
|
||||
//
|
||||
// This may not yet be set as it
|
||||
// is often set on status post.
|
||||
if form.Description != "" {
|
||||
form.Description, errWithCode = processDescription(form.Description)
|
||||
if errWithCode != nil {
|
||||
return nil, errWithCode
|
||||
}
|
||||
}
|
||||
|
||||
// Open multipart file reader.
|
||||
|
@ -58,7 +70,7 @@ func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, form
|
|||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
// Wrap the multipart file reader to ensure is limited to max.
|
||||
// Wrap multipart file reader to ensure is limited to max size.
|
||||
rc, _, _ := iotools.UpdateReadCloserLimit(mpfile, maxszInt64)
|
||||
|
||||
// Create local media and write to instance storage.
|
||||
|
|
|
@ -23,6 +23,8 @@
|
|||
"fmt"
|
||||
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
|
@ -47,17 +49,27 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, media
|
|||
var updatingColumns []string
|
||||
|
||||
if form.Description != nil {
|
||||
attachment.Description = text.SanitizeToPlaintext(*form.Description)
|
||||
// Sanitize and validate incoming description.
|
||||
description, errWithCode := processDescription(
|
||||
*form.Description,
|
||||
)
|
||||
if errWithCode != nil {
|
||||
return nil, errWithCode
|
||||
}
|
||||
|
||||
attachment.Description = description
|
||||
updatingColumns = append(updatingColumns, "description")
|
||||
}
|
||||
|
||||
if form.Focus != nil {
|
||||
focusx, focusy, err := parseFocus(*form.Focus)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorBadRequest(err)
|
||||
// Parse focus details from API form input.
|
||||
focusX, focusY, errWithCode := apiutil.ParseFocus(*form.Focus)
|
||||
if errWithCode != nil {
|
||||
return nil, errWithCode
|
||||
}
|
||||
attachment.FileMeta.Focus.X = focusx
|
||||
attachment.FileMeta.Focus.Y = focusy
|
||||
|
||||
attachment.FileMeta.Focus.X = focusX
|
||||
attachment.FileMeta.Focus.Y = focusY
|
||||
updatingColumns = append(updatingColumns, "focus_x", "focus_y")
|
||||
}
|
||||
|
||||
|
@ -72,3 +84,21 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, media
|
|||
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
// processDescription will sanitize and valid description against server configuration.
|
||||
func processDescription(description string) (string, gtserror.WithCode) {
|
||||
description = text.SanitizeToPlaintext(description)
|
||||
chars := len([]rune(description))
|
||||
|
||||
if min := config.GetMediaDescriptionMinChars(); chars < min {
|
||||
text := fmt.Sprintf("media description less than min chars (%d)", min)
|
||||
return "", gtserror.NewErrorBadRequest(errors.New(text), text)
|
||||
}
|
||||
|
||||
if max := config.GetMediaDescriptionMaxChars(); chars > max {
|
||||
text := fmt.Sprintf("media description exceeds max chars (%d)", max)
|
||||
return "", gtserror.NewErrorBadRequest(errors.New(text), text)
|
||||
}
|
||||
|
||||
return description, nil
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
351
internal/processing/status/common.go
Normal file
351
internal/processing/status/common.go
Normal file
|
@ -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 := >smodel.Poll{
|
||||
ID: id.NewULIDFromTime(now),
|
||||
Multiple: &form.Multiple,
|
||||
HideCounts: &form.HideTotals,
|
||||
Options: form.Options,
|
||||
StatusID: statusID,
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
|
||||
// Insert the newly created poll model in the database.
|
||||
if err := p.state.DB.PutPoll(ctx, poll); err != nil {
|
||||
err := gtserror.Newf("error inserting poll in db: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
return poll, nil
|
||||
}
|
|
@ -19,29 +19,22 @@
|
|||
|
||||
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 status.ContentWarning != "" && len(status.AttachmentIDs) > 0 {
|
||||
// If a content-warning is set, and
|
||||
// the status contains media, always
|
||||
// set the status sensitive flag.
|
||||
status.Sensitive = util.Ptr(true)
|
||||
}
|
||||
|
||||
if err := p.processContent(ctx, p.parseMention, form, status); err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
if form.Poll != nil {
|
||||
// Process poll, inserting into database.
|
||||
poll, errWithCode := p.processPoll(ctx,
|
||||
statusID,
|
||||
form.Poll,
|
||||
now,
|
||||
)
|
||||
if errWithCode != nil {
|
||||
return nil, errWithCode
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
// 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 = >smodel.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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
555
internal/processing/status/edit.go
Normal file
555
internal/processing/status/edit.go
Normal file
|
@ -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
|
||||
}
|
544
internal/processing/status/edit_test.go
Normal file
544
internal/processing/status/edit_test.go
Normal file
|
@ -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))
|
||||
}
|
|
@ -19,47 +19,16 @@
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
)
|
||||
|
||||
// HistoryGet gets edit history for the target status, taking account of privacy settings and blocks etc.
|
||||
// TODO: currently this just returns the latest version of the status.
|
||||
func (p *Processor) HistoryGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) ([]*apimodel.StatusEdit, gtserror.WithCode) {
|
||||
targetStatus, errWithCode := p.c.GetVisibleTargetStatus(ctx,
|
||||
requestingAccount,
|
||||
targetStatusID,
|
||||
nil, // default freshness
|
||||
)
|
||||
if errWithCode != nil {
|
||||
return nil, errWithCode
|
||||
}
|
||||
|
||||
apiStatus, errWithCode := p.c.GetAPIStatus(ctx, requestingAccount, targetStatus)
|
||||
if errWithCode != nil {
|
||||
return nil, errWithCode
|
||||
}
|
||||
|
||||
return []*apimodel.StatusEdit{
|
||||
{
|
||||
Content: apiStatus.Content,
|
||||
SpoilerText: apiStatus.SpoilerText,
|
||||
Sensitive: apiStatus.Sensitive,
|
||||
CreatedAt: util.FormatISO8601(targetStatus.UpdatedAt),
|
||||
Account: apiStatus.Account,
|
||||
Poll: apiStatus.Poll,
|
||||
MediaAttachments: apiStatus.MediaAttachments,
|
||||
Emojis: apiStatus.Emojis,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Get gets the given status, taking account of privacy settings and blocks etc.
|
||||
func (p *Processor) Get(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) {
|
||||
targetStatus, errWithCode := p.c.GetVisibleTargetStatus(ctx,
|
||||
target, errWithCode := p.c.GetVisibleTargetStatus(ctx,
|
||||
requestingAccount,
|
||||
targetStatusID,
|
||||
nil, // default freshness
|
||||
|
@ -67,44 +36,25 @@ func (p *Processor) Get(ctx context.Context, requestingAccount *gtsmodel.Account
|
|||
if errWithCode != nil {
|
||||
return nil, errWithCode
|
||||
}
|
||||
return p.c.GetAPIStatus(ctx, requestingAccount, targetStatus)
|
||||
return p.c.GetAPIStatus(ctx, requestingAccount, target)
|
||||
}
|
||||
|
||||
// SourceGet returns the *apimodel.StatusSource version of the targetStatusID.
|
||||
// Status must belong to the requester, and must not be a boost.
|
||||
func (p *Processor) SourceGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*apimodel.StatusSource, gtserror.WithCode) {
|
||||
targetStatus, errWithCode := p.c.GetVisibleTargetStatus(ctx,
|
||||
requestingAccount,
|
||||
targetStatusID,
|
||||
nil, // default freshness
|
||||
)
|
||||
func (p *Processor) SourceGet(ctx context.Context, requester *gtsmodel.Account, statusID string) (*apimodel.StatusSource, gtserror.WithCode) {
|
||||
status, errWithCode := p.c.GetOwnStatus(ctx, requester, statusID)
|
||||
if errWithCode != nil {
|
||||
return nil, errWithCode
|
||||
}
|
||||
|
||||
// Redirect to wrapped status if boost.
|
||||
targetStatus, errWithCode = p.c.UnwrapIfBoost(
|
||||
ctx,
|
||||
requestingAccount,
|
||||
targetStatus,
|
||||
if status.BoostOfID != "" {
|
||||
return nil, gtserror.NewErrorNotFound(
|
||||
errors.New("status is a boost wrapper"),
|
||||
"target status not found",
|
||||
)
|
||||
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,
|
||||
)
|
||||
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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -1216,21 +1216,6 @@ func (c *Converter) StatusToWebStatus(
|
|||
return webStatus, nil
|
||||
}
|
||||
|
||||
// StatusToAPIStatusSource returns the *apimodel.StatusSource of the given status.
|
||||
// Callers should check beforehand whether a requester has permission to view the
|
||||
// source of the status, and ensure they're passing only a local status into this function.
|
||||
func (c *Converter) StatusToAPIStatusSource(ctx context.Context, s *gtsmodel.Status) (*apimodel.StatusSource, error) {
|
||||
// TODO: remove this when edit support is added.
|
||||
text := "**STATUS EDITS ARE NOT CURRENTLY SUPPORTED IN GOTOSOCIAL (coming in 2024)**\n" +
|
||||
"You can review the original text of your status below, but you will not be able to submit this edit.\n\n---\n\n" + s.Text
|
||||
|
||||
return &apimodel.StatusSource{
|
||||
ID: s.ID,
|
||||
Text: text,
|
||||
SpoilerText: s.ContentWarning,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// statusToFrontend is a package internal function for
|
||||
// parsing a status into its initial frontend representation.
|
||||
//
|
||||
|
@ -1472,6 +1457,149 @@ func (c *Converter) baseStatusToFrontend(
|
|||
return apiStatus, nil
|
||||
}
|
||||
|
||||
// StatusToAPIEdits converts a status and its historical edits (if any) to a slice of API model status edits.
|
||||
func (c *Converter) StatusToAPIEdits(ctx context.Context, status *gtsmodel.Status) ([]*apimodel.StatusEdit, error) {
|
||||
var media map[string]*gtsmodel.MediaAttachment
|
||||
|
||||
// Gather attachments of status AND edits.
|
||||
attachmentIDs := status.AllAttachmentIDs()
|
||||
if len(attachmentIDs) > 0 {
|
||||
|
||||
// Fetch all of the gathered status attachments from the database.
|
||||
attachments, err := c.state.DB.GetAttachmentsByIDs(ctx, attachmentIDs)
|
||||
if err != nil {
|
||||
return nil, gtserror.Newf("error getting attachments from db: %w", err)
|
||||
}
|
||||
|
||||
// Generate a lookup map in 'media' of status attachments by their IDs.
|
||||
media = util.KeyBy(attachments, func(m *gtsmodel.MediaAttachment) string {
|
||||
return m.ID
|
||||
})
|
||||
}
|
||||
|
||||
// Convert the status author account to API model.
|
||||
apiAccount, err := c.AccountToAPIAccountPublic(ctx,
|
||||
status.Account,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, gtserror.Newf("error converting account: %w", err)
|
||||
}
|
||||
|
||||
// Convert status emojis to their API models,
|
||||
// this includes all status emojis both current
|
||||
// and historic, so it gets passed to each edit.
|
||||
apiEmojis, err := c.convertEmojisToAPIEmojis(ctx,
|
||||
nil,
|
||||
status.EmojiIDs,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, gtserror.Newf("error converting emojis: %w", err)
|
||||
}
|
||||
|
||||
var votes []int
|
||||
var options []string
|
||||
|
||||
if status.Poll != nil {
|
||||
// Extract status poll options.
|
||||
options = status.Poll.Options
|
||||
|
||||
// Show votes only if closed / allowed.
|
||||
if !status.Poll.ClosedAt.IsZero() ||
|
||||
!*status.Poll.HideCounts {
|
||||
votes = status.Poll.Votes
|
||||
}
|
||||
}
|
||||
|
||||
// Append status itself to final slot in the edits
|
||||
// so we can add its revision using the below loop.
|
||||
edits := append(status.Edits, >smodel.StatusEdit{ //nolint:gocritic
|
||||
Content: status.Content,
|
||||
ContentWarning: status.ContentWarning,
|
||||
Sensitive: status.Sensitive,
|
||||
PollOptions: options,
|
||||
PollVotes: votes,
|
||||
AttachmentIDs: status.AttachmentIDs,
|
||||
AttachmentDescriptions: nil, // no change from current
|
||||
CreatedAt: status.UpdatedAt,
|
||||
})
|
||||
|
||||
// Iterate through status edits, starting at newest.
|
||||
apiEdits := make([]*apimodel.StatusEdit, 0, len(edits))
|
||||
for i := len(edits) - 1; i >= 0; i-- {
|
||||
edit := edits[i]
|
||||
|
||||
// Iterate through edit attachment IDs, getting model from 'media' lookup.
|
||||
apiAttachments := make([]*apimodel.Attachment, 0, len(edit.AttachmentIDs))
|
||||
for _, id := range edit.AttachmentIDs {
|
||||
attachment, ok := media[id]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Convert each media attachment to frontend API model.
|
||||
apiAttachment, err := c.AttachmentToAPIAttachment(ctx,
|
||||
attachment,
|
||||
)
|
||||
if err != nil {
|
||||
log.Error(ctx, "error converting attachment: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Append converted media attachment to return slice.
|
||||
apiAttachments = append(apiAttachments, &apiAttachment)
|
||||
}
|
||||
|
||||
// If media descriptions are set, update API model descriptions.
|
||||
if len(edit.AttachmentIDs) == len(edit.AttachmentDescriptions) {
|
||||
var j int
|
||||
for i, id := range edit.AttachmentIDs {
|
||||
descr := edit.AttachmentDescriptions[i]
|
||||
for ; j < len(apiAttachments); j++ {
|
||||
if apiAttachments[j].ID == id {
|
||||
apiAttachments[j].Description = &descr
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Attach status poll if set.
|
||||
var apiPoll *apimodel.Poll
|
||||
if len(edit.PollOptions) > 0 {
|
||||
apiPoll = new(apimodel.Poll)
|
||||
|
||||
// Iterate through poll options and attach to API poll model.
|
||||
apiPoll.Options = make([]apimodel.PollOption, len(edit.PollOptions))
|
||||
for i, option := range edit.PollOptions {
|
||||
apiPoll.Options[i] = apimodel.PollOption{
|
||||
Title: option,
|
||||
}
|
||||
}
|
||||
|
||||
// If poll votes are attached, set vote counts.
|
||||
if len(edit.PollVotes) == len(apiPoll.Options) {
|
||||
for i, votes := range edit.PollVotes {
|
||||
apiPoll.Options[i].VotesCount = &votes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Append this status edit to the return slice.
|
||||
apiEdits = append(apiEdits, &apimodel.StatusEdit{
|
||||
CreatedAt: util.FormatISO8601(edit.CreatedAt),
|
||||
Content: edit.Content,
|
||||
SpoilerText: edit.ContentWarning,
|
||||
Sensitive: util.PtrOrZero(edit.Sensitive),
|
||||
Account: apiAccount,
|
||||
Poll: apiPoll,
|
||||
MediaAttachments: apiAttachments,
|
||||
Emojis: apiEmojis, // same models used for whole status + all edits
|
||||
})
|
||||
}
|
||||
|
||||
return apiEdits, nil
|
||||
}
|
||||
|
||||
// VisToAPIVis converts a gts visibility into its api equivalent
|
||||
func (c *Converter) VisToAPIVis(ctx context.Context, m gtsmodel.Visibility) apimodel.Visibility {
|
||||
switch m {
|
||||
|
@ -1488,7 +1616,7 @@ func (c *Converter) VisToAPIVis(ctx context.Context, m gtsmodel.Visibility) apim
|
|||
}
|
||||
|
||||
// InstanceRuleToAdminAPIRule converts a local instance rule into its api equivalent for serving at /api/v1/admin/instance/rules/:id
|
||||
func (c *Converter) InstanceRuleToAPIRule(r gtsmodel.Rule) apimodel.InstanceRule {
|
||||
func InstanceRuleToAPIRule(r gtsmodel.Rule) apimodel.InstanceRule {
|
||||
return apimodel.InstanceRule{
|
||||
ID: r.ID,
|
||||
Text: r.Text,
|
||||
|
@ -1496,18 +1624,16 @@ func (c *Converter) InstanceRuleToAPIRule(r gtsmodel.Rule) apimodel.InstanceRule
|
|||
}
|
||||
|
||||
// InstanceRulesToAPIRules converts all local instance rules into their api equivalent for serving at /api/v1/instance/rules
|
||||
func (c *Converter) InstanceRulesToAPIRules(r []gtsmodel.Rule) []apimodel.InstanceRule {
|
||||
func InstanceRulesToAPIRules(r []gtsmodel.Rule) []apimodel.InstanceRule {
|
||||
rules := make([]apimodel.InstanceRule, len(r))
|
||||
|
||||
for i, v := range r {
|
||||
rules[i] = c.InstanceRuleToAPIRule(v)
|
||||
rules[i] = InstanceRuleToAPIRule(v)
|
||||
}
|
||||
|
||||
return rules
|
||||
}
|
||||
|
||||
// InstanceRuleToAdminAPIRule converts a local instance rule into its api equivalent for serving at /api/v1/admin/instance/rules/:id
|
||||
func (c *Converter) InstanceRuleToAdminAPIRule(r *gtsmodel.Rule) *apimodel.AdminInstanceRule {
|
||||
func InstanceRuleToAdminAPIRule(r *gtsmodel.Rule) *apimodel.AdminInstanceRule {
|
||||
return &apimodel.AdminInstanceRule{
|
||||
ID: r.ID,
|
||||
CreatedAt: util.FormatISO8601(r.CreatedAt),
|
||||
|
@ -1540,7 +1666,7 @@ func (c *Converter) InstanceToAPIV1Instance(ctx context.Context, i *gtsmodel.Ins
|
|||
ApprovalRequired: true, // approval always required
|
||||
InvitesEnabled: false, // todo: not supported yet
|
||||
MaxTootChars: uint(config.GetStatusesMaxChars()), // #nosec G115 -- Already validated.
|
||||
Rules: c.InstanceRulesToAPIRules(i.Rules),
|
||||
Rules: InstanceRulesToAPIRules(i.Rules),
|
||||
Terms: i.Terms,
|
||||
TermsRaw: i.TermsText,
|
||||
}
|
||||
|
@ -1674,7 +1800,7 @@ func (c *Converter) InstanceToAPIV2Instance(ctx context.Context, i *gtsmodel.Ins
|
|||
CustomCSS: i.CustomCSS,
|
||||
Usage: apimodel.InstanceV2Usage{}, // todo: not implemented
|
||||
Languages: config.GetInstanceLanguages().TagStrs(),
|
||||
Rules: c.InstanceRulesToAPIRules(i.Rules),
|
||||
Rules: InstanceRulesToAPIRules(i.Rules),
|
||||
Terms: i.Terms,
|
||||
TermsText: i.TermsText,
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue