mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2024-12-25 11:42:11 +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.
|
summary: Create a new status using the given form field parameters.
|
||||||
tags:
|
tags:
|
||||||
- statuses
|
- 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}:
|
/api/v1/statuses/{id}:
|
||||||
delete:
|
delete:
|
||||||
description: |-
|
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) {
|
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.MethodPost, BasePath, m.StatusCreatePOSTHandler)
|
||||||
attachHandler(http.MethodGet, BasePathWithID, m.StatusGETHandler)
|
attachHandler(http.MethodGet, BasePathWithID, m.StatusGETHandler)
|
||||||
|
attachHandler(http.MethodPut, BasePathWithID, m.StatusEditPUTHandler)
|
||||||
attachHandler(http.MethodDelete, BasePathWithID, m.StatusDELETEHandler)
|
attachHandler(http.MethodDelete, BasePathWithID, m.StatusDELETEHandler)
|
||||||
|
|
||||||
// fave stuff
|
// fave stuff
|
||||||
|
|
|
@ -27,11 +27,9 @@
|
||||||
"github.com/go-playground/form/v4"
|
"github.com/go-playground/form/v4"
|
||||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
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/gtserror"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/validate"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// StatusCreatePOSTHandler swagger:operation POST /api/v1/statuses statusCreate
|
// StatusCreatePOSTHandler swagger:operation POST /api/v1/statuses statusCreate
|
||||||
|
@ -272,9 +270,9 @@ func (m *Module) StatusCreatePOSTHandler(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
form, err := parseStatusCreateForm(c)
|
form, errWithCode := parseStatusCreateForm(c)
|
||||||
if err != nil {
|
if errWithCode != nil {
|
||||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -287,11 +285,6 @@ func (m *Module) StatusCreatePOSTHandler(c *gin.Context) {
|
||||||
// }
|
// }
|
||||||
// form.Status += "\n\nsent from " + user + "'s iphone\n"
|
// 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(
|
apiStatus, errWithCode := m.processor.Status().Create(
|
||||||
c.Request.Context(),
|
c.Request.Context(),
|
||||||
authed.Account,
|
authed.Account,
|
||||||
|
@ -303,7 +296,7 @@ func (m *Module) StatusCreatePOSTHandler(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, apiStatus)
|
apiutil.JSON(c, http.StatusOK, apiStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
// intPolicyFormBinding satisfies gin's binding.Binding interface.
|
// 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)
|
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)
|
form := new(apimodel.StatusCreateRequest)
|
||||||
|
|
||||||
switch ct := c.ContentType(); ct {
|
switch ct := c.ContentType(); ct {
|
||||||
case binding.MIMEJSON:
|
case binding.MIMEJSON:
|
||||||
// Just bind with default json binding.
|
// Just bind with default json binding.
|
||||||
if err := c.ShouldBindWith(form, binding.JSON); err != nil {
|
if err := c.ShouldBindWith(form, binding.JSON); err != nil {
|
||||||
return nil, err
|
return nil, gtserror.NewErrorBadRequest(
|
||||||
|
err,
|
||||||
|
err.Error(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
case binding.MIMEPOSTForm:
|
case binding.MIMEPOSTForm:
|
||||||
// Bind with default form binding first.
|
// Bind with default form binding first.
|
||||||
if err := c.ShouldBindWith(form, binding.FormPost); err != nil {
|
if err := c.ShouldBindWith(form, binding.FormPost); err != nil {
|
||||||
return nil, err
|
return nil, gtserror.NewErrorBadRequest(
|
||||||
|
err,
|
||||||
|
err.Error(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now do custom binding.
|
// Now do custom binding.
|
||||||
intReqForm := new(apimodel.StatusInteractionPolicyForm)
|
intReqForm := new(apimodel.StatusInteractionPolicyForm)
|
||||||
if err := c.ShouldBindWith(intReqForm, intPolicyFormBinding{}); err != nil {
|
if err := c.ShouldBindWith(intReqForm, intPolicyFormBinding{}); err != nil {
|
||||||
return nil, err
|
return nil, gtserror.NewErrorBadRequest(
|
||||||
|
err,
|
||||||
|
err.Error(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
form.InteractionPolicy = intReqForm.InteractionPolicy
|
form.InteractionPolicy = intReqForm.InteractionPolicy
|
||||||
|
|
||||||
case binding.MIMEMultipartPOSTForm:
|
case binding.MIMEMultipartPOSTForm:
|
||||||
// Bind with default form binding first.
|
// Bind with default form binding first.
|
||||||
if err := c.ShouldBindWith(form, binding.FormMultipart); err != nil {
|
if err := c.ShouldBindWith(form, binding.FormMultipart); err != nil {
|
||||||
return nil, err
|
return nil, gtserror.NewErrorBadRequest(
|
||||||
|
err,
|
||||||
|
err.Error(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now do custom binding.
|
// Now do custom binding.
|
||||||
intReqForm := new(apimodel.StatusInteractionPolicyForm)
|
intReqForm := new(apimodel.StatusInteractionPolicyForm)
|
||||||
if err := c.ShouldBindWith(intReqForm, intPolicyFormBinding{}); err != nil {
|
if err := c.ShouldBindWith(intReqForm, intPolicyFormBinding{}); err != nil {
|
||||||
return nil, err
|
return nil, gtserror.NewErrorBadRequest(
|
||||||
|
err,
|
||||||
|
err.Error(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
form.InteractionPolicy = intReqForm.InteractionPolicy
|
form.InteractionPolicy = intReqForm.InteractionPolicy
|
||||||
|
|
||||||
default:
|
default:
|
||||||
err := fmt.Errorf(
|
text := fmt.Sprintf("content-type %s not supported for this endpoint; supported content-types are %s, %s, %s",
|
||||||
"content-type %s not supported for this endpoint; supported content-types are %s, %s, %s",
|
ct, binding.MIMEJSON, binding.MIMEPOSTForm, binding.MIMEMultipartPOSTForm)
|
||||||
ct, binding.MIMEJSON, binding.MIMEPOSTForm, binding.MIMEMultipartPOSTForm,
|
return nil, gtserror.NewErrorNotAcceptable(errors.New(text), text)
|
||||||
)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check not scheduled status.
|
||||||
if form.ScheduledAt != "" {
|
if form.ScheduledAt != "" {
|
||||||
const text = "scheduled_at is not yet implemented"
|
const text = "scheduled_at is not yet implemented"
|
||||||
return gtserror.NewErrorNotImplemented(errors.New(text), text)
|
return nil, 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the deprecated "federated" field was
|
// 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
|
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
|
// If we parsed this as JSON, expires_in
|
||||||
// may be either a float64 or a string.
|
// may be either a float64 or a string.
|
||||||
expiresIn, err := apiutil.ParseDuration(
|
expiresIn, err := apiutil.ParseDuration(
|
||||||
|
@ -481,13 +402,10 @@ func validateStatusPoll(form *apimodel.StatusCreateRequest) gtserror.WithCode {
|
||||||
"expires_in",
|
"expires_in",
|
||||||
)
|
)
|
||||||
if err != nil {
|
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 {
|
return form, nil
|
||||||
form.Poll.ExpiresIn = *expiresIn
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -95,5 +95,5 @@ func (m *Module) StatusDELETEHandler(c *gin.Context) {
|
||||||
return
|
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(`{
|
suite.Equal(`{
|
||||||
"id": "01F8MHAMCHF6Y650WCRSCP4WMY",
|
"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"
|
"spoiler_text": "introduction post"
|
||||||
}`, dst.String())
|
}`, dst.String())
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,12 +23,15 @@
|
||||||
//
|
//
|
||||||
// swagger: ignore
|
// swagger: ignore
|
||||||
type AttachmentRequest struct {
|
type AttachmentRequest struct {
|
||||||
|
|
||||||
// Media file.
|
// Media file.
|
||||||
File *multipart.FileHeader `form:"file" binding:"required"`
|
File *multipart.FileHeader `form:"file" binding:"required"`
|
||||||
|
|
||||||
// Description of the media file. Optional.
|
// Description of the media file. Optional.
|
||||||
// This will be used as alt-text for users of screenreaders etc.
|
// 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.
|
// example: This is an image of some kittens, they are very cute and fluffy.
|
||||||
Description string `form:"description"`
|
Description string `form:"description"`
|
||||||
|
|
||||||
// Focus of the media file. Optional.
|
// Focus of the media file. Optional.
|
||||||
// If present, it should be in the form of two comma-separated floats between -1 and 1.
|
// If present, it should be in the form of two comma-separated floats between -1 and 1.
|
||||||
// example: -0.5,0.565
|
// example: -0.5,0.565
|
||||||
|
@ -39,16 +42,38 @@ type AttachmentRequest struct {
|
||||||
//
|
//
|
||||||
// swagger:ignore
|
// swagger:ignore
|
||||||
type AttachmentUpdateRequest struct {
|
type AttachmentUpdateRequest struct {
|
||||||
|
|
||||||
// Description of the media file.
|
// Description of the media file.
|
||||||
// This will be used as alt-text for users of screenreaders etc.
|
// This will be used as alt-text for users of screenreaders etc.
|
||||||
// allowEmptyValue: true
|
// allowEmptyValue: true
|
||||||
Description *string `form:"description" json:"description" xml:"description"`
|
Description *string `form:"description" json:"description" xml:"description"`
|
||||||
|
|
||||||
// Focus of the media file.
|
// Focus of the media file.
|
||||||
// If present, it should be in the form of two comma-separated floats between -1 and 1.
|
// If present, it should be in the form of two comma-separated floats between -1 and 1.
|
||||||
// allowEmptyValue: true
|
// allowEmptyValue: true
|
||||||
Focus *string `form:"focus" json:"focus" xml:"focus"`
|
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.
|
// Attachment models a media attachment.
|
||||||
//
|
//
|
||||||
// swagger:model attachment
|
// swagger:model attachment
|
||||||
|
|
|
@ -197,36 +197,50 @@ type StatusReblogged struct {
|
||||||
//
|
//
|
||||||
// swagger:ignore
|
// swagger:ignore
|
||||||
type StatusCreateRequest struct {
|
type StatusCreateRequest struct {
|
||||||
|
|
||||||
// Text content of the status.
|
// Text content of the status.
|
||||||
// If media_ids is provided, this becomes optional.
|
// If media_ids is provided, this becomes optional.
|
||||||
// Attaching a poll is optional while status is provided.
|
// Attaching a poll is optional while status is provided.
|
||||||
Status string `form:"status" json:"status"`
|
Status string `form:"status" json:"status"`
|
||||||
|
|
||||||
// Array of Attachment ids to be attached as media.
|
// Array of Attachment ids to be attached as media.
|
||||||
// If provided, status becomes optional, and poll cannot be used.
|
// If provided, status becomes optional, and poll cannot be used.
|
||||||
MediaIDs []string `form:"media_ids[]" json:"media_ids"`
|
MediaIDs []string `form:"media_ids[]" json:"media_ids"`
|
||||||
|
|
||||||
// Poll to include with this status.
|
// Poll to include with this status.
|
||||||
Poll *PollRequest `form:"poll" json:"poll"`
|
Poll *PollRequest `form:"poll" json:"poll"`
|
||||||
|
|
||||||
// ID of the status being replied to, if status is a reply.
|
// ID of the status being replied to, if status is a reply.
|
||||||
InReplyToID string `form:"in_reply_to_id" json:"in_reply_to_id"`
|
InReplyToID string `form:"in_reply_to_id" json:"in_reply_to_id"`
|
||||||
|
|
||||||
// Status and attached media should be marked as sensitive.
|
// Status and attached media should be marked as sensitive.
|
||||||
Sensitive bool `form:"sensitive" json:"sensitive"`
|
Sensitive bool `form:"sensitive" json:"sensitive"`
|
||||||
|
|
||||||
// Text to be shown as a warning or subject before the actual content.
|
// Text to be shown as a warning or subject before the actual content.
|
||||||
// Statuses are generally collapsed behind this field.
|
// Statuses are generally collapsed behind this field.
|
||||||
SpoilerText string `form:"spoiler_text" json:"spoiler_text"`
|
SpoilerText string `form:"spoiler_text" json:"spoiler_text"`
|
||||||
|
|
||||||
// Visibility of the posted status.
|
// Visibility of the posted status.
|
||||||
Visibility Visibility `form:"visibility" json:"visibility"`
|
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"`
|
LocalOnly *bool `form:"local_only" json:"local_only"`
|
||||||
|
|
||||||
// Deprecated: Only used if LocalOnly is not set.
|
// Deprecated: Only used if LocalOnly is not set.
|
||||||
Federated *bool `form:"federated" json:"federated"`
|
Federated *bool `form:"federated" json:"federated"`
|
||||||
|
|
||||||
// ISO 8601 Datetime at which to schedule a status.
|
// ISO 8601 Datetime at which to schedule a status.
|
||||||
// Providing this parameter will cause ScheduledStatus to be returned instead of Status.
|
// Providing this parameter will cause ScheduledStatus to be returned instead of Status.
|
||||||
// Must be at least 5 minutes in the future.
|
// Must be at least 5 minutes in the future.
|
||||||
ScheduledAt string `form:"scheduled_at" json:"scheduled_at"`
|
ScheduledAt string `form:"scheduled_at" json:"scheduled_at"`
|
||||||
|
|
||||||
// ISO 639 language code for this status.
|
// ISO 639 language code for this status.
|
||||||
Language string `form:"language" json:"language"`
|
Language string `form:"language" json:"language"`
|
||||||
|
|
||||||
// Content type to use when parsing this status.
|
// Content type to use when parsing this status.
|
||||||
ContentType StatusContentType `form:"content_type" json:"content_type"`
|
ContentType StatusContentType `form:"content_type" json:"content_type"`
|
||||||
|
|
||||||
// Interaction policy to use for this status.
|
// Interaction policy to use for this status.
|
||||||
InteractionPolicy *InteractionPolicy `form:"-" json:"interaction_policy"`
|
InteractionPolicy *InteractionPolicy `form:"-" json:"interaction_policy"`
|
||||||
}
|
}
|
||||||
|
@ -236,6 +250,7 @@ type StatusCreateRequest struct {
|
||||||
//
|
//
|
||||||
// swagger:ignore
|
// swagger:ignore
|
||||||
type StatusInteractionPolicyForm struct {
|
type StatusInteractionPolicyForm struct {
|
||||||
|
|
||||||
// Interaction policy to use for this status.
|
// Interaction policy to use for this status.
|
||||||
InteractionPolicy *InteractionPolicy `form:"interaction_policy" json:"-"`
|
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 is visible to nobody. This is only used for the visibility of web statuses.
|
||||||
VisibilityNone Visibility = "none"
|
VisibilityNone Visibility = "none"
|
||||||
// VisibilityPublic is visible to everyone, and will be available via the web even for nonauthenticated users.
|
// VisibilityPublic is visible to everyone, and will be available via the web even for nonauthenticated users.
|
||||||
|
|
||||||
VisibilityPublic Visibility = "public"
|
VisibilityPublic Visibility = "public"
|
||||||
|
|
||||||
// VisibilityUnlisted is visible to everyone, but only on home timelines, lists, etc.
|
// VisibilityUnlisted is visible to everyone, but only on home timelines, lists, etc.
|
||||||
VisibilityUnlisted Visibility = "unlisted"
|
VisibilityUnlisted Visibility = "unlisted"
|
||||||
|
|
||||||
// VisibilityPrivate is visible only to followers of the account that posted the status.
|
// VisibilityPrivate is visible only to followers of the account that posted the status.
|
||||||
VisibilityPrivate Visibility = "private"
|
VisibilityPrivate Visibility = "private"
|
||||||
|
|
||||||
// VisibilityMutualsOnly is visible only to mutual followers of the account that posted the status.
|
// VisibilityMutualsOnly is visible only to mutual followers of the account that posted the status.
|
||||||
VisibilityMutualsOnly Visibility = "mutuals_only"
|
VisibilityMutualsOnly Visibility = "mutuals_only"
|
||||||
|
|
||||||
// VisibilityDirect is visible only to accounts tagged in the status. It is equivalent to a direct message.
|
// VisibilityDirect is visible only to accounts tagged in the status. It is equivalent to a direct message.
|
||||||
VisibilityDirect Visibility = "direct"
|
VisibilityDirect Visibility = "direct"
|
||||||
)
|
)
|
||||||
|
@ -268,7 +288,8 @@ type StatusInteractionPolicyForm struct {
|
||||||
// swagger:type string
|
// swagger:type string
|
||||||
type StatusContentType 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 (
|
const (
|
||||||
StatusContentTypePlain StatusContentType = "text/plain"
|
StatusContentTypePlain StatusContentType = "text/plain"
|
||||||
StatusContentTypeMarkdown StatusContentType = "text/markdown"
|
StatusContentTypeMarkdown StatusContentType = "text/markdown"
|
||||||
|
@ -280,11 +301,14 @@ type StatusInteractionPolicyForm struct {
|
||||||
//
|
//
|
||||||
// swagger:model statusSource
|
// swagger:model statusSource
|
||||||
type StatusSource struct {
|
type StatusSource struct {
|
||||||
|
|
||||||
// ID of the status.
|
// ID of the status.
|
||||||
// example: 01FBVD42CQ3ZEEVMW180SBX03B
|
// example: 01FBVD42CQ3ZEEVMW180SBX03B
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
|
||||||
// Plain-text source of a status.
|
// Plain-text source of a status.
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
|
|
||||||
// Plain-text version of spoiler text.
|
// Plain-text version of spoiler text.
|
||||||
SpoilerText string `json:"spoiler_text"`
|
SpoilerText string `json:"spoiler_text"`
|
||||||
}
|
}
|
||||||
|
@ -294,27 +318,69 @@ type StatusSource struct {
|
||||||
//
|
//
|
||||||
// swagger:model statusEdit
|
// swagger:model statusEdit
|
||||||
type StatusEdit struct {
|
type StatusEdit struct {
|
||||||
|
|
||||||
// The content of this status at this revision.
|
// The content of this status at this revision.
|
||||||
// Should be HTML, but might also be plaintext in some cases.
|
// Should be HTML, but might also be plaintext in some cases.
|
||||||
// example: <p>Hey this is a status!</p>
|
// example: <p>Hey this is a status!</p>
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
|
|
||||||
// Subject, summary, or content warning for the status at this revision.
|
// Subject, summary, or content warning for the status at this revision.
|
||||||
// example: warning nsfw
|
// example: warning nsfw
|
||||||
SpoilerText string `json:"spoiler_text"`
|
SpoilerText string `json:"spoiler_text"`
|
||||||
|
|
||||||
// Status marked sensitive at this revision.
|
// Status marked sensitive at this revision.
|
||||||
// example: false
|
// example: false
|
||||||
Sensitive bool `json:"sensitive"`
|
Sensitive bool `json:"sensitive"`
|
||||||
|
|
||||||
// The date when this revision was created (ISO 8601 Datetime).
|
// The date when this revision was created (ISO 8601 Datetime).
|
||||||
// example: 2021-07-30T09:20:25+00:00
|
// example: 2021-07-30T09:20:25+00:00
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
|
|
||||||
// The account that authored this status.
|
// The account that authored this status.
|
||||||
Account *Account `json:"account"`
|
Account *Account `json:"account"`
|
||||||
|
|
||||||
// The poll attached to the status at this revision.
|
// 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.
|
// Note that edits changing the poll options will be collapsed together into one edit, since this action resets the poll.
|
||||||
// nullable: true
|
// nullable: true
|
||||||
Poll *Poll `json:"poll"`
|
Poll *Poll `json:"poll"`
|
||||||
|
|
||||||
// Media that is attached to this status.
|
// Media that is attached to this status.
|
||||||
MediaAttachments []*Attachment `json:"media_attachments"`
|
MediaAttachments []*Attachment `json:"media_attachments"`
|
||||||
|
|
||||||
// Custom emoji to be used when rendering status content.
|
// Custom emoji to be used when rendering status content.
|
||||||
Emojis []Emoji `json:"emojis"`
|
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
|
package util
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
"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
|
// ParseDuration parses the given raw interface belonging
|
||||||
// the given fieldName as an integer duration.
|
// the given fieldName as an integer duration.
|
||||||
func ParseDuration(rawI any, fieldName string) (*int, error) {
|
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 {
|
if status.CreatedWithApplicationID != "" && status.CreatedWithApplication == nil {
|
||||||
// Populate the status' expected CreatedWithApplication (not always set).
|
// Populate the status' expected CreatedWithApplication (not always set).
|
||||||
status.CreatedWithApplication, err = s.state.DB.GetApplicationByID(
|
status.CreatedWithApplication, err = s.state.DB.GetApplicationByID(
|
||||||
|
@ -322,6 +311,23 @@ func (s *statusDB) PopulateStatus(ctx context.Context, status *gtsmodel.Status)
|
||||||
return errs.Combine()
|
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 {
|
func (s *statusDB) PutStatus(ctx context.Context, status *gtsmodel.Status) error {
|
||||||
return s.state.Caches.DB.Status.Store(status, func() error {
|
return s.state.Caches.DB.Status.Store(status, func() error {
|
||||||
// It is safe to run this database transaction within cache.Store
|
// 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)
|
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).
|
// 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
|
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 stores one status in the database.
|
||||||
PutStatus(ctx context.Context, status *gtsmodel.Status) error
|
PutStatus(ctx context.Context, status *gtsmodel.Status) error
|
||||||
|
|
||||||
|
|
|
@ -66,7 +66,7 @@
|
||||||
// causing loads of dereferencing calls.
|
// causing loads of dereferencing calls.
|
||||||
Fresh = util.Ptr(FreshnessWindow(5 * time.Minute))
|
Fresh = util.Ptr(FreshnessWindow(5 * time.Minute))
|
||||||
|
|
||||||
// 10 seconds.
|
// 5 seconds.
|
||||||
//
|
//
|
||||||
// Freshest is useful when you want an
|
// Freshest is useful when you want an
|
||||||
// immediately up to date model of something
|
// immediately up to date model of something
|
||||||
|
@ -74,7 +74,7 @@
|
||||||
//
|
//
|
||||||
// Be careful using this one; it can cause
|
// Be careful using this one; it can cause
|
||||||
// lots of unnecessary traffic if used unwisely.
|
// 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
|
// Dereferencer wraps logic and functionality for doing dereferencing
|
||||||
|
|
|
@ -35,6 +35,7 @@
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/util/xslices"
|
||||||
)
|
)
|
||||||
|
|
||||||
// statusFresh returns true if the given status is still
|
// statusFresh returns true if the given status is still
|
||||||
|
@ -1000,12 +1001,21 @@ func (d *Dereferencer) fetchStatusEmojis(
|
||||||
// Set latest emojis.
|
// Set latest emojis.
|
||||||
status.Emojis = emojis
|
status.Emojis = emojis
|
||||||
|
|
||||||
// Iterate over and set changed emoji IDs.
|
// Extract IDs from latest slice of emojis.
|
||||||
status.EmojiIDs = make([]string, len(emojis))
|
status.EmojiIDs = make([]string, len(emojis))
|
||||||
for i, emoji := range emojis {
|
for i, emoji := range emojis {
|
||||||
status.EmojiIDs[i] = emoji.ID
|
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
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1118,10 +1128,10 @@ func (d *Dereferencer) handleStatusEdit(
|
||||||
var edited bool
|
var edited bool
|
||||||
|
|
||||||
// Preallocate max slice length.
|
// Preallocate max slice length.
|
||||||
cols = make([]string, 0, 13)
|
cols = make([]string, 1, 13)
|
||||||
|
|
||||||
// Always update `fetched_at`.
|
// Always update `fetched_at`.
|
||||||
cols = append(cols, "fetched_at")
|
cols[0] = "fetched_at"
|
||||||
|
|
||||||
// Check for edited status content.
|
// Check for edited status content.
|
||||||
if existing.Content != status.Content {
|
if existing.Content != status.Content {
|
||||||
|
@ -1187,6 +1197,13 @@ func (d *Dereferencer) handleStatusEdit(
|
||||||
// Attached emojis changed.
|
// Attached emojis changed.
|
||||||
cols = append(cols, "emojis") // i.e. EmojiIDs
|
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
|
// Emojis changed doesn't necessarily
|
||||||
// indicate an edit, it may just not have
|
// indicate an edit, it may just not have
|
||||||
// been previously populated properly.
|
// been previously populated properly.
|
||||||
|
@ -1230,7 +1247,8 @@ func (d *Dereferencer) handleStatusEdit(
|
||||||
// Poll only set if existing contained them.
|
// Poll only set if existing contained them.
|
||||||
edit.PollOptions = existing.Poll.Options
|
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
|
// If the counts are allowed to be
|
||||||
// shown, or poll has changed, then
|
// shown, or poll has changed, then
|
||||||
// include poll vote counts in edit.
|
// include poll vote counts in edit.
|
||||||
|
|
|
@ -27,6 +27,7 @@
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/id"
|
"github.com/superseriousbusiness/gotosocial/internal/id"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -42,7 +43,7 @@ func (p *Processor) RulesGet(
|
||||||
apiRules := make([]*apimodel.AdminInstanceRule, len(rules))
|
apiRules := make([]*apimodel.AdminInstanceRule, len(rules))
|
||||||
|
|
||||||
for i := range rules {
|
for i := range rules {
|
||||||
apiRules[i] = p.converter.InstanceRuleToAdminAPIRule(&rules[i])
|
apiRules[i] = typeutils.InstanceRuleToAdminAPIRule(&rules[i])
|
||||||
}
|
}
|
||||||
|
|
||||||
return apiRules, nil
|
return apiRules, nil
|
||||||
|
@ -58,7 +59,7 @@ func (p *Processor) RuleGet(ctx context.Context, id string) (*apimodel.AdminInst
|
||||||
return nil, gtserror.NewErrorInternalError(err)
|
return nil, gtserror.NewErrorInternalError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return p.converter.InstanceRuleToAdminAPIRule(rule), nil
|
return typeutils.InstanceRuleToAdminAPIRule(rule), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RuleCreate adds a new rule to the instance.
|
// 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 nil, gtserror.NewErrorInternalError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return p.converter.InstanceRuleToAdminAPIRule(rule), nil
|
return typeutils.InstanceRuleToAdminAPIRule(rule), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RuleUpdate updates text for an existing rule.
|
// 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 nil, gtserror.NewErrorInternalError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return p.converter.InstanceRuleToAdminAPIRule(updatedRule), nil
|
return typeutils.InstanceRuleToAdminAPIRule(updatedRule), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RuleDelete deletes an existing rule.
|
// 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 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"
|
"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
|
// GetTargetStatusBy fetches the target status with db load
|
||||||
// function, given the authorized (or, nil) requester's
|
// function, given the authorized (or, nil) requester's
|
||||||
// account. This returns an approprate gtserror.WithCode
|
// account. This returns an approprate gtserror.WithCode
|
||||||
|
|
|
@ -29,6 +29,7 @@
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/text"
|
"github.com/superseriousbusiness/gotosocial/internal/text"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/validate"
|
"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 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) {
|
func (p *Processor) InstancePatch(ctx context.Context, form *apimodel.InstanceSettingsUpdateRequest) (*apimodel.InstanceV1, gtserror.WithCode) {
|
||||||
|
|
|
@ -25,6 +25,7 @@
|
||||||
|
|
||||||
"codeberg.org/gruf/go-iotools"
|
"codeberg.org/gruf/go-iotools"
|
||||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
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/config"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
"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.
|
// Parse focus details from API form input.
|
||||||
focusX, focusY, err := parseFocus(form.Focus)
|
focusX, focusY, errWithCode := apiutil.ParseFocus(form.Focus)
|
||||||
if err != nil {
|
if errWithCode != nil {
|
||||||
text := fmt.Sprintf("could not parse focus value %s: %s", form.Focus, err)
|
return nil, errWithCode
|
||||||
return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
|
}
|
||||||
|
|
||||||
|
// 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.
|
// Open multipart file reader.
|
||||||
|
@ -58,7 +70,7 @@ func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, form
|
||||||
return nil, gtserror.NewErrorInternalError(err)
|
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)
|
rc, _, _ := iotools.UpdateReadCloserLimit(mpfile, maxszInt64)
|
||||||
|
|
||||||
// Create local media and write to instance storage.
|
// Create local media and write to instance storage.
|
||||||
|
|
|
@ -23,6 +23,8 @@
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
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/db"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||||
|
@ -47,17 +49,27 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, media
|
||||||
var updatingColumns []string
|
var updatingColumns []string
|
||||||
|
|
||||||
if form.Description != nil {
|
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")
|
updatingColumns = append(updatingColumns, "description")
|
||||||
}
|
}
|
||||||
|
|
||||||
if form.Focus != nil {
|
if form.Focus != nil {
|
||||||
focusx, focusy, err := parseFocus(*form.Focus)
|
// Parse focus details from API form input.
|
||||||
if err != nil {
|
focusX, focusY, errWithCode := apiutil.ParseFocus(*form.Focus)
|
||||||
return nil, gtserror.NewErrorBadRequest(err)
|
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")
|
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
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/ap"
|
"github.com/superseriousbusiness/gotosocial/internal/ap"
|
||||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
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/gtserror"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/id"
|
"github.com/superseriousbusiness/gotosocial/internal/id"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/messages"
|
"github.com/superseriousbusiness/gotosocial/internal/messages"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/text"
|
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
|
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/uris"
|
"github.com/superseriousbusiness/gotosocial/internal/uris"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
"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.
|
// Create processes the given form to create a new status, returning the api model representation of that status if it's OK.
|
||||||
//
|
// Note this also handles validation of incoming form field data.
|
||||||
// Precondition: the form's fields should have already been validated and normalized by the caller.
|
|
||||||
func (p *Processor) Create(
|
func (p *Processor) Create(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
requester *gtsmodel.Account,
|
requester *gtsmodel.Account,
|
||||||
|
@ -51,7 +44,17 @@ func (p *Processor) Create(
|
||||||
*apimodel.Status,
|
*apimodel.Status,
|
||||||
gtserror.WithCode,
|
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 {
|
if err := p.state.DB.PopulateAccount(ctx, requester); err != nil {
|
||||||
log.Errorf(ctx, "error(s) populating account, will continue: %s", err)
|
log.Errorf(ctx, "error(s) populating account, will continue: %s", err)
|
||||||
}
|
}
|
||||||
|
@ -59,6 +62,30 @@ func (p *Processor) Create(
|
||||||
// Generate new ID for status.
|
// Generate new ID for status.
|
||||||
statusID := id.NewULID()
|
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.
|
// Generate necessary URIs for username, to build status URIs.
|
||||||
accountURIs := uris.GenerateURIsForAccount(requester.Username)
|
accountURIs := uris.GenerateURIsForAccount(requester.Username)
|
||||||
|
|
||||||
|
@ -78,16 +105,36 @@ func (p *Processor) Create(
|
||||||
ActivityStreamsType: ap.ObjectNote,
|
ActivityStreamsType: ap.ObjectNote,
|
||||||
Sensitive: &form.Sensitive,
|
Sensitive: &form.Sensitive,
|
||||||
CreatedWithApplicationID: application.ID,
|
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
|
// Assume not pending approval; this may
|
||||||
// change when permissivity is checked.
|
// change when permissivity is checked.
|
||||||
PendingApproval: util.Ptr(false),
|
PendingApproval: util.Ptr(false),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process any attached poll.
|
|
||||||
p.processPoll(status, form.Poll)
|
|
||||||
|
|
||||||
// Check + attach in-reply-to status.
|
// Check + attach in-reply-to status.
|
||||||
if errWithCode := p.processInReplyTo(ctx,
|
if errWithCode := p.processInReplyTo(ctx,
|
||||||
requester,
|
requester,
|
||||||
|
@ -101,10 +148,6 @@ func (p *Processor) Create(
|
||||||
return nil, errWithCode
|
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 {
|
if err := p.processVisibility(ctx, form, requester.Settings.Privacy, status); err != nil {
|
||||||
return nil, gtserror.NewErrorInternalError(err)
|
return nil, gtserror.NewErrorInternalError(err)
|
||||||
}
|
}
|
||||||
|
@ -115,36 +158,49 @@ func (p *Processor) Create(
|
||||||
return nil, errWithCode
|
return nil, errWithCode
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := processLanguage(form, requester.Settings.Language, status); err != nil {
|
if status.ContentWarning != "" && len(status.AttachmentIDs) > 0 {
|
||||||
return nil, gtserror.NewErrorInternalError(err)
|
// 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 {
|
if form.Poll != nil {
|
||||||
return nil, gtserror.NewErrorInternalError(err)
|
// Process poll, inserting into database.
|
||||||
|
poll, errWithCode := p.processPoll(ctx,
|
||||||
|
statusID,
|
||||||
|
form.Poll,
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
if errWithCode != nil {
|
||||||
|
return nil, errWithCode
|
||||||
}
|
}
|
||||||
|
|
||||||
if status.Poll != nil {
|
// Set poll and its ID
|
||||||
// Try to insert the new status poll in the database.
|
// on status before insert.
|
||||||
if err := p.state.DB.PutPoll(ctx, status.Poll); err != nil {
|
status.PollID = poll.ID
|
||||||
err := gtserror.Newf("error inserting poll in db: %w", err)
|
status.Poll = poll
|
||||||
return nil, gtserror.NewErrorInternalError(err)
|
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 {
|
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)
|
return nil, gtserror.NewErrorInternalError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if status.Poll != nil && !status.Poll.ExpiresAt.IsZero() {
|
if status.Poll != nil && !status.Poll.ExpiresAt.IsZero() {
|
||||||
// Now that the status is inserted, and side effects queued,
|
// Now that the status is inserted, attempt to
|
||||||
// attempt to schedule an expiry handler for the status poll.
|
// schedule an expiry handler for the status poll.
|
||||||
if err := p.polls.ScheduleExpiry(ctx, status.Poll); err != nil {
|
if err := p.polls.ScheduleExpiry(ctx, status.Poll); err != nil {
|
||||||
log.Errorf(ctx, "error scheduling poll expiry: %v", err)
|
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{
|
p.state.Workers.Client.Queue.Push(&messages.FromClientAPI{
|
||||||
APObjectType: ap.ObjectNote,
|
APObjectType: ap.ObjectNote,
|
||||||
APActivityType: ap.ActivityCreate,
|
APActivityType: ap.ActivityCreate,
|
||||||
|
@ -172,43 +228,6 @@ func (p *Processor) Create(
|
||||||
return p.c.GetAPIStatus(ctx, requester, status)
|
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 {
|
func (p *Processor) processInReplyTo(ctx context.Context, requester *gtsmodel.Account, status *gtsmodel.Status, inReplyToID string) gtserror.WithCode {
|
||||||
if inReplyToID == "" {
|
if inReplyToID == "" {
|
||||||
// Not a reply.
|
// Not a reply.
|
||||||
|
@ -332,53 +351,6 @@ func (p *Processor) processThreadID(ctx context.Context, status *gtsmodel.Status
|
||||||
return nil
|
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(
|
func (p *Processor) processVisibility(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
form *apimodel.StatusCreateRequest,
|
form *apimodel.StatusCreateRequest,
|
||||||
|
@ -474,99 +446,3 @@ func processInteractionPolicy(
|
||||||
// setting it explicitly to save space.
|
// setting it explicitly to save space.
|
||||||
return nil
|
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)
|
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)
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
"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.
|
// 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) {
|
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,
|
requestingAccount,
|
||||||
targetStatusID,
|
targetStatusID,
|
||||||
nil, // default freshness
|
nil, // default freshness
|
||||||
|
@ -67,44 +36,25 @@ func (p *Processor) Get(ctx context.Context, requestingAccount *gtsmodel.Account
|
||||||
if errWithCode != nil {
|
if errWithCode != nil {
|
||||||
return nil, errWithCode
|
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.
|
// SourceGet returns the *apimodel.StatusSource version of the targetStatusID.
|
||||||
// Status must belong to the requester, and must not be a boost.
|
// 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) {
|
func (p *Processor) SourceGet(ctx context.Context, requester *gtsmodel.Account, statusID string) (*apimodel.StatusSource, gtserror.WithCode) {
|
||||||
targetStatus, errWithCode := p.c.GetVisibleTargetStatus(ctx,
|
status, errWithCode := p.c.GetOwnStatus(ctx, requester, statusID)
|
||||||
requestingAccount,
|
|
||||||
targetStatusID,
|
|
||||||
nil, // default freshness
|
|
||||||
)
|
|
||||||
if errWithCode != nil {
|
if errWithCode != nil {
|
||||||
return nil, errWithCode
|
return nil, errWithCode
|
||||||
}
|
}
|
||||||
|
if status.BoostOfID != "" {
|
||||||
// Redirect to wrapped status if boost.
|
return nil, gtserror.NewErrorNotFound(
|
||||||
targetStatus, errWithCode = p.c.UnwrapIfBoost(
|
errors.New("status is a boost wrapper"),
|
||||||
ctx,
|
"target status not found",
|
||||||
requestingAccount,
|
|
||||||
targetStatus,
|
|
||||||
)
|
)
|
||||||
if errWithCode != nil {
|
|
||||||
return nil, errWithCode
|
|
||||||
}
|
}
|
||||||
|
return &apimodel.StatusSource{
|
||||||
if targetStatus.AccountID != requestingAccount.ID {
|
ID: status.ID,
|
||||||
err := gtserror.Newf(
|
Text: status.Text,
|
||||||
"status %s does not belong to account %s",
|
SpoilerText: status.ContentWarning,
|
||||||
targetStatusID, requestingAccount.ID,
|
}, nil
|
||||||
)
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -762,7 +762,7 @@ func (p *fediAPI) UpdateAccount(ctx context.Context, fMsg *messages.FromFediAPI)
|
||||||
account,
|
account,
|
||||||
apubAcc,
|
apubAcc,
|
||||||
|
|
||||||
// Force refresh within 10s window.
|
// Force refresh within 5s window.
|
||||||
//
|
//
|
||||||
// Missing account updates could be
|
// Missing account updates could be
|
||||||
// detrimental to federation if they
|
// 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)
|
return gtserror.Newf("cannot cast %T -> *gtsmodel.Status", fMsg.GTSModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var freshness *dereferencing.FreshnessWindow
|
||||||
|
|
||||||
// Cast the updated ActivityPub statusable object .
|
// Cast the updated ActivityPub statusable object .
|
||||||
apStatus, _ := fMsg.APObject.(ap.Statusable)
|
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.
|
// Fetch up-to-date attach status attachments, etc.
|
||||||
status, _, err := p.federate.RefreshStatus(
|
status, _, err := p.federate.RefreshStatus(
|
||||||
ctx,
|
ctx,
|
||||||
fMsg.Receiving.Username,
|
fMsg.Receiving.Username,
|
||||||
existing,
|
existing,
|
||||||
apStatus,
|
apStatus,
|
||||||
// Force refresh within 5min window.
|
freshness,
|
||||||
dereferencing.Fresh,
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf(ctx, "error refreshing status: %v", err)
|
log.Errorf(ctx, "error refreshing status: %v", err)
|
||||||
|
|
|
@ -1216,21 +1216,6 @@ func (c *Converter) StatusToWebStatus(
|
||||||
return webStatus, nil
|
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
|
// statusToFrontend is a package internal function for
|
||||||
// parsing a status into its initial frontend representation.
|
// parsing a status into its initial frontend representation.
|
||||||
//
|
//
|
||||||
|
@ -1472,6 +1457,149 @@ func (c *Converter) baseStatusToFrontend(
|
||||||
return apiStatus, nil
|
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
|
// VisToAPIVis converts a gts visibility into its api equivalent
|
||||||
func (c *Converter) VisToAPIVis(ctx context.Context, m gtsmodel.Visibility) apimodel.Visibility {
|
func (c *Converter) VisToAPIVis(ctx context.Context, m gtsmodel.Visibility) apimodel.Visibility {
|
||||||
switch m {
|
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
|
// 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{
|
return apimodel.InstanceRule{
|
||||||
ID: r.ID,
|
ID: r.ID,
|
||||||
Text: r.Text,
|
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
|
// 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))
|
rules := make([]apimodel.InstanceRule, len(r))
|
||||||
|
|
||||||
for i, v := range r {
|
for i, v := range r {
|
||||||
rules[i] = c.InstanceRuleToAPIRule(v)
|
rules[i] = InstanceRuleToAPIRule(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
return rules
|
return rules
|
||||||
}
|
}
|
||||||
|
|
||||||
// InstanceRuleToAdminAPIRule converts a local instance rule into its api equivalent for serving at /api/v1/admin/instance/rules/:id
|
// 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{
|
return &apimodel.AdminInstanceRule{
|
||||||
ID: r.ID,
|
ID: r.ID,
|
||||||
CreatedAt: util.FormatISO8601(r.CreatedAt),
|
CreatedAt: util.FormatISO8601(r.CreatedAt),
|
||||||
|
@ -1540,7 +1666,7 @@ func (c *Converter) InstanceToAPIV1Instance(ctx context.Context, i *gtsmodel.Ins
|
||||||
ApprovalRequired: true, // approval always required
|
ApprovalRequired: true, // approval always required
|
||||||
InvitesEnabled: false, // todo: not supported yet
|
InvitesEnabled: false, // todo: not supported yet
|
||||||
MaxTootChars: uint(config.GetStatusesMaxChars()), // #nosec G115 -- Already validated.
|
MaxTootChars: uint(config.GetStatusesMaxChars()), // #nosec G115 -- Already validated.
|
||||||
Rules: c.InstanceRulesToAPIRules(i.Rules),
|
Rules: InstanceRulesToAPIRules(i.Rules),
|
||||||
Terms: i.Terms,
|
Terms: i.Terms,
|
||||||
TermsRaw: i.TermsText,
|
TermsRaw: i.TermsText,
|
||||||
}
|
}
|
||||||
|
@ -1674,7 +1800,7 @@ func (c *Converter) InstanceToAPIV2Instance(ctx context.Context, i *gtsmodel.Ins
|
||||||
CustomCSS: i.CustomCSS,
|
CustomCSS: i.CustomCSS,
|
||||||
Usage: apimodel.InstanceV2Usage{}, // todo: not implemented
|
Usage: apimodel.InstanceV2Usage{}, // todo: not implemented
|
||||||
Languages: config.GetInstanceLanguages().TagStrs(),
|
Languages: config.GetInstanceLanguages().TagStrs(),
|
||||||
Rules: c.InstanceRulesToAPIRules(i.Rules),
|
Rules: InstanceRulesToAPIRules(i.Rules),
|
||||||
Terms: i.Terms,
|
Terms: i.Terms,
|
||||||
TermsText: i.TermsText,
|
TermsText: i.TermsText,
|
||||||
}
|
}
|
||||||
|
|
|
@ -3737,6 +3737,136 @@ func (suite *InternalToFrontendTestSuite) TestConversationToAPI() {
|
||||||
}`, string(b))
|
}`, 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) {
|
func TestInternalToFrontendTestSuite(t *testing.T) {
|
||||||
suite.Run(t, new(InternalToFrontendTestSuite))
|
suite.Run(t, new(InternalToFrontendTestSuite))
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue