Skip to content

Commit

Permalink
[feature] add support for clients editing statuses and fetching statu…
Browse files Browse the repository at this point in the history
…s 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
  • Loading branch information
NyaaaWhatsUpDoc authored Dec 23, 2024
1 parent 1aa7f70 commit fe8d5f2
Show file tree
Hide file tree
Showing 29 changed files with 2,546 additions and 523 deletions.
106 changes: 106 additions & 0 deletions docs/api/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9550,6 +9550,112 @@ paths:
summary: Create a new status using the given form field parameters.
tags:
- statuses
put:
consumes:
- application/json
- application/x-www-form-urlencoded
description: The parameters can also be given in the body of the request, as JSON, if the content-type is set to 'application/json'.
operationId: statusEdit
parameters:
- description: |-
Text content of the status.
If media_ids is provided, this becomes optional.
Attaching a poll is optional while status is provided.
in: formData
name: status
type: string
x-go-name: Status
- description: |-
Array of Attachment ids to be attached as media.
If provided, status becomes optional, and poll cannot be used.
If the status is being submitted as a form, the key is 'media_ids[]',
but if it's json or xml, the key is 'media_ids'.
in: formData
items:
type: string
name: media_ids
type: array
x-go-name: MediaIDs
- description: |-
Array of possible poll answers.
If provided, media_ids cannot be used, and poll[expires_in] must be provided.
in: formData
items:
type: string
name: poll[options][]
type: array
x-go-name: PollOptions
- description: |-
Duration the poll should be open, in seconds.
If provided, media_ids cannot be used, and poll[options] must be provided.
format: int64
in: formData
name: poll[expires_in]
type: integer
x-go-name: PollExpiresIn
- default: false
description: Allow multiple choices on this poll.
in: formData
name: poll[multiple]
type: boolean
x-go-name: PollMultiple
- default: true
description: Hide vote counts until the poll ends.
in: formData
name: poll[hide_totals]
type: boolean
x-go-name: PollHideTotals
- description: Status and attached media should be marked as sensitive.
in: formData
name: sensitive
type: boolean
x-go-name: Sensitive
- description: |-
Text to be shown as a warning or subject before the actual content.
Statuses are generally collapsed behind this field.
in: formData
name: spoiler_text
type: string
x-go-name: SpoilerText
- description: ISO 639 language code for this status.
in: formData
name: language
type: string
x-go-name: Language
- description: Content type to use when parsing this status.
enum:
- text/plain
- text/markdown
in: formData
name: content_type
type: string
x-go-name: ContentType
produces:
- application/json
responses:
"200":
description: The latest status revision.
schema:
$ref: '#/definitions/status'
"400":
description: bad request
"401":
description: unauthorized
"403":
description: forbidden
"404":
description: not found
"406":
description: not acceptable
"500":
description: internal server error
security:
- OAuth2 Bearer:
- write:statuses
summary: Edit an existing status using the given form field parameters.
tags:
- statuses
/api/v1/statuses/{id}:
delete:
description: |-
Expand Down
3 changes: 2 additions & 1 deletion internal/api/client/statuses/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
156 changes: 37 additions & 119 deletions internal/api/client/statuses/statuscreate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand All @@ -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,
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -438,56 +392,20 @@ 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(
form.Poll.ExpiresInI,
"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
}
2 changes: 1 addition & 1 deletion internal/api/client/statuses/statusdelete.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,5 +95,5 @@ func (m *Module) StatusDELETEHandler(c *gin.Context) {
return
}

c.JSON(http.StatusOK, apiStatus)
apiutil.JSON(c, http.StatusOK, apiStatus)
}
Loading

0 comments on commit fe8d5f2

Please sign in to comment.