[feature] Allow users to submit interaction_policy on new statuses (#3314)

* [feature] Parse `interaction_policy` on status submission

* beep boop

* swagger? i barely know er
This commit is contained in:
tobi 2024-09-18 18:35:35 +02:00 committed by GitHub
parent f819229988
commit c378ad2bb3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1342 additions and 413 deletions

View file

@ -8826,11 +8826,27 @@ paths:
post: post:
consumes: consumes:
- application/json - application/json
- application/xml
- application/x-www-form-urlencoded - application/x-www-form-urlencoded
description: |- description: |-
The parameters can also be given in the body of the request, as JSON, if the content-type is set to 'application/json'. The parameters can also be given in the body of the request, as JSON, if the content-type is set to 'application/json'.
The parameters can also be given in the body of the request, as XML, if the content-type is set to 'application/xml'.
The 'interaction_policy' field can be used to set an interaction policy for this status.
If submitting using form data, use the following pattern to set an interaction policy:
`interaction_policy[INTERACTION_TYPE][CONDITION][INDEX]=Value`
For example: `interaction_policy[can_reply][always][0]=author`
Using `curl` this might look something like:
`curl -F 'interaction_policy[can_reply][always][0]=author' -F 'interaction_policy[can_reply][always][1]=followers' [... other form fields ...]`
The JSON equivalent would be:
`curl -H 'Content-Type: application/json' -d '{"interaction_policy":{"can_reply":{"always":["author","followers"]}} [... other json fields ...]}'`
The server will perform some normalization on the submitted policy so that you can't submit something totally invalid.
operationId: statusCreate operationId: statusCreate
parameters: parameters:
- description: |- - description: |-
@ -8944,6 +8960,30 @@ paths:
name: content_type name: content_type
type: string type: string
x-go-name: ContentType x-go-name: ContentType
- description: Nth entry for interaction_policy.can_favourite.always.
in: formData
name: interaction_policy[can_favourite][always][0]
type: string
- description: Nth entry for interaction_policy.can_favourite.with_approval.
in: formData
name: interaction_policy[can_favourite][with_approval][0]
type: string
- description: Nth entry for interaction_policy.can_reply.always.
in: formData
name: interaction_policy[can_reply][always][0]
type: string
- description: Nth entry for interaction_policy.can_reply.with_approval.
in: formData
name: interaction_policy[can_reply][with_approval][0]
type: string
- description: Nth entry for interaction_policy.can_reblog.always.
in: formData
name: interaction_policy[can_reblog][always][0]
type: string
- description: Nth entry for interaction_policy.can_reblog.with_approval.
in: formData
name: interaction_policy[can_reblog][with_approval][0]
type: string
produces: produces:
- application/json - application/json
responses: responses:
@ -8966,7 +9006,7 @@ paths:
security: security:
- OAuth2 Bearer: - OAuth2 Bearer:
- write:statuses - write:statuses
summary: Create a new status. summary: Create a new status using the given form field parameters.
tags: tags:
- statuses - statuses
/api/v1/statuses/{id}: /api/v1/statuses/{id}:

View file

@ -222,7 +222,7 @@ func (m *Module) PoliciesDefaultsPATCHHandler(c *gin.Context) {
return return
} }
form, err := parseUpdateAccountForm(c) form, err := parseUpdatePoliciesForm(c)
if err != nil { if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return return
@ -290,7 +290,7 @@ func customBind(
return nil return nil
} }
func parseUpdateAccountForm(c *gin.Context) (*apimodel.UpdateInteractionPoliciesRequest, error) { func parseUpdatePoliciesForm(c *gin.Context) (*apimodel.UpdateInteractionPoliciesRequest, error) {
form := new(apimodel.UpdateInteractionPoliciesRequest) form := new(apimodel.UpdateInteractionPoliciesRequest)
switch ct := c.ContentType(); ct { switch ct := c.ContentType(); ct {

View file

@ -24,6 +24,8 @@ import (
"strconv" "strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"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/config"
@ -35,10 +37,27 @@ import (
// StatusCreatePOSTHandler swagger:operation POST /api/v1/statuses statusCreate // StatusCreatePOSTHandler swagger:operation POST /api/v1/statuses statusCreate
// //
// Create a new status. // Create a new 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'. // The parameters can also be given in the body of the request, as JSON, if the content-type is set to 'application/json'.
// The parameters can also be given in the body of the request, as XML, if the content-type is set to 'application/xml'. //
// The 'interaction_policy' field can be used to set an interaction policy for this status.
//
// If submitting using form data, use the following pattern to set an interaction policy:
//
// `interaction_policy[INTERACTION_TYPE][CONDITION][INDEX]=Value`
//
// For example: `interaction_policy[can_reply][always][0]=author`
//
// Using `curl` this might look something like:
//
// `curl -F 'interaction_policy[can_reply][always][0]=author' -F 'interaction_policy[can_reply][always][1]=followers' [... other form fields ...]`
//
// The JSON equivalent would be:
//
// `curl -H 'Content-Type: application/json' -d '{"interaction_policy":{"can_reply":{"always":["author","followers"]}} [... other json fields ...]}'`
//
// The server will perform some normalization on the submitted policy so that you can't submit something totally invalid.
// //
// --- // ---
// tags: // tags:
@ -46,7 +65,6 @@ import (
// //
// consumes: // consumes:
// - application/json // - application/json
// - application/xml
// - application/x-www-form-urlencoded // - application/x-www-form-urlencoded
// //
// parameters: // parameters:
@ -181,6 +199,36 @@ import (
// - text/plain // - text/plain
// - text/markdown // - text/markdown
// in: formData // in: formData
// -
// name: interaction_policy[can_favourite][always][0]
// in: formData
// description: Nth entry for interaction_policy.can_favourite.always.
// type: string
// -
// name: interaction_policy[can_favourite][with_approval][0]
// in: formData
// description: Nth entry for interaction_policy.can_favourite.with_approval.
// type: string
// -
// name: interaction_policy[can_reply][always][0]
// in: formData
// description: Nth entry for interaction_policy.can_reply.always.
// type: string
// -
// name: interaction_policy[can_reply][with_approval][0]
// in: formData
// description: Nth entry for interaction_policy.can_reply.with_approval.
// type: string
// -
// name: interaction_policy[can_reblog][always][0]
// in: formData
// description: Nth entry for interaction_policy.can_reblog.always.
// type: string
// -
// name: interaction_policy[can_reblog][with_approval][0]
// in: formData
// description: Nth entry for interaction_policy.can_reblog.with_approval.
// type: string
// //
// produces: // produces:
// - application/json // - application/json
@ -223,8 +271,8 @@ func (m *Module) StatusCreatePOSTHandler(c *gin.Context) {
return return
} }
form := &apimodel.StatusCreateRequest{} form, err := parseStatusCreateForm(c)
if err := c.ShouldBind(form); err != nil { if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return return
} }
@ -257,6 +305,75 @@ func (m *Module) StatusCreatePOSTHandler(c *gin.Context) {
c.JSON(http.StatusOK, apiStatus) c.JSON(http.StatusOK, apiStatus)
} }
// intPolicyFormBinding satisfies gin's binding.Binding interface.
// Should only be used specifically for multipart/form-data MIME type.
type intPolicyFormBinding struct{}
func (i intPolicyFormBinding) Name() string {
return "InteractionPolicy"
}
func (intPolicyFormBinding) Bind(req *http.Request, obj any) error {
if err := req.ParseForm(); err != nil {
return err
}
// Change default namespace prefix and suffix to
// allow correct parsing of the field attributes.
decoder := form.NewDecoder()
decoder.SetNamespacePrefix("[")
decoder.SetNamespaceSuffix("]")
return decoder.Decode(obj, req.Form)
}
func parseStatusCreateForm(c *gin.Context) (*apimodel.StatusCreateRequest, error) {
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
}
case binding.MIMEPOSTForm:
// Bind with default form binding first.
if err := c.ShouldBindWith(form, binding.FormPost); err != nil {
return nil, err
}
// Now do custom binding.
intReqForm := new(apimodel.StatusInteractionPolicyForm)
if err := c.ShouldBindWith(intReqForm, intPolicyFormBinding{}); err != nil {
return nil, err
}
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
}
// Now do custom binding.
intReqForm := new(apimodel.StatusInteractionPolicyForm)
if err := c.ShouldBindWith(intReqForm, intPolicyFormBinding{}); err != nil {
return nil, err
}
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
}
// validateNormalizeCreateStatus checks the form // validateNormalizeCreateStatus checks the form
// for disallowed combinations of attachments and // for disallowed combinations of attachments and
// overlength inputs. // overlength inputs.

File diff suppressed because it is too large Load diff

View file

@ -196,33 +196,44 @@ 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" xml:"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" xml:"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" xml:"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" xml:"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" xml:"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" xml:"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" xml:"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"` 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"` 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" xml:"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" xml:"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" xml:"content_type"` ContentType StatusContentType `form:"content_type" json:"content_type"`
// Interaction policy to use for this status.
InteractionPolicy *InteractionPolicy `form:"-" json:"interaction_policy"`
}
// Separate form for parsing interaction
// policy on status create requests.
//
// swagger:ignore
type StatusInteractionPolicyForm struct {
// Interaction policy to use for this status.
InteractionPolicy *InteractionPolicy `form:"interaction_policy" json:"-"`
} }
// Visibility models the visibility of a status. // Visibility models the visibility of a status.

View file

@ -117,14 +117,14 @@ func (p *Processor) Create(
return nil, errWithCode return nil, errWithCode
} }
if err := processVisibility(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)
} }
// Process policy AFTER visibility as it // Process policy AFTER visibility as it relies
// relies on status.Visibility being set. // on status.Visibility and form.Visibility being set.
if err := processInteractionPolicy(form, requester.Settings, status); err != nil { if errWithCode := processInteractionPolicy(form, requester.Settings, status); errWithCode != nil {
return nil, gtserror.NewErrorInternalError(err) return nil, errWithCode
} }
if err := processLanguage(form, requester.Settings.Language, status); err != nil { if err := processLanguage(form, requester.Settings.Language, status); err != nil {
@ -337,7 +337,8 @@ func (p *Processor) processMediaIDs(ctx context.Context, form *apimodel.StatusCr
return nil return nil
} }
func processVisibility( func (p *Processor) processVisibility(
ctx context.Context,
form *apimodel.StatusCreateRequest, form *apimodel.StatusCreateRequest,
accountDefaultVis gtsmodel.Visibility, accountDefaultVis gtsmodel.Visibility,
status *gtsmodel.Status, status *gtsmodel.Status,
@ -347,13 +348,17 @@ func processVisibility(
case form.Visibility != "": case form.Visibility != "":
status.Visibility = typeutils.APIVisToVis(form.Visibility) status.Visibility = typeutils.APIVisToVis(form.Visibility)
// Fall back to account default. // Fall back to account default, set
// this back on the form for later use.
case accountDefaultVis != "": case accountDefaultVis != "":
status.Visibility = accountDefaultVis status.Visibility = accountDefaultVis
form.Visibility = p.converter.VisToAPIVis(ctx, accountDefaultVis)
// What? Fall back to global default. // What? Fall back to global default, set
// this back on the form for later use.
default: default:
status.Visibility = gtsmodel.VisibilityDefault status.Visibility = gtsmodel.VisibilityDefault
form.Visibility = p.converter.VisToAPIVis(ctx, gtsmodel.VisibilityDefault)
} }
// Set federated according to "local_only" field, // Set federated according to "local_only" field,
@ -365,17 +370,32 @@ func processVisibility(
} }
func processInteractionPolicy( func processInteractionPolicy(
_ *apimodel.StatusCreateRequest, form *apimodel.StatusCreateRequest,
settings *gtsmodel.AccountSettings, settings *gtsmodel.AccountSettings,
status *gtsmodel.Status, status *gtsmodel.Status,
) error { ) gtserror.WithCode {
// TODO: parse policy for this
// status from form and prefer this.
// If policy is set on the
// form then prefer this.
//
// TODO: prevent scope widening by // TODO: prevent scope widening by
// limiting interaction policy if // limiting interaction policy if
// inReplyTo status has a stricter // inReplyTo status has a stricter
// interaction policy than this one. // interaction policy than this one.
if form.InteractionPolicy != nil {
p, err := typeutils.APIInteractionPolicyToInteractionPolicy(
form.InteractionPolicy,
form.Visibility,
)
if err != nil {
errWithCode := gtserror.NewErrorBadRequest(err, err.Error())
return errWithCode
}
status.InteractionPolicy = p
return nil
}
switch status.Visibility { switch status.Visibility {