diff options
| author | 2024-12-23 17:54:44 +0000 | |
|---|---|---|
| committer | 2024-12-23 17:54:44 +0000 | |
| commit | fe8d5f23072c40a407723904eb5c54234879d58a (patch) | |
| tree | df80063f3238997de7144932d2d713321164ac1b /internal/api/client/statuses | |
| parent | [chore] Stub /api/v1/announcements implementation (#3630) (diff) | |
| download | gotosocial-fe8d5f23072c40a407723904eb5c54234879d58a.tar.xz | |
[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
Diffstat (limited to 'internal/api/client/statuses')
| -rw-r--r-- | internal/api/client/statuses/status.go | 3 | ||||
| -rw-r--r-- | internal/api/client/statuses/statuscreate.go | 156 | ||||
| -rw-r--r-- | internal/api/client/statuses/statusdelete.go | 2 | ||||
| -rw-r--r-- | internal/api/client/statuses/statusedit.go | 249 | ||||
| -rw-r--r-- | internal/api/client/statuses/statusedit_test.go | 32 | ||||
| -rw-r--r-- | internal/api/client/statuses/statussource_test.go | 2 | 
6 files changed, 322 insertions, 122 deletions
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 <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 + +} diff --git a/internal/api/client/statuses/statusedit_test.go b/internal/api/client/statuses/statusedit_test.go new file mode 100644 index 000000000..43b283d6d --- /dev/null +++ b/internal/api/client/statuses/statusedit_test.go @@ -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)) +} diff --git a/internal/api/client/statuses/statussource_test.go b/internal/api/client/statuses/statussource_test.go index 28b1e6852..797a462ed 100644 --- a/internal/api/client/statuses/statussource_test.go +++ b/internal/api/client/statuses/statussource_test.go @@ -91,7 +91,7 @@ func (suite *StatusSourceTestSuite) TestGetSource() {  	suite.Equal(`{    "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"  }`, dst.String())  }  | 
