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