From fe8d5f23072c40a407723904eb5c54234879d58a Mon Sep 17 00:00:00 2001
From: kim <89579420+NyaaaWhatsUpDoc@users.noreply.github.com>
Date: Mon, 23 Dec 2024 17:54:44 +0000
Subject: [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
---
internal/api/client/statuses/status.go | 3 +-
internal/api/client/statuses/statuscreate.go | 156 ++++----------
internal/api/client/statuses/statusdelete.go | 2 +-
internal/api/client/statuses/statusedit.go | 249 ++++++++++++++++++++++
internal/api/client/statuses/statusedit_test.go | 32 +++
internal/api/client/statuses/statussource_test.go | 2 +-
internal/api/model/attachment.go | 25 +++
internal/api/model/status.go | 70 +++++-
internal/api/util/parseform.go | 42 ++++
9 files changed, 457 insertions(+), 124 deletions(-)
create mode 100644 internal/api/client/statuses/statusedit.go
create mode 100644 internal/api/client/statuses/statusedit_test.go
(limited to 'internal/api')
diff --git a/internal/api/client/statuses/status.go b/internal/api/client/statuses/status.go
index 33af9c456..88b34cbf5 100644
--- a/internal/api/client/statuses/status.go
+++ b/internal/api/client/statuses/status.go
@@ -83,9 +83,10 @@ func New(processor *processing.Processor) *Module {
}
func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) {
- // create / get / delete status
+ // create / get / edit / delete status
attachHandler(http.MethodPost, BasePath, m.StatusCreatePOSTHandler)
attachHandler(http.MethodGet, BasePathWithID, m.StatusGETHandler)
+ attachHandler(http.MethodPut, BasePathWithID, m.StatusEditPUTHandler)
attachHandler(http.MethodDelete, BasePathWithID, m.StatusDELETEHandler)
// fave stuff
diff --git a/internal/api/client/statuses/statuscreate.go b/internal/api/client/statuses/statuscreate.go
index 8198d5358..c83cdbad7 100644
--- a/internal/api/client/statuses/statuscreate.go
+++ b/internal/api/client/statuses/statuscreate.go
@@ -27,11 +27,9 @@ import (
"github.com/go-playground/form/v4"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
- "github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/internal/util"
- "github.com/superseriousbusiness/gotosocial/internal/validate"
)
// StatusCreatePOSTHandler swagger:operation POST /api/v1/statuses statusCreate
@@ -272,9 +270,9 @@ func (m *Module) StatusCreatePOSTHandler(c *gin.Context) {
return
}
- form, err := parseStatusCreateForm(c)
- if err != nil {
- apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
+ form, errWithCode := parseStatusCreateForm(c)
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
@@ -287,11 +285,6 @@ func (m *Module) StatusCreatePOSTHandler(c *gin.Context) {
// }
// form.Status += "\n\nsent from " + user + "'s iphone\n"
- if errWithCode := validateStatusCreateForm(form); errWithCode != nil {
- apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
- return
- }
-
apiStatus, errWithCode := m.processor.Status().Create(
c.Request.Context(),
authed.Account,
@@ -303,7 +296,7 @@ func (m *Module) StatusCreatePOSTHandler(c *gin.Context) {
return
}
- c.JSON(http.StatusOK, apiStatus)
+ apiutil.JSON(c, http.StatusOK, apiStatus)
}
// intPolicyFormBinding satisfies gin's binding.Binding interface.
@@ -328,108 +321,69 @@ func (intPolicyFormBinding) Bind(req *http.Request, obj any) error {
return decoder.Decode(obj, req.Form)
}
-func parseStatusCreateForm(c *gin.Context) (*apimodel.StatusCreateRequest, error) {
+func parseStatusCreateForm(c *gin.Context) (*apimodel.StatusCreateRequest, gtserror.WithCode) {
form := new(apimodel.StatusCreateRequest)
switch ct := c.ContentType(); ct {
case binding.MIMEJSON:
// Just bind with default json binding.
if err := c.ShouldBindWith(form, binding.JSON); err != nil {
- return nil, err
+ return nil, gtserror.NewErrorBadRequest(
+ err,
+ err.Error(),
+ )
}
case binding.MIMEPOSTForm:
// Bind with default form binding first.
if err := c.ShouldBindWith(form, binding.FormPost); err != nil {
- return nil, err
+ return nil, gtserror.NewErrorBadRequest(
+ err,
+ err.Error(),
+ )
}
// Now do custom binding.
intReqForm := new(apimodel.StatusInteractionPolicyForm)
if err := c.ShouldBindWith(intReqForm, intPolicyFormBinding{}); err != nil {
- return nil, err
+ return nil, gtserror.NewErrorBadRequest(
+ err,
+ err.Error(),
+ )
}
+
form.InteractionPolicy = intReqForm.InteractionPolicy
case binding.MIMEMultipartPOSTForm:
// Bind with default form binding first.
if err := c.ShouldBindWith(form, binding.FormMultipart); err != nil {
- return nil, err
+ return nil, gtserror.NewErrorBadRequest(
+ err,
+ err.Error(),
+ )
}
// Now do custom binding.
intReqForm := new(apimodel.StatusInteractionPolicyForm)
if err := c.ShouldBindWith(intReqForm, intPolicyFormBinding{}); err != nil {
- return nil, err
+ return nil, gtserror.NewErrorBadRequest(
+ err,
+ err.Error(),
+ )
}
+
form.InteractionPolicy = intReqForm.InteractionPolicy
default:
- err := fmt.Errorf(
- "content-type %s not supported for this endpoint; supported content-types are %s, %s, %s",
- ct, binding.MIMEJSON, binding.MIMEPOSTForm, binding.MIMEMultipartPOSTForm,
- )
- return nil, err
- }
-
- return form, nil
-}
-
-// validateStatusCreateForm checks the form for disallowed
-// combinations of attachments, overlength inputs, etc.
-//
-// Side effect: normalizes the post's language tag.
-func validateStatusCreateForm(form *apimodel.StatusCreateRequest) gtserror.WithCode {
- var (
- chars = len([]rune(form.Status)) + len([]rune(form.SpoilerText))
- maxChars = config.GetStatusesMaxChars()
- mediaFiles = len(form.MediaIDs)
- maxMediaFiles = config.GetStatusesMediaMaxFiles()
- hasMedia = mediaFiles != 0
- hasPoll = form.Poll != nil
- )
-
- if chars == 0 && !hasMedia && !hasPoll {
- // Status must contain *some* kind of content.
- const text = "no status content, content warning, media, or poll provided"
- return gtserror.NewErrorBadRequest(errors.New(text), text)
- }
-
- if chars > maxChars {
- text := fmt.Sprintf(
- "status too long, %d characters provided (including content warning) but limit is %d",
- chars, maxChars,
- )
- return gtserror.NewErrorBadRequest(errors.New(text), text)
- }
-
- if mediaFiles > maxMediaFiles {
- text := fmt.Sprintf(
- "too many media files attached to status, %d attached but limit is %d",
- mediaFiles, maxMediaFiles,
- )
- return gtserror.NewErrorBadRequest(errors.New(text), text)
- }
-
- if form.Poll != nil {
- if errWithCode := validateStatusPoll(form); errWithCode != nil {
- return errWithCode
- }
+ text := fmt.Sprintf("content-type %s not supported for this endpoint; supported content-types are %s, %s, %s",
+ ct, binding.MIMEJSON, binding.MIMEPOSTForm, binding.MIMEMultipartPOSTForm)
+ return nil, gtserror.NewErrorNotAcceptable(errors.New(text), text)
}
+ // Check not scheduled status.
if form.ScheduledAt != "" {
const text = "scheduled_at is not yet implemented"
- return gtserror.NewErrorNotImplemented(errors.New(text), text)
- }
-
- // Validate + normalize
- // language tag if provided.
- if form.Language != "" {
- lang, err := validate.Language(form.Language)
- if err != nil {
- return gtserror.NewErrorBadRequest(err, err.Error())
- }
- form.Language = lang
+ return nil, gtserror.NewErrorNotImplemented(errors.New(text), text)
}
// Check if the deprecated "federated" field was
@@ -438,42 +392,9 @@ func validateStatusCreateForm(form *apimodel.StatusCreateRequest) gtserror.WithC
form.LocalOnly = util.Ptr(!*form.Federated) // nolint:staticcheck
}
- return nil
-}
-
-func validateStatusPoll(form *apimodel.StatusCreateRequest) gtserror.WithCode {
- var (
- maxPollOptions = config.GetStatusesPollMaxOptions()
- pollOptions = len(form.Poll.Options)
- maxPollOptionChars = config.GetStatusesPollOptionMaxChars()
- )
+ // Normalize poll expiry time if a poll was given.
+ if form.Poll != nil && form.Poll.ExpiresInI != nil {
- if pollOptions == 0 {
- const text = "poll with no options"
- return gtserror.NewErrorBadRequest(errors.New(text), text)
- }
-
- if pollOptions > maxPollOptions {
- text := fmt.Sprintf(
- "too many poll options provided, %d provided but limit is %d",
- pollOptions, maxPollOptions,
- )
- return gtserror.NewErrorBadRequest(errors.New(text), text)
- }
-
- for _, option := range form.Poll.Options {
- optionChars := len([]rune(option))
- if optionChars > maxPollOptionChars {
- text := fmt.Sprintf(
- "poll option too long, %d characters provided but limit is %d",
- optionChars, maxPollOptionChars,
- )
- return gtserror.NewErrorBadRequest(errors.New(text), text)
- }
- }
-
- // Normalize poll expiry if necessary.
- if form.Poll.ExpiresInI != nil {
// If we parsed this as JSON, expires_in
// may be either a float64 or a string.
expiresIn, err := apiutil.ParseDuration(
@@ -481,13 +402,10 @@ func validateStatusPoll(form *apimodel.StatusCreateRequest) gtserror.WithCode {
"expires_in",
)
if err != nil {
- return gtserror.NewErrorBadRequest(err, err.Error())
- }
-
- if expiresIn != nil {
- form.Poll.ExpiresIn = *expiresIn
+ return nil, gtserror.NewErrorBadRequest(err, err.Error())
}
+ form.Poll.ExpiresIn = util.PtrOrZero(expiresIn)
}
- return nil
+ return form, nil
}
diff --git a/internal/api/client/statuses/statusdelete.go b/internal/api/client/statuses/statusdelete.go
index 7ee240dff..fa62d6893 100644
--- a/internal/api/client/statuses/statusdelete.go
+++ b/internal/api/client/statuses/statusdelete.go
@@ -95,5 +95,5 @@ func (m *Module) StatusDELETEHandler(c *gin.Context) {
return
}
- c.JSON(http.StatusOK, apiStatus)
+ apiutil.JSON(c, http.StatusOK, apiStatus)
}
diff --git a/internal/api/client/statuses/statusedit.go b/internal/api/client/statuses/statusedit.go
new file mode 100644
index 000000000..dfd7d651e
--- /dev/null
+++ b/internal/api/client/statuses/statusedit.go
@@ -0,0 +1,249 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see
Hey this is a status!
Content string `json:"content"` + // Subject, summary, or content warning for the status at this revision. // example: warning nsfw SpoilerText string `json:"spoiler_text"` + // Status marked sensitive at this revision. // example: false Sensitive bool `json:"sensitive"` + // The date when this revision was created (ISO 8601 Datetime). // example: 2021-07-30T09:20:25+00:00 CreatedAt string `json:"created_at"` + // The account that authored this status. Account *Account `json:"account"` + // The poll attached to the status at this revision. // Note that edits changing the poll options will be collapsed together into one edit, since this action resets the poll. // nullable: true Poll *Poll `json:"poll"` + // Media that is attached to this status. MediaAttachments []*Attachment `json:"media_attachments"` + // Custom emoji to be used when rendering status content. Emojis []Emoji `json:"emojis"` } + +// StatusEditRequest models status edit parameters. +// +// swagger:ignore +type StatusEditRequest struct { + + // Text content of the status. + // If media_ids is provided, this becomes optional. + // Attaching a poll is optional while status is provided. + Status string `form:"status" json:"status"` + + // Text to be shown as a warning or subject before the actual content. + // Statuses are generally collapsed behind this field. + SpoilerText string `form:"spoiler_text" json:"spoiler_text"` + + // Content type to use when parsing this status. + ContentType StatusContentType `form:"content_type" json:"content_type"` + + // Status and attached media should be marked as sensitive. + Sensitive bool `form:"sensitive" json:"sensitive"` + + // ISO 639 language code for this status. + Language string `form:"language" json:"language"` + + // Array of Attachment ids to be attached as media. + // If provided, status becomes optional, and poll cannot be used. + MediaIDs []string `form:"media_ids[]" json:"media_ids"` + + // Array of Attachment attributes to be updated in attached media. + MediaAttributes []AttachmentAttributesRequest `form:"media_attributes[]" json:"media_attributes"` + + // Poll to include with this status. + Poll *PollRequest `form:"poll" json:"poll"` +} diff --git a/internal/api/util/parseform.go b/internal/api/util/parseform.go index 3eab065f2..8bb10012c 100644 --- a/internal/api/util/parseform.go +++ b/internal/api/util/parseform.go @@ -18,13 +18,55 @@ package util import ( + "errors" "fmt" "strconv" + "strings" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/util" ) +// ParseFocus parses a media attachment focus parameters from incoming API string. +func ParseFocus(focus string) (focusx, focusy float32, errWithCode gtserror.WithCode) { + if focus == "" { + return + } + spl := strings.Split(focus, ",") + if len(spl) != 2 { + const text = "missing comma separator" + errWithCode = gtserror.NewErrorBadRequest( + errors.New(text), + text, + ) + return + } + xStr := spl[0] + yStr := spl[1] + fx, err := strconv.ParseFloat(xStr, 32) + if err != nil || fx > 1 || fx < -1 { + text := fmt.Sprintf("invalid x focus: %s", xStr) + errWithCode = gtserror.NewErrorBadRequest( + errors.New(text), + text, + ) + return + } + fy, err := strconv.ParseFloat(yStr, 32) + if err != nil || fy > 1 || fy < -1 { + text := fmt.Sprintf("invalid y focus: %s", xStr) + errWithCode = gtserror.NewErrorBadRequest( + errors.New(text), + text, + ) + return + } + focusx = float32(fx) + focusy = float32(fy) + return +} + // ParseDuration parses the given raw interface belonging // the given fieldName as an integer duration. func ParseDuration(rawI any, fieldName string) (*int, error) { -- cgit v1.2.3