diff options
Diffstat (limited to 'internal')
28 files changed, 2440 insertions, 523 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())  } diff --git a/internal/api/model/attachment.go b/internal/api/model/attachment.go index f037a09aa..1d910343c 100644 --- a/internal/api/model/attachment.go +++ b/internal/api/model/attachment.go @@ -23,12 +23,15 @@ import "mime/multipart"  //  // swagger: ignore  type AttachmentRequest struct { +  	// Media file.  	File *multipart.FileHeader `form:"file" binding:"required"` +  	// Description of the media file. Optional.  	// This will be used as alt-text for users of screenreaders etc.  	// example: This is an image of some kittens, they are very cute and fluffy.  	Description string `form:"description"` +  	// Focus of the media file. Optional.  	// If present, it should be in the form of two comma-separated floats between -1 and 1.  	// example: -0.5,0.565 @@ -39,16 +42,38 @@ type AttachmentRequest struct {  //  // swagger:ignore  type AttachmentUpdateRequest struct { +  	// Description of the media file.  	// This will be used as alt-text for users of screenreaders etc.  	// allowEmptyValue: true  	Description *string `form:"description" json:"description" xml:"description"` +  	// Focus of the media file.  	// If present, it should be in the form of two comma-separated floats between -1 and 1.  	// allowEmptyValue: true  	Focus *string `form:"focus" json:"focus" xml:"focus"`  } +// AttachmentAttributesRequest models an edit request for attachment attributes. +// +// swagger:ignore +type AttachmentAttributesRequest struct { + +	// The ID of the attachment. +	// example: 01FC31DZT1AYWDZ8XTCRWRBYRK +	ID string `form:"id" json:"id"` + +	// Description of the media file. +	// This will be used as alt-text for users of screenreaders etc. +	// allowEmptyValue: true +	Description string `form:"description" json:"description"` + +	// Focus of the media file. +	// If present, it should be in the form of two comma-separated floats between -1 and 1. +	// allowEmptyValue: true +	Focus string `form:"focus" json:"focus"` +} +  // Attachment models a media attachment.  //  // swagger:model attachment diff --git a/internal/api/model/status.go b/internal/api/model/status.go index 724134b77..ea9fbaa35 100644 --- a/internal/api/model/status.go +++ b/internal/api/model/status.go @@ -197,36 +197,50 @@ type StatusReblogged struct {  //  // swagger:ignore  type StatusCreateRequest 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"` +  	// 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"` +  	// Poll to include with this status.  	Poll *PollRequest `form:"poll" json:"poll"` +  	// ID of the status being replied to, if status is a reply.  	InReplyToID string `form:"in_reply_to_id" json:"in_reply_to_id"` +  	// Status and attached media should be marked as sensitive.  	Sensitive bool `form:"sensitive" json:"sensitive"` +  	// 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"` +  	// Visibility of the posted status.  	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" json:"local_only"` +  	// Deprecated: Only used if LocalOnly is not set.  	Federated *bool `form:"federated" json:"federated"` +  	// ISO 8601 Datetime at which to schedule a status.  	// Providing this parameter will cause ScheduledStatus to be returned instead of Status.  	// Must be at least 5 minutes in the future.  	ScheduledAt string `form:"scheduled_at" json:"scheduled_at"` +  	// ISO 639 language code for this status.  	Language string `form:"language" json:"language"` +  	// Content type to use when parsing this status.  	ContentType StatusContentType `form:"content_type" json:"content_type"` +  	// Interaction policy to use for this status.  	InteractionPolicy *InteractionPolicy `form:"-" json:"interaction_policy"`  } @@ -236,6 +250,7 @@ type StatusCreateRequest struct {  //  // swagger:ignore  type StatusInteractionPolicyForm struct { +  	// Interaction policy to use for this status.  	InteractionPolicy *InteractionPolicy `form:"interaction_policy" json:"-"`  } @@ -250,13 +265,18 @@ const (  	// VisibilityNone is visible to nobody. This is only used for the visibility of web statuses.  	VisibilityNone Visibility = "none"  	// VisibilityPublic is visible to everyone, and will be available via the web even for nonauthenticated users. +  	VisibilityPublic Visibility = "public" +  	// VisibilityUnlisted is visible to everyone, but only on home timelines, lists, etc.  	VisibilityUnlisted Visibility = "unlisted" +  	// VisibilityPrivate is visible only to followers of the account that posted the status.  	VisibilityPrivate Visibility = "private" +  	// VisibilityMutualsOnly is visible only to mutual followers of the account that posted the status.  	VisibilityMutualsOnly Visibility = "mutuals_only" +  	// VisibilityDirect is visible only to accounts tagged in the status. It is equivalent to a direct message.  	VisibilityDirect Visibility = "direct"  ) @@ -268,7 +288,8 @@ const (  // swagger:type string  type StatusContentType string -// Content type to use when parsing submitted status into an html-formatted status +// Content type to use when parsing submitted +// status into an html-formatted status.  const (  	StatusContentTypePlain    StatusContentType = "text/plain"  	StatusContentTypeMarkdown StatusContentType = "text/markdown" @@ -280,11 +301,14 @@ const (  //  // swagger:model statusSource  type StatusSource struct { +  	// ID of the status.  	// example: 01FBVD42CQ3ZEEVMW180SBX03B  	ID string `json:"id"` +  	// Plain-text source of a status.  	Text string `json:"text"` +  	// Plain-text version of spoiler text.  	SpoilerText string `json:"spoiler_text"`  } @@ -294,27 +318,69 @@ type StatusSource struct {  //  // swagger:model statusEdit  type StatusEdit struct { +  	// The content of this status at this revision.  	// Should be HTML, but might also be plaintext in some cases.  	// example: <p>Hey this is a status!</p>  	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) { diff --git a/internal/db/bundb/status.go b/internal/db/bundb/status.go index fa31f3459..fea5594dd 100644 --- a/internal/db/bundb/status.go +++ b/internal/db/bundb/status.go @@ -297,17 +297,6 @@ func (s *statusDB) PopulateStatus(ctx context.Context, status *gtsmodel.Status)  		}  	} -	if !status.EditsPopulated() { -		// Status edits are out-of-date with IDs, repopulate. -		status.Edits, err = s.state.DB.GetStatusEditsByIDs( -			gtscontext.SetBarebones(ctx), -			status.EditIDs, -		) -		if err != nil { -			errs.Appendf("error populating status edits: %w", err) -		} -	} -  	if status.CreatedWithApplicationID != "" && status.CreatedWithApplication == nil {  		// Populate the status' expected CreatedWithApplication (not always set).  		status.CreatedWithApplication, err = s.state.DB.GetApplicationByID( @@ -322,6 +311,23 @@ func (s *statusDB) PopulateStatus(ctx context.Context, status *gtsmodel.Status)  	return errs.Combine()  } +func (s *statusDB) PopulateStatusEdits(ctx context.Context, status *gtsmodel.Status) error { +	var err error + +	if !status.EditsPopulated() { +		// Status edits are out-of-date with IDs, repopulate. +		status.Edits, err = s.state.DB.GetStatusEditsByIDs( +			gtscontext.SetBarebones(ctx), +			status.EditIDs, +		) +		if err != nil { +			return gtserror.Newf("error populating status edits: %w", err) +		} +	} + +	return nil +} +  func (s *statusDB) PutStatus(ctx context.Context, status *gtsmodel.Status) error {  	return s.state.Caches.DB.Status.Store(status, func() error {  		// It is safe to run this database transaction within cache.Store diff --git a/internal/db/status.go b/internal/db/status.go index ade900728..6bf9653c8 100644 --- a/internal/db/status.go +++ b/internal/db/status.go @@ -41,8 +41,12 @@ type Status interface {  	GetStatusBoost(ctx context.Context, boostOfID string, byAccountID string) (*gtsmodel.Status, error)  	// PopulateStatus ensures that all sub-models of a status are populated (e.g. mentions, attachments, etc). +	// Except for edits, to fetch these please call PopulateStatusEdits() .  	PopulateStatus(ctx context.Context, status *gtsmodel.Status) error +	// PopulateStatusEdits ensures that status' edits are fully popualted. +	PopulateStatusEdits(ctx context.Context, status *gtsmodel.Status) error +  	// PutStatus stores one status in the database.  	PutStatus(ctx context.Context, status *gtsmodel.Status) error diff --git a/internal/federation/dereferencing/dereferencer.go b/internal/federation/dereferencing/dereferencer.go index 3bff0d1a2..5e7b2b9c0 100644 --- a/internal/federation/dereferencing/dereferencer.go +++ b/internal/federation/dereferencing/dereferencer.go @@ -66,7 +66,7 @@ var (  	// causing loads of dereferencing calls.  	Fresh = util.Ptr(FreshnessWindow(5 * time.Minute)) -	// 10 seconds. +	// 5 seconds.  	//  	// Freshest is useful when you want an  	// immediately up to date model of something @@ -74,7 +74,7 @@ var (  	//  	// Be careful using this one; it can cause  	// lots of unnecessary traffic if used unwisely. -	Freshest = util.Ptr(FreshnessWindow(10 * time.Second)) +	Freshest = util.Ptr(FreshnessWindow(5 * time.Second))  )  // Dereferencer wraps logic and functionality for doing dereferencing diff --git a/internal/federation/dereferencing/status.go b/internal/federation/dereferencing/status.go index d19669891..0a75a4802 100644 --- a/internal/federation/dereferencing/status.go +++ b/internal/federation/dereferencing/status.go @@ -35,6 +35,7 @@ import (  	"github.com/superseriousbusiness/gotosocial/internal/log"  	"github.com/superseriousbusiness/gotosocial/internal/media"  	"github.com/superseriousbusiness/gotosocial/internal/util" +	"github.com/superseriousbusiness/gotosocial/internal/util/xslices"  )  // statusFresh returns true if the given status is still @@ -1000,12 +1001,21 @@ func (d *Dereferencer) fetchStatusEmojis(  	// Set latest emojis.  	status.Emojis = emojis -	// Iterate over and set changed emoji IDs. +	// Extract IDs from latest slice of emojis.  	status.EmojiIDs = make([]string, len(emojis))  	for i, emoji := range emojis {  		status.EmojiIDs[i] = emoji.ID  	} +	// Combine both old and new emojis, as statuses.emojis +	// keeps track of emojis for both old and current edits. +	status.EmojiIDs = append(status.EmojiIDs, existing.EmojiIDs...) +	status.Emojis = append(status.Emojis, existing.Emojis...) +	status.EmojiIDs = xslices.Deduplicate(status.EmojiIDs) +	status.Emojis = xslices.DeduplicateFunc(status.Emojis, +		func(e *gtsmodel.Emoji) string { return e.ID }, +	) +  	return true, nil  } @@ -1118,10 +1128,10 @@ func (d *Dereferencer) handleStatusEdit(  	var edited bool  	// Preallocate max slice length. -	cols = make([]string, 0, 13) +	cols = make([]string, 1, 13)  	// Always update `fetched_at`. -	cols = append(cols, "fetched_at") +	cols[0] = "fetched_at"  	// Check for edited status content.  	if existing.Content != status.Content { @@ -1187,6 +1197,13 @@ func (d *Dereferencer) handleStatusEdit(  		// Attached emojis changed.  		cols = append(cols, "emojis") // i.e. EmojiIDs +		// We specifically store both *new* AND *old* edit +		// revision emojis in the statuses.emojis column. +		emojiByID := func(e *gtsmodel.Emoji) string { return e.ID } +		status.Emojis = append(status.Emojis, existing.Emojis...) +		status.Emojis = xslices.DeduplicateFunc(status.Emojis, emojiByID) +		status.EmojiIDs = xslices.Gather(status.EmojiIDs[:0], status.Emojis, emojiByID) +  		// Emojis changed doesn't necessarily  		// indicate an edit, it may just not have  		// been previously populated properly. @@ -1230,7 +1247,8 @@ func (d *Dereferencer) handleStatusEdit(  			// Poll only set if existing contained them.  			edit.PollOptions = existing.Poll.Options -			if !*existing.Poll.HideCounts || pollChanged { +			if pollChanged || !*existing.Poll.HideCounts || +				!existing.Poll.ClosedAt.IsZero() {  				// If the counts are allowed to be  				// shown, or poll has changed, then  				// include poll vote counts in edit. diff --git a/internal/processing/admin/rule.go b/internal/processing/admin/rule.go index d1ee63cc8..8134c21cd 100644 --- a/internal/processing/admin/rule.go +++ b/internal/processing/admin/rule.go @@ -27,6 +27,7 @@ import (  	"github.com/superseriousbusiness/gotosocial/internal/gtserror"  	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"  	"github.com/superseriousbusiness/gotosocial/internal/id" +	"github.com/superseriousbusiness/gotosocial/internal/typeutils"  	"github.com/superseriousbusiness/gotosocial/internal/util"  ) @@ -42,7 +43,7 @@ func (p *Processor) RulesGet(  	apiRules := make([]*apimodel.AdminInstanceRule, len(rules))  	for i := range rules { -		apiRules[i] = p.converter.InstanceRuleToAdminAPIRule(&rules[i]) +		apiRules[i] = typeutils.InstanceRuleToAdminAPIRule(&rules[i])  	}  	return apiRules, nil @@ -58,7 +59,7 @@ func (p *Processor) RuleGet(ctx context.Context, id string) (*apimodel.AdminInst  		return nil, gtserror.NewErrorInternalError(err)  	} -	return p.converter.InstanceRuleToAdminAPIRule(rule), nil +	return typeutils.InstanceRuleToAdminAPIRule(rule), nil  }  // RuleCreate adds a new rule to the instance. @@ -77,7 +78,7 @@ func (p *Processor) RuleCreate(ctx context.Context, form *apimodel.InstanceRuleC  		return nil, gtserror.NewErrorInternalError(err)  	} -	return p.converter.InstanceRuleToAdminAPIRule(rule), nil +	return typeutils.InstanceRuleToAdminAPIRule(rule), nil  }  // RuleUpdate updates text for an existing rule. @@ -99,7 +100,7 @@ func (p *Processor) RuleUpdate(ctx context.Context, id string, form *apimodel.In  		return nil, gtserror.NewErrorInternalError(err)  	} -	return p.converter.InstanceRuleToAdminAPIRule(updatedRule), nil +	return typeutils.InstanceRuleToAdminAPIRule(updatedRule), nil  }  // RuleDelete deletes an existing rule. @@ -120,5 +121,5 @@ func (p *Processor) RuleDelete(ctx context.Context, id string) (*apimodel.AdminI  		return nil, gtserror.NewErrorInternalError(err)  	} -	return p.converter.InstanceRuleToAdminAPIRule(deletedRule), nil +	return typeutils.InstanceRuleToAdminAPIRule(deletedRule), nil  } diff --git a/internal/processing/common/status.go b/internal/processing/common/status.go index da5cf1290..01f2ab72d 100644 --- a/internal/processing/common/status.go +++ b/internal/processing/common/status.go @@ -31,6 +31,40 @@ import (  	"github.com/superseriousbusiness/gotosocial/internal/log"  ) +// GetOwnStatus fetches the given status with ID, +// and ensures that it belongs to given requester. +func (p *Processor) GetOwnStatus( +	ctx context.Context, +	requester *gtsmodel.Account, +	targetID string, +) ( +	*gtsmodel.Status, +	gtserror.WithCode, +) { +	target, err := p.state.DB.GetStatusByID(ctx, targetID) +	if err != nil && !errors.Is(err, db.ErrNoEntries) { +		err := gtserror.Newf("error getting from db: %w", err) +		return nil, gtserror.NewErrorInternalError(err) +	} + +	switch { +	case target == nil: +		const text = "target status not found" +		return nil, gtserror.NewErrorNotFound( +			errors.New(text), +			text, +		) + +	case target.AccountID != requester.ID: +		return nil, gtserror.NewErrorNotFound( +			errors.New("status does not belong to requester"), +			"target status not found", +		) +	} + +	return target, nil +} +  // GetTargetStatusBy fetches the target status with db load  // function, given the authorized (or, nil) requester's  // account. This returns an approprate gtserror.WithCode diff --git a/internal/processing/instance.go b/internal/processing/instance.go index fab71b1de..2f4c40416 100644 --- a/internal/processing/instance.go +++ b/internal/processing/instance.go @@ -29,6 +29,7 @@ import (  	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"  	"github.com/superseriousbusiness/gotosocial/internal/log"  	"github.com/superseriousbusiness/gotosocial/internal/text" +	"github.com/superseriousbusiness/gotosocial/internal/typeutils"  	"github.com/superseriousbusiness/gotosocial/internal/util"  	"github.com/superseriousbusiness/gotosocial/internal/validate"  ) @@ -133,7 +134,7 @@ func (p *Processor) InstanceGetRules(ctx context.Context) ([]apimodel.InstanceRu  		return nil, gtserror.NewErrorInternalError(fmt.Errorf("db error fetching instance: %s", err))  	} -	return p.converter.InstanceRulesToAPIRules(i.Rules), nil +	return typeutils.InstanceRulesToAPIRules(i.Rules), nil  }  func (p *Processor) InstancePatch(ctx context.Context, form *apimodel.InstanceSettingsUpdateRequest) (*apimodel.InstanceV1, gtserror.WithCode) { diff --git a/internal/processing/media/create.go b/internal/processing/media/create.go index ca1f1c3c6..5ea630618 100644 --- a/internal/processing/media/create.go +++ b/internal/processing/media/create.go @@ -25,6 +25,7 @@ import (  	"codeberg.org/gruf/go-iotools"  	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/gtsmodel" @@ -45,10 +46,21 @@ func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, form  	}  	// Parse focus details from API form input. -	focusX, focusY, err := parseFocus(form.Focus) -	if err != nil { -		text := fmt.Sprintf("could not parse focus value %s: %s", form.Focus, err) -		return nil, gtserror.NewErrorBadRequest(errors.New(text), text) +	focusX, focusY, errWithCode := apiutil.ParseFocus(form.Focus) +	if errWithCode != nil { +		return nil, errWithCode +	} + +	// If description provided, +	// process and validate it. +	// +	// This may not yet be set as it +	// is often set on status post. +	if form.Description != "" { +		form.Description, errWithCode = processDescription(form.Description) +		if errWithCode != nil { +			return nil, errWithCode +		}  	}  	// Open multipart file reader. @@ -58,7 +70,7 @@ func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, form  		return nil, gtserror.NewErrorInternalError(err)  	} -	// Wrap the multipart file reader to ensure is limited to max. +	// Wrap multipart file reader to ensure is limited to max size.  	rc, _, _ := iotools.UpdateReadCloserLimit(mpfile, maxszInt64)  	// Create local media and write to instance storage. diff --git a/internal/processing/media/update.go b/internal/processing/media/update.go index d3a9cfe61..c8592395f 100644 --- a/internal/processing/media/update.go +++ b/internal/processing/media/update.go @@ -23,6 +23,8 @@ import (  	"fmt"  	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/db"  	"github.com/superseriousbusiness/gotosocial/internal/gtserror"  	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" @@ -47,17 +49,27 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, media  	var updatingColumns []string  	if form.Description != nil { -		attachment.Description = text.SanitizeToPlaintext(*form.Description) +		// Sanitize and validate incoming description. +		description, errWithCode := processDescription( +			*form.Description, +		) +		if errWithCode != nil { +			return nil, errWithCode +		} + +		attachment.Description = description  		updatingColumns = append(updatingColumns, "description")  	}  	if form.Focus != nil { -		focusx, focusy, err := parseFocus(*form.Focus) -		if err != nil { -			return nil, gtserror.NewErrorBadRequest(err) +		// Parse focus details from API form input. +		focusX, focusY, errWithCode := apiutil.ParseFocus(*form.Focus) +		if errWithCode != nil { +			return nil, errWithCode  		} -		attachment.FileMeta.Focus.X = focusx -		attachment.FileMeta.Focus.Y = focusy + +		attachment.FileMeta.Focus.X = focusX +		attachment.FileMeta.Focus.Y = focusY  		updatingColumns = append(updatingColumns, "focus_x", "focus_y")  	} @@ -72,3 +84,21 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, media  	return &a, nil  } + +// processDescription will sanitize and valid description against server configuration. +func processDescription(description string) (string, gtserror.WithCode) { +	description = text.SanitizeToPlaintext(description) +	chars := len([]rune(description)) + +	if min := config.GetMediaDescriptionMinChars(); chars < min { +		text := fmt.Sprintf("media description less than min chars (%d)", min) +		return "", gtserror.NewErrorBadRequest(errors.New(text), text) +	} + +	if max := config.GetMediaDescriptionMaxChars(); chars > max { +		text := fmt.Sprintf("media description exceeds max chars (%d)", max) +		return "", gtserror.NewErrorBadRequest(errors.New(text), text) +	} + +	return description, nil +} diff --git a/internal/processing/media/util.go b/internal/processing/media/util.go deleted file mode 100644 index 0ca2697fd..000000000 --- a/internal/processing/media/util.go +++ /dev/null @@ -1,62 +0,0 @@ -// 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 media - -import ( -	"fmt" -	"strconv" -	"strings" -) - -func parseFocus(focus string) (focusx, focusy float32, err error) { -	if focus == "" { -		return -	} -	spl := strings.Split(focus, ",") -	if len(spl) != 2 { -		err = fmt.Errorf("improperly formatted focus %s", focus) -		return -	} -	xStr := spl[0] -	yStr := spl[1] -	if xStr == "" || yStr == "" { -		err = fmt.Errorf("improperly formatted focus %s", focus) -		return -	} -	fx, err := strconv.ParseFloat(xStr, 32) -	if err != nil { -		err = fmt.Errorf("improperly formatted focus %s: %s", focus, err) -		return -	} -	if fx > 1 || fx < -1 { -		err = fmt.Errorf("improperly formatted focus %s", focus) -		return -	} -	focusx = float32(fx) -	fy, err := strconv.ParseFloat(yStr, 32) -	if err != nil { -		err = fmt.Errorf("improperly formatted focus %s: %s", focus, err) -		return -	} -	if fy > 1 || fy < -1 { -		err = fmt.Errorf("improperly formatted focus %s", focus) -		return -	} -	focusy = float32(fy) -	return -} diff --git a/internal/processing/status/common.go b/internal/processing/status/common.go new file mode 100644 index 000000000..3f2b7b6cb --- /dev/null +++ b/internal/processing/status/common.go @@ -0,0 +1,351 @@ +// 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 status + +import ( +	"context" +	"errors" +	"fmt" +	"time" + +	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +	"github.com/superseriousbusiness/gotosocial/internal/config" +	"github.com/superseriousbusiness/gotosocial/internal/db" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/id" +	"github.com/superseriousbusiness/gotosocial/internal/text" +	"github.com/superseriousbusiness/gotosocial/internal/util/xslices" +	"github.com/superseriousbusiness/gotosocial/internal/validate" +) + +// validateStatusContent will validate the common +// content fields across status write endpoints against +// current server configuration (e.g. max char counts). +func validateStatusContent( +	status string, +	spoiler string, +	mediaIDs []string, +	poll *apimodel.PollRequest, +) gtserror.WithCode { +	totalChars := len([]rune(status)) + +		len([]rune(spoiler)) + +	if totalChars == 0 && len(mediaIDs) == 0 && poll == nil { +		const text = "status contains no text, media or poll" +		return gtserror.NewErrorBadRequest(errors.New(text), text) +	} + +	if max := config.GetStatusesMaxChars(); totalChars > max { +		text := fmt.Sprintf("text with spoiler exceed max chars (%d)", max) +		return gtserror.NewErrorBadRequest(errors.New(text), text) +	} + +	if max := config.GetStatusesMediaMaxFiles(); len(mediaIDs) > max { +		text := fmt.Sprintf("media files exceed max count (%d)", max) +		return gtserror.NewErrorBadRequest(errors.New(text), text) +	} + +	if poll != nil { +		switch max := config.GetStatusesPollMaxOptions(); { +		case len(poll.Options) == 0: +			const text = "poll cannot have no options" +			return gtserror.NewErrorBadRequest(errors.New(text), text) + +		case len(poll.Options) > max: +			text := fmt.Sprintf("poll options exceed max count (%d)", max) +			return gtserror.NewErrorBadRequest(errors.New(text), text) +		} + +		max := config.GetStatusesPollOptionMaxChars() +		for i, option := range poll.Options { +			switch l := len([]rune(option)); { +			case l == 0: +				const text = "poll option cannot be empty" +				return gtserror.NewErrorBadRequest(errors.New(text), text) + +			case l > max: +				text := fmt.Sprintf("poll option %d exceed max chars (%d)", i, max) +				return gtserror.NewErrorBadRequest(errors.New(text), text) +			} +		} +	} + +	return nil +} + +// statusContent encompasses the set of common processed +// status content fields from status write operations for +// an easily returnable type, without needing to allocate +// an entire gtsmodel.Status{} model. +type statusContent struct { +	Content        string +	ContentWarning string +	PollOptions    []string +	Language       string +	MentionIDs     []string +	Mentions       []*gtsmodel.Mention +	EmojiIDs       []string +	Emojis         []*gtsmodel.Emoji +	TagIDs         []string +	Tags           []*gtsmodel.Tag +} + +func (p *Processor) processContent( +	ctx context.Context, +	author *gtsmodel.Account, +	statusID string, +	contentType string, +	content string, +	contentWarning string, +	language string, +	poll *apimodel.PollRequest, +) ( +	*statusContent, +	gtserror.WithCode, +) { +	if language == "" { +		// Ensure we have a status language. +		language = author.Settings.Language +		if language == "" { +			const text = "account default language unset" +			return nil, gtserror.NewErrorInternalError( +				errors.New(text), +			) +		} +	} + +	var err error + +	// Validate + normalize determined language. +	language, err = validate.Language(language) +	if err != nil { +		text := fmt.Sprintf("invalid language tag: %v", err) +		return nil, gtserror.NewErrorBadRequest( +			errors.New(text), +			text, +		) +	} + +	// format is the currently set text formatting +	// function, according to the provided content-type. +	var format text.FormatFunc + +	if contentType == "" { +		// If content type wasn't specified, use +		// the author's preferred content-type. +		contentType = author.Settings.StatusContentType +	} + +	switch contentType { + +	// Format status according to text/plain. +	case "", string(apimodel.StatusContentTypePlain): +		format = p.formatter.FromPlain + +	// Format status according to text/markdown. +	case string(apimodel.StatusContentTypeMarkdown): +		format = p.formatter.FromMarkdown + +	// Unknown. +	default: +		const text = "invalid status format" +		return nil, gtserror.NewErrorBadRequest( +			errors.New(text), +			text, +		) +	} + +	// Allocate a structure to hold the +	// majority of formatted content without +	// needing to alloc a whole gtsmodel.Status{}. +	var status statusContent +	status.Language = language + +	// formatInput is a shorthand function to format the given input string with the +	// currently set 'formatFunc', passing in all required args and returning result. +	formatInput := func(formatFunc text.FormatFunc, input string) *text.FormatResult { +		return formatFunc(ctx, p.parseMention, author.ID, statusID, input) +	} + +	// Sanitize input status text and format. +	contentRes := formatInput(format, content) + +	// Gather results of formatted. +	status.Content = contentRes.HTML +	status.Mentions = contentRes.Mentions +	status.Emojis = contentRes.Emojis +	status.Tags = contentRes.Tags + +	// From here-on-out just use emoji-only +	// plain-text formatting as the FormatFunc. +	format = p.formatter.FromPlainEmojiOnly + +	// Sanitize content warning and format. +	warning := text.SanitizeToPlaintext(contentWarning) +	warningRes := formatInput(format, warning) + +	// Gather results of the formatted. +	status.ContentWarning = warningRes.HTML +	status.Emojis = append(status.Emojis, warningRes.Emojis...) + +	if poll != nil { +		// Pre-allocate slice of poll options of expected length. +		status.PollOptions = make([]string, len(poll.Options)) +		for i, option := range poll.Options { + +			// Sanitize each poll option and format. +			option = text.SanitizeToPlaintext(option) +			optionRes := formatInput(format, option) + +			// Gather results of the formatted. +			status.PollOptions[i] = optionRes.HTML +			status.Emojis = append(status.Emojis, optionRes.Emojis...) +		} + +		// Also update options on the form. +		poll.Options = status.PollOptions +	} + +	// We may have received multiple copies of the same emoji, deduplicate these first. +	status.Emojis = xslices.DeduplicateFunc(status.Emojis, func(e *gtsmodel.Emoji) string { +		return e.ID +	}) + +	// Gather up the IDs of mentions from parsed content. +	status.MentionIDs = xslices.Gather(nil, status.Mentions, +		func(m *gtsmodel.Mention) string { +			return m.ID +		}, +	) + +	// Gather up the IDs of tags from parsed content. +	status.TagIDs = xslices.Gather(nil, status.Tags, +		func(t *gtsmodel.Tag) string { +			return t.ID +		}, +	) + +	// Gather up the IDs of emojis in updated content. +	status.EmojiIDs = xslices.Gather(nil, status.Emojis, +		func(e *gtsmodel.Emoji) string { +			return e.ID +		}, +	) + +	return &status, nil +} + +func (p *Processor) processMedia( +	ctx context.Context, +	authorID string, +	statusID string, +	mediaIDs []string, +) ( +	[]*gtsmodel.MediaAttachment, +	gtserror.WithCode, +) { +	// No media provided! +	if len(mediaIDs) == 0 { +		return nil, nil +	} + +	// Get configured min/max supported descr chars. +	minChars := config.GetMediaDescriptionMinChars() +	maxChars := config.GetMediaDescriptionMaxChars() + +	// Pre-allocate slice of media attachments of expected length. +	attachments := make([]*gtsmodel.MediaAttachment, len(mediaIDs)) +	for i, id := range mediaIDs { + +		// Look for media attachment by ID in database. +		media, err := p.state.DB.GetAttachmentByID(ctx, id) +		if err != nil && !errors.Is(err, db.ErrNoEntries) { +			err := gtserror.Newf("error getting media from db: %w", err) +			return nil, gtserror.NewErrorInternalError(err) +		} + +		// Check media exists and is owned by author +		// (this masks finding out media ownership info). +		if media == nil || media.AccountID != authorID { +			text := fmt.Sprintf("media not found: %s", id) +			return nil, gtserror.NewErrorBadRequest(errors.New(text), text) +		} + +		// Check media isn't already attached to another status. +		if (media.StatusID != "" && media.StatusID != statusID) || +			(media.ScheduledStatusID != "" && media.ScheduledStatusID != statusID) { +			text := fmt.Sprintf("media already attached to status: %s", id) +			return nil, gtserror.NewErrorBadRequest(errors.New(text), text) +		} + +		// Check media description chars within range, +		// this needs to be done here as lots of clients +		// only update media description on status post. +		switch chars := len([]rune(media.Description)); { +		case chars < minChars: +			text := fmt.Sprintf("media description less than min chars (%d)", minChars) +			return nil, gtserror.NewErrorBadRequest(errors.New(text), text) + +		case chars > maxChars: +			text := fmt.Sprintf("media description exceeds max chars (%d)", maxChars) +			return nil, gtserror.NewErrorBadRequest(errors.New(text), text) +		} + +		// Set media at index. +		attachments[i] = media +	} + +	return attachments, nil +} + +func (p *Processor) processPoll( +	ctx context.Context, +	statusID string, +	form *apimodel.PollRequest, +	now time.Time, // used for expiry time +) ( +	*gtsmodel.Poll, +	gtserror.WithCode, +) { +	var expiresAt time.Time + +	// Set an expiry time if one given. +	if in := form.ExpiresIn; in > 0 { +		expiresIn := time.Duration(in) +		expiresAt = now.Add(expiresIn * time.Second) +	} + +	// Create new poll model. +	poll := >smodel.Poll{ +		ID:         id.NewULIDFromTime(now), +		Multiple:   &form.Multiple, +		HideCounts: &form.HideTotals, +		Options:    form.Options, +		StatusID:   statusID, +		ExpiresAt:  expiresAt, +	} + +	// Insert the newly created poll model in the database. +	if err := p.state.DB.PutPoll(ctx, poll); err != nil { +		err := gtserror.Newf("error inserting poll in db: %w", err) +		return nil, gtserror.NewErrorInternalError(err) +	} + +	return poll, nil +} diff --git a/internal/processing/status/create.go b/internal/processing/status/create.go index 340cf9ff3..af9831b9c 100644 --- a/internal/processing/status/create.go +++ b/internal/processing/status/create.go @@ -19,29 +19,22 @@ package status  import (  	"context" -	"errors" -	"fmt"  	"time"  	"github.com/superseriousbusiness/gotosocial/internal/ap"  	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" -	"github.com/superseriousbusiness/gotosocial/internal/config" -	"github.com/superseriousbusiness/gotosocial/internal/db"  	"github.com/superseriousbusiness/gotosocial/internal/gtserror"  	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"  	"github.com/superseriousbusiness/gotosocial/internal/id"  	"github.com/superseriousbusiness/gotosocial/internal/log"  	"github.com/superseriousbusiness/gotosocial/internal/messages" -	"github.com/superseriousbusiness/gotosocial/internal/text"  	"github.com/superseriousbusiness/gotosocial/internal/typeutils"  	"github.com/superseriousbusiness/gotosocial/internal/uris"  	"github.com/superseriousbusiness/gotosocial/internal/util" -	"github.com/superseriousbusiness/gotosocial/internal/util/xslices"  )  // Create processes the given form to create a new status, returning the api model representation of that status if it's OK. -// -// Precondition: the form's fields should have already been validated and normalized by the caller. +// Note this also handles validation of incoming form field data.  func (p *Processor) Create(  	ctx context.Context,  	requester *gtsmodel.Account, @@ -51,7 +44,17 @@ func (p *Processor) Create(  	*apimodel.Status,  	gtserror.WithCode,  ) { -	// Ensure account populated; we'll need settings. +	// Validate incoming form status content. +	if errWithCode := validateStatusContent( +		form.Status, +		form.SpoilerText, +		form.MediaIDs, +		form.Poll, +	); errWithCode != nil { +		return nil, errWithCode +	} + +	// Ensure account populated; we'll need their settings.  	if err := p.state.DB.PopulateAccount(ctx, requester); err != nil {  		log.Errorf(ctx, "error(s) populating account, will continue: %s", err)  	} @@ -59,6 +62,30 @@ func (p *Processor) Create(  	// Generate new ID for status.  	statusID := id.NewULID() +	// Process incoming status content fields. +	content, errWithCode := p.processContent(ctx, +		requester, +		statusID, +		string(form.ContentType), +		form.Status, +		form.SpoilerText, +		form.Language, +		form.Poll, +	) +	if errWithCode != nil { +		return nil, errWithCode +	} + +	// Process incoming status attachments. +	media, errWithCode := p.processMedia(ctx, +		requester.ID, +		statusID, +		form.MediaIDs, +	) +	if errWithCode != nil { +		return nil, errWithCode +	} +  	// Generate necessary URIs for username, to build status URIs.  	accountURIs := uris.GenerateURIsForAccount(requester.Username) @@ -78,16 +105,36 @@ func (p *Processor) Create(  		ActivityStreamsType:      ap.ObjectNote,  		Sensitive:                &form.Sensitive,  		CreatedWithApplicationID: application.ID, -		Text:                     form.Status, + +		// Set validated language. +		Language: content.Language, + +		// Set formatted status content. +		Content:        content.Content, +		ContentWarning: content.ContentWarning, +		Text:           form.Status, // raw + +		// Set gathered mentions. +		MentionIDs: content.MentionIDs, +		Mentions:   content.Mentions, + +		// Set gathered emojis. +		EmojiIDs: content.EmojiIDs, +		Emojis:   content.Emojis, + +		// Set gathered tags. +		TagIDs: content.TagIDs, +		Tags:   content.Tags, + +		// Set gathered media. +		AttachmentIDs: form.MediaIDs, +		Attachments:   media,  		// Assume not pending approval; this may  		// change when permissivity is checked.  		PendingApproval: util.Ptr(false),  	} -	// Process any attached poll. -	p.processPoll(status, form.Poll) -  	// Check + attach in-reply-to status.  	if errWithCode := p.processInReplyTo(ctx,  		requester, @@ -101,10 +148,6 @@ func (p *Processor) Create(  		return nil, errWithCode  	} -	if errWithCode := p.processMediaIDs(ctx, form, requester.ID, status); errWithCode != nil { -		return nil, errWithCode -	} -  	if err := p.processVisibility(ctx, form, requester.Settings.Privacy, status); err != nil {  		return nil, gtserror.NewErrorInternalError(err)  	} @@ -115,36 +158,49 @@ func (p *Processor) Create(  		return nil, errWithCode  	} -	if err := processLanguage(form, requester.Settings.Language, status); err != nil { -		return nil, gtserror.NewErrorInternalError(err) -	} - -	if err := p.processContent(ctx, p.parseMention, form, status); err != nil { -		return nil, gtserror.NewErrorInternalError(err) +	if status.ContentWarning != "" && len(status.AttachmentIDs) > 0 { +		// If a content-warning is set, and +		// the status contains media, always +		// set the status sensitive flag. +		status.Sensitive = util.Ptr(true)  	} -	if status.Poll != nil { -		// Try to insert the new status poll in the database. -		if err := p.state.DB.PutPoll(ctx, status.Poll); err != nil { -			err := gtserror.Newf("error inserting poll in db: %w", err) -			return nil, gtserror.NewErrorInternalError(err) +	if form.Poll != nil { +		// Process poll, inserting into database. +		poll, errWithCode := p.processPoll(ctx, +			statusID, +			form.Poll, +			now, +		) +		if errWithCode != nil { +			return nil, errWithCode  		} + +		// Set poll and its ID +		// on status before insert. +		status.PollID = poll.ID +		status.Poll = poll +		poll.Status = status + +		// Update the status' ActivityPub type to Question. +		status.ActivityStreamsType = ap.ActivityQuestion  	} -	// Insert this new status in the database. +	// Insert this newly prepared status into the database.  	if err := p.state.DB.PutStatus(ctx, status); err != nil { +		err := gtserror.Newf("error inserting status in db: %w", err)  		return nil, gtserror.NewErrorInternalError(err)  	}  	if status.Poll != nil && !status.Poll.ExpiresAt.IsZero() { -		// Now that the status is inserted, and side effects queued, -		// attempt to schedule an expiry handler for the status poll. +		// Now that the status is inserted, attempt to +		// schedule an expiry handler for the status poll.  		if err := p.polls.ScheduleExpiry(ctx, status.Poll); err != nil {  			log.Errorf(ctx, "error scheduling poll expiry: %v", err)  		}  	} -	// send it back to the client API worker for async side-effects. +	// Send it to the client API worker for async side-effects.  	p.state.Workers.Client.Queue.Push(&messages.FromClientAPI{  		APObjectType:   ap.ObjectNote,  		APActivityType: ap.ActivityCreate, @@ -172,43 +228,6 @@ func (p *Processor) Create(  	return p.c.GetAPIStatus(ctx, requester, status)  } -func (p *Processor) processPoll(status *gtsmodel.Status, poll *apimodel.PollRequest) { -	if poll == nil { -		// No poll set. -		// Nothing to do. -		return -	} - -	var expiresAt time.Time - -	// Now will have been set -	// as the status creation. -	now := status.CreatedAt - -	// Update the status AS type to "Question". -	status.ActivityStreamsType = ap.ActivityQuestion - -	// Set an expiry time if one given. -	if in := poll.ExpiresIn; in > 0 { -		expiresIn := time.Duration(in) -		expiresAt = now.Add(expiresIn * time.Second) -	} - -	// Create new poll for status. -	status.Poll = >smodel.Poll{ -		ID:         id.NewULID(), -		Multiple:   &poll.Multiple, -		HideCounts: &poll.HideTotals, -		Options:    poll.Options, -		StatusID:   status.ID, -		Status:     status, -		ExpiresAt:  expiresAt, -	} - -	// Set poll ID on the status. -	status.PollID = status.Poll.ID -} -  func (p *Processor) processInReplyTo(ctx context.Context, requester *gtsmodel.Account, status *gtsmodel.Status, inReplyToID string) gtserror.WithCode {  	if inReplyToID == "" {  		// Not a reply. @@ -332,53 +351,6 @@ func (p *Processor) processThreadID(ctx context.Context, status *gtsmodel.Status  	return nil  } -func (p *Processor) processMediaIDs(ctx context.Context, form *apimodel.StatusCreateRequest, thisAccountID string, status *gtsmodel.Status) gtserror.WithCode { -	if form.MediaIDs == nil { -		return nil -	} - -	// Get minimum allowed char descriptions. -	minChars := config.GetMediaDescriptionMinChars() - -	attachments := []*gtsmodel.MediaAttachment{} -	attachmentIDs := []string{} - -	for _, mediaID := range form.MediaIDs { -		attachment, err := p.state.DB.GetAttachmentByID(ctx, mediaID) -		if err != nil && !errors.Is(err, db.ErrNoEntries) { -			err := gtserror.Newf("error fetching media from db: %w", err) -			return gtserror.NewErrorInternalError(err) -		} - -		if attachment == nil { -			text := fmt.Sprintf("media %s not found", mediaID) -			return gtserror.NewErrorBadRequest(errors.New(text), text) -		} - -		if attachment.AccountID != thisAccountID { -			text := fmt.Sprintf("media %s does not belong to account", mediaID) -			return gtserror.NewErrorBadRequest(errors.New(text), text) -		} - -		if attachment.StatusID != "" || attachment.ScheduledStatusID != "" { -			text := fmt.Sprintf("media %s already attached to status", mediaID) -			return gtserror.NewErrorBadRequest(errors.New(text), text) -		} - -		if length := len([]rune(attachment.Description)); length < minChars { -			text := fmt.Sprintf("media %s description too short, at least %d required", mediaID, minChars) -			return gtserror.NewErrorBadRequest(errors.New(text), text) -		} - -		attachments = append(attachments, attachment) -		attachmentIDs = append(attachmentIDs, attachment.ID) -	} - -	status.Attachments = attachments -	status.AttachmentIDs = attachmentIDs -	return nil -} -  func (p *Processor) processVisibility(  	ctx context.Context,  	form *apimodel.StatusCreateRequest, @@ -474,99 +446,3 @@ func processInteractionPolicy(  	// setting it explicitly to save space.  	return nil  } - -func processLanguage(form *apimodel.StatusCreateRequest, accountDefaultLanguage string, status *gtsmodel.Status) error { -	if form.Language != "" { -		status.Language = form.Language -	} else { -		status.Language = accountDefaultLanguage -	} -	if status.Language == "" { -		return errors.New("no language given either in status create form or account default") -	} -	return nil -} - -func (p *Processor) processContent(ctx context.Context, parseMention gtsmodel.ParseMentionFunc, form *apimodel.StatusCreateRequest, status *gtsmodel.Status) error { -	if form.ContentType == "" { -		// If content type wasn't specified, use the author's preferred content-type. -		contentType := apimodel.StatusContentType(status.Account.Settings.StatusContentType) -		form.ContentType = contentType -	} - -	// format is the currently set text formatting -	// function, according to the provided content-type. -	var format text.FormatFunc - -	// formatInput is a shorthand function to format the given input string with the -	// currently set 'formatFunc', passing in all required args and returning result. -	formatInput := func(formatFunc text.FormatFunc, input string) *text.FormatResult { -		return formatFunc(ctx, parseMention, status.AccountID, status.ID, input) -	} - -	switch form.ContentType { -	// None given / set, -	// use default (plain). -	case "": -		fallthrough - -	// Format status according to text/plain. -	case apimodel.StatusContentTypePlain: -		format = p.formatter.FromPlain - -	// Format status according to text/markdown. -	case apimodel.StatusContentTypeMarkdown: -		format = p.formatter.FromMarkdown - -	// Unknown. -	default: -		return fmt.Errorf("invalid status format: %q", form.ContentType) -	} - -	// Sanitize status text and format. -	contentRes := formatInput(format, form.Status) - -	// Collect formatted results. -	status.Content = contentRes.HTML -	status.Mentions = append(status.Mentions, contentRes.Mentions...) -	status.Emojis = append(status.Emojis, contentRes.Emojis...) -	status.Tags = append(status.Tags, contentRes.Tags...) - -	// From here-on-out just use emoji-only -	// plain-text formatting as the FormatFunc. -	format = p.formatter.FromPlainEmojiOnly - -	// Sanitize content warning and format. -	spoiler := text.SanitizeToPlaintext(form.SpoilerText) -	warningRes := formatInput(format, spoiler) - -	// Collect formatted results. -	status.ContentWarning = warningRes.HTML -	status.Emojis = append(status.Emojis, warningRes.Emojis...) - -	if status.Poll != nil { -		for i := range status.Poll.Options { -			// Sanitize each option title name and format. -			option := text.SanitizeToPlaintext(status.Poll.Options[i]) -			optionRes := formatInput(format, option) - -			// Collect each formatted result. -			status.Poll.Options[i] = optionRes.HTML -			status.Emojis = append(status.Emojis, optionRes.Emojis...) -		} -	} - -	// Gather all the database IDs from each of the gathered status mentions, tags, and emojis. -	status.MentionIDs = xslices.Gather(nil, status.Mentions, func(mention *gtsmodel.Mention) string { return mention.ID }) -	status.TagIDs = xslices.Gather(nil, status.Tags, func(tag *gtsmodel.Tag) string { return tag.ID }) -	status.EmojiIDs = xslices.Gather(nil, status.Emojis, func(emoji *gtsmodel.Emoji) string { return emoji.ID }) - -	if status.ContentWarning != "" && len(status.AttachmentIDs) > 0 { -		// If a content-warning is set, and -		// the status contains media, always -		// set the status sensitive flag. -		status.Sensitive = util.Ptr(true) -	} - -	return nil -} diff --git a/internal/processing/status/create_test.go b/internal/processing/status/create_test.go index 84168880e..d0a5c7f92 100644 --- a/internal/processing/status/create_test.go +++ b/internal/processing/status/create_test.go @@ -170,7 +170,7 @@ func (suite *StatusCreateTestSuite) TestProcessMediaDescriptionTooShort() {  	}  	apiStatus, err := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm) -	suite.EqualError(err, "media 01F8MH8RMYQ6MSNY3JM2XT1CQ5 description too short, at least 100 required") +	suite.EqualError(err, "media description less than min chars (100)")  	suite.Nil(apiStatus)  } diff --git a/internal/processing/status/edit.go b/internal/processing/status/edit.go new file mode 100644 index 000000000..d16092a57 --- /dev/null +++ b/internal/processing/status/edit.go @@ -0,0 +1,555 @@ +// 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 status + +import ( +	"context" +	"errors" +	"fmt" +	"slices" +	"time" + +	"github.com/superseriousbusiness/gotosocial/internal/ap" +	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +	apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" +	"github.com/superseriousbusiness/gotosocial/internal/db" +	"github.com/superseriousbusiness/gotosocial/internal/gtserror" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/id" +	"github.com/superseriousbusiness/gotosocial/internal/log" +	"github.com/superseriousbusiness/gotosocial/internal/messages" +	"github.com/superseriousbusiness/gotosocial/internal/util/xslices" +) + +// Edit ... +func (p *Processor) Edit( +	ctx context.Context, +	requester *gtsmodel.Account, +	statusID string, +	form *apimodel.StatusEditRequest, +) ( +	*apimodel.Status, +	gtserror.WithCode, +) { +	// Fetch status and ensure it's owned by requesting account. +	status, errWithCode := p.c.GetOwnStatus(ctx, requester, statusID) +	if errWithCode != nil { +		return nil, errWithCode +	} + +	// Ensure this isn't a boost. +	if status.BoostOfID != "" { +		return nil, gtserror.NewErrorNotFound( +			errors.New("status is a boost wrapper"), +			"target status not found", +		) +	} + +	// Ensure account populated; we'll need their settings. +	if err := p.state.DB.PopulateAccount(ctx, requester); err != nil { +		log.Errorf(ctx, "error(s) populating account, will continue: %s", err) +	} + +	// We need the status populated including all historical edits. +	if err := p.state.DB.PopulateStatusEdits(ctx, status); err != nil { +		err := gtserror.Newf("error getting status edits from db: %w", err) +		return nil, gtserror.NewErrorInternalError(err) +	} + +	// Time of edit. +	now := time.Now() + +	// Validate incoming form edit content. +	if errWithCode := validateStatusContent( +		form.Status, +		form.SpoilerText, +		form.MediaIDs, +		form.Poll, +	); errWithCode != nil { +		return nil, errWithCode +	} + +	// Process incoming status edit content fields. +	content, errWithCode := p.processContent(ctx, +		requester, +		statusID, +		string(form.ContentType), +		form.Status, +		form.SpoilerText, +		form.Language, +		form.Poll, +	) +	if errWithCode != nil { +		return nil, errWithCode +	} + +	// Process new status attachments to use. +	media, errWithCode := p.processMedia(ctx, +		requester.ID, +		statusID, +		form.MediaIDs, +	) +	if errWithCode != nil { +		return nil, errWithCode +	} + +	// Process incoming edits of any attached media. +	mediaEdited, errWithCode := p.processMediaEdits(ctx, +		media, +		form.MediaAttributes, +	) +	if errWithCode != nil { +		return nil, errWithCode +	} + +	// Process incoming edits of any attached status poll. +	poll, pollEdited, errWithCode := p.processPollEdit(ctx, +		statusID, +		status.Poll, +		form.Poll, +		now, +	) +	if errWithCode != nil { +		return nil, errWithCode +	} + +	// Check if new status poll was set. +	pollChanged := (poll != status.Poll) + +	// Determine whether there were any changes possibly +	// causing a change to embedded mentions, tags, emojis. +	contentChanged := (status.Content != content.Content) +	warningChanged := (status.ContentWarning != content.ContentWarning) +	languageChanged := (status.Language != content.Language) +	anyContentChanged := contentChanged || warningChanged || +		pollEdited // encapsulates pollChanged too + +	// Check if status media attachments have changed. +	mediaChanged := !slices.Equal(status.AttachmentIDs, +		form.MediaIDs, +	) + +	// Track status columns we +	// need to update in database. +	cols := make([]string, 2, 13) +	cols[0] = "updated_at" +	cols[1] = "edits" + +	if contentChanged { +		// Update status text. +		// +		// Note we don't update these +		// status fields right away so +		// we can save current version. +		cols = append(cols, "content") +		cols = append(cols, "text") +	} + +	if warningChanged { +		// Update status content warning. +		// +		// Note we don't update these +		// status fields right away so +		// we can save current version. +		cols = append(cols, "content_warning") +	} + +	if languageChanged { +		// Update status language pref. +		// +		// Note we don't update these +		// status fields right away so +		// we can save current version. +		cols = append(cols, "language") +	} + +	if *status.Sensitive != form.Sensitive { +		// Update status sensitivity pref. +		// +		// Note we don't update these +		// status fields right away so +		// we can save current version. +		cols = append(cols, "sensitive") +	} + +	if mediaChanged { +		// Updated status media attachments. +		// +		// Note we don't update these +		// status fields right away so +		// we can save current version. +		cols = append(cols, "attachments") +	} + +	if pollChanged { +		// Updated attached status poll. +		// +		// Note we don't update these +		// status fields right away so +		// we can save current version. +		cols = append(cols, "poll_id") + +		if status.Poll == nil || poll == nil { +			// Went from with-poll to without-poll +			// or vice-versa. This changes AP type. +			cols = append(cols, "activity_streams_type") +		} +	} + +	if anyContentChanged { +		if !slices.Equal(status.MentionIDs, content.MentionIDs) { +			// Update attached status mentions. +			cols = append(cols, "mentions") +			status.MentionIDs = content.MentionIDs +			status.Mentions = content.Mentions +		} + +		if !slices.Equal(status.TagIDs, content.TagIDs) { +			// Updated attached status tags. +			cols = append(cols, "tags") +			status.TagIDs = content.TagIDs +			status.Tags = content.Tags +		} + +		if !slices.Equal(status.EmojiIDs, content.EmojiIDs) { +			// We specifically store both *new* AND *old* edit +			// revision emojis in the statuses.emojis column. +			emojiByID := func(e *gtsmodel.Emoji) string { return e.ID } +			status.Emojis = append(status.Emojis, content.Emojis...) +			status.Emojis = xslices.DeduplicateFunc(status.Emojis, emojiByID) +			status.EmojiIDs = xslices.Gather(status.EmojiIDs[:0], status.Emojis, emojiByID) + +			// Update attached status emojis. +			cols = append(cols, "emojis") +		} +	} + +	// If no status columns were updated, no media and +	// no poll were edited, there's nothing to do! +	if len(cols) == 2 && !mediaEdited && !pollEdited { +		const text = "status was not changed" +		return nil, gtserror.NewErrorUnprocessableEntity( +			errors.New(text), +			text, +		) +	} + +	// Create an edit to store a +	// historical snapshot of status. +	var edit gtsmodel.StatusEdit +	edit.ID = id.NewULIDFromTime(now) +	edit.Content = status.Content +	edit.ContentWarning = status.ContentWarning +	edit.Text = status.Text +	edit.Language = status.Language +	edit.Sensitive = status.Sensitive +	edit.StatusID = status.ID +	edit.CreatedAt = status.UpdatedAt + +	// Copy existing media and descriptions. +	edit.AttachmentIDs = status.AttachmentIDs +	if l := len(status.Attachments); l > 0 { +		edit.AttachmentDescriptions = make([]string, l) +		for i, attach := range status.Attachments { +			edit.AttachmentDescriptions[i] = attach.Description +		} +	} + +	if status.Poll != nil { +		// Poll only set if existed previously. +		edit.PollOptions = status.Poll.Options + +		if pollChanged || !*status.Poll.HideCounts || +			!status.Poll.ClosedAt.IsZero() { +			// If the counts are allowed to be +			// shown, or poll has changed, then +			// include poll vote counts in edit. +			edit.PollVotes = status.Poll.Votes +		} +	} + +	// Insert this new edit of existing status into database. +	if err := p.state.DB.PutStatusEdit(ctx, &edit); err != nil { +		err := gtserror.Newf("error putting edit in database: %w", err) +		return nil, gtserror.NewErrorInternalError(err) +	} + +	// Add edit to list of edits on the status. +	status.EditIDs = append(status.EditIDs, edit.ID) +	status.Edits = append(status.Edits, &edit) + +	// Now historical status data is stored, +	// update the other necessary status fields. +	status.Content = content.Content +	status.ContentWarning = content.ContentWarning +	status.Text = form.Status +	status.Language = content.Language +	status.Sensitive = &form.Sensitive +	status.AttachmentIDs = form.MediaIDs +	status.Attachments = media +	status.UpdatedAt = now + +	if poll != nil { +		// Set relevent fields for latest with poll. +		status.ActivityStreamsType = ap.ActivityQuestion +		status.PollID = poll.ID +		status.Poll = poll +	} else { +		// Set relevant fields for latest without poll. +		status.ActivityStreamsType = ap.ObjectNote +		status.PollID = "" +		status.Poll = nil +	} + +	// Finally update the existing status model in the database. +	if err := p.state.DB.UpdateStatus(ctx, status, cols...); err != nil { +		err := gtserror.Newf("error updating status in db: %w", err) +		return nil, gtserror.NewErrorInternalError(err) +	} + +	if pollChanged && status.Poll != nil && !status.Poll.ExpiresAt.IsZero() { +		// Now the status is updated, attempt to schedule +		// an expiry handler for the changed status poll. +		if err := p.polls.ScheduleExpiry(ctx, status.Poll); err != nil { +			log.Errorf(ctx, "error scheduling poll expiry: %v", err) +		} +	} + +	// Send it to the client API worker for async side-effects. +	p.state.Workers.Client.Queue.Push(&messages.FromClientAPI{ +		APObjectType:   ap.ObjectNote, +		APActivityType: ap.ActivityUpdate, +		GTSModel:       status, +		Origin:         requester, +	}) + +	// Return an API model of the updated status. +	return p.c.GetAPIStatus(ctx, requester, status) +} + +// HistoryGet gets edit history for the target status, taking account of privacy settings and blocks etc. +func (p *Processor) HistoryGet(ctx context.Context, requester *gtsmodel.Account, targetStatusID string) ([]*apimodel.StatusEdit, gtserror.WithCode) { +	target, errWithCode := p.c.GetVisibleTargetStatus(ctx, +		requester, +		targetStatusID, +		nil, // default freshness +	) +	if errWithCode != nil { +		return nil, errWithCode +	} + +	if err := p.state.DB.PopulateStatusEdits(ctx, target); err != nil { +		err := gtserror.Newf("error getting status edits from db: %w", err) +		return nil, gtserror.NewErrorInternalError(err) +	} + +	edits, err := p.converter.StatusToAPIEdits(ctx, target) +	if err != nil { +		err := gtserror.Newf("error converting status edits: %w", err) +		return nil, gtserror.NewErrorInternalError(err) +	} + +	return edits, nil +} + +func (p *Processor) processMediaEdits( +	ctx context.Context, +	attachs []*gtsmodel.MediaAttachment, +	attrs []apimodel.AttachmentAttributesRequest, +) ( +	bool, +	gtserror.WithCode, +) { +	var edited bool + +	for _, attr := range attrs { +		// Search the media attachments slice for index of media with attr.ID. +		i := slices.IndexFunc(attachs, func(m *gtsmodel.MediaAttachment) bool { +			return m.ID == attr.ID +		}) +		if i == -1 { +			text := fmt.Sprintf("media not found: %s", attr.ID) +			return false, gtserror.NewErrorBadRequest(errors.New(text), text) +		} + +		// Get attach at index. +		attach := attachs[i] + +		// Track which columns need +		// updating in database query. +		cols := make([]string, 0, 2) + +		// Check for description change. +		if attr.Description != attach.Description { +			attach.Description = attr.Description +			cols = append(cols, "description") +		} + +		if attr.Focus != "" { +			// Parse provided media focus parameters from string. +			fx, fy, errWithCode := apiutil.ParseFocus(attr.Focus) +			if errWithCode != nil { +				return false, errWithCode +			} + +			// Check for change in focus coords. +			if attach.FileMeta.Focus.X != fx || +				attach.FileMeta.Focus.Y != fy { +				attach.FileMeta.Focus.X = fx +				attach.FileMeta.Focus.Y = fy +				cols = append(cols, "focus_x", "focus_y") +			} +		} + +		if len(cols) > 0 { +			// Media attachment was changed, update this in database. +			err := p.state.DB.UpdateAttachment(ctx, attach, cols...) +			if err != nil { +				err := gtserror.Newf("error updating attachment in db: %w", err) +				return false, gtserror.NewErrorInternalError(err) +			} + +			// Set edited. +			edited = true +		} +	} + +	return edited, nil +} + +func (p *Processor) processPollEdit( +	ctx context.Context, +	statusID string, +	original *gtsmodel.Poll, +	form *apimodel.PollRequest, +	now time.Time, // used for expiry time +) ( +	*gtsmodel.Poll, +	bool, +	gtserror.WithCode, +) { +	if form == nil { +		if original != nil { +			// No poll was given but there's an existing poll, +			// this indicates the original needs to be deleted. +			if err := p.deletePoll(ctx, original); err != nil { +				return nil, true, gtserror.NewErrorInternalError(err) +			} + +			// Existing was deleted. +			return nil, true, nil +		} + +		// No change in poll. +		return nil, false, nil +	} + +	switch { +	// No existing poll. +	case original == nil: + +	// Any change that effects voting, i.e. options, allow multiple +	// or re-opening a closed poll requires deleting the existing poll. +	case !slices.Equal(form.Options, original.Options) || +		(form.Multiple != *original.Multiple) || +		(!original.ClosedAt.IsZero() && form.ExpiresIn != 0): +		if err := p.deletePoll(ctx, original); err != nil { +			return nil, true, gtserror.NewErrorInternalError(err) +		} + +	// Any other changes only require a model +	// update, and at-most a new expiry handler. +	default: +		var cols []string + +		// Check if the hide counts field changed. +		if form.HideTotals != *original.HideCounts { +			cols = append(cols, "hide_counts") +			original.HideCounts = &form.HideTotals +		} + +		var expiresAt time.Time + +		// Determine expiry time if given. +		if in := form.ExpiresIn; in > 0 { +			expiresIn := time.Duration(in) +			expiresAt = now.Add(expiresIn * time.Second) +		} + +		// Check for expiry time. +		if !expiresAt.IsZero() { + +			if !original.ExpiresAt.IsZero() { +				// Existing had expiry, cancel scheduled handler. +				_ = p.state.Workers.Scheduler.Cancel(original.ID) +			} + +			// Since expiry is given as a duration +			// we always treat > 0 as a change as +			// we can't know otherwise unfortunately. +			cols = append(cols, "expires_at") +			original.ExpiresAt = expiresAt +		} + +		if len(cols) == 0 { +			// Were no changes to poll. +			return original, false, nil +		} + +		// Update the original poll model in the database with these columns. +		if err := p.state.DB.UpdatePoll(ctx, original, cols...); err != nil { +			err := gtserror.Newf("error updating poll.expires_at in db: %w", err) +			return nil, true, gtserror.NewErrorInternalError(err) +		} + +		if !expiresAt.IsZero() { +			// Updated poll has an expiry, schedule a new expiry handler. +			if err := p.polls.ScheduleExpiry(ctx, original); err != nil { +				log.Errorf(ctx, "error scheduling poll expiry: %v", err) +			} +		} + +		// Existing poll was updated. +		return original, true, nil +	} + +	// If we reached here then an entirely +	// new status poll needs to be created. +	poll, errWithCode := p.processPoll(ctx, +		statusID, +		form, +		now, +	) +	return poll, true, errWithCode +} + +func (p *Processor) deletePoll(ctx context.Context, poll *gtsmodel.Poll) error { +	if !poll.ExpiresAt.IsZero() && !poll.ClosedAt.IsZero() { +		// Poll has an expiry and has not yet closed, +		// cancel any expiry handler before deletion. +		_ = p.state.Workers.Scheduler.Cancel(poll.ID) +	} + +	// Delete the given poll from the database. +	err := p.state.DB.DeletePollByID(ctx, poll.ID) +	if err != nil && !errors.Is(err, db.ErrNoEntries) { +		return gtserror.Newf("error deleting poll from db: %w", err) +	} + +	return nil +} diff --git a/internal/processing/status/edit_test.go b/internal/processing/status/edit_test.go new file mode 100644 index 000000000..393c3efc2 --- /dev/null +++ b/internal/processing/status/edit_test.go @@ -0,0 +1,544 @@ +// 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 status_test + +import ( +	"context" +	"net/http" +	"testing" +	"time" + +	"github.com/stretchr/testify/suite" +	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/util" +	"github.com/superseriousbusiness/gotosocial/internal/util/xslices" +) + +type StatusEditTestSuite struct { +	StatusStandardTestSuite +} + +func (suite *StatusEditTestSuite) TestSimpleEdit() { +	// Create cancellable context to use for test. +	ctx, cncl := context.WithCancel(context.Background()) +	defer cncl() + +	// Get a local account to use as test requester. +	requester := suite.testAccounts["local_account_1"] +	requester, _ = suite.state.DB.GetAccountByID(ctx, requester.ID) + +	// Get requester's existing status to perform an edit on. +	status := suite.testStatuses["local_account_1_status_9"] +	status, _ = suite.state.DB.GetStatusByID(ctx, status.ID) + +	// Prepare a simple status edit. +	form := &apimodel.StatusEditRequest{ +		Status:          "<p>this is some edited status text!</p>", +		SpoilerText:     "shhhhh", +		Sensitive:       true, +		Language:        "fr", // hoh hoh hoh +		MediaIDs:        nil, +		MediaAttributes: nil, +		Poll:            nil, +	} + +	// Pass the prepared form to the status processor to perform the edit. +	apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form) +	suite.NotNil(apiStatus) +	suite.NoError(errWithCode) + +	// Check response against input form data. +	suite.Equal(form.Status, apiStatus.Text) +	suite.Equal(form.SpoilerText, apiStatus.SpoilerText) +	suite.Equal(form.Sensitive, apiStatus.Sensitive) +	suite.Equal(form.Language, *apiStatus.Language) +	suite.NotEqual(util.FormatISO8601(status.UpdatedAt), *apiStatus.EditedAt) + +	// Fetched the latest version of edited status from the database. +	latestStatus, err := suite.state.DB.GetStatusByID(ctx, status.ID) +	suite.NoError(err) + +	// Check latest status against input form data. +	suite.Equal(form.Status, latestStatus.Text) +	suite.Equal(form.SpoilerText, latestStatus.ContentWarning) +	suite.Equal(form.Sensitive, *latestStatus.Sensitive) +	suite.Equal(form.Language, latestStatus.Language) +	suite.Equal(len(status.EditIDs)+1, len(latestStatus.EditIDs)) +	suite.NotEqual(status.UpdatedAt, latestStatus.UpdatedAt) + +	// Populate all historical edits for this status. +	err = suite.state.DB.PopulateStatusEdits(ctx, latestStatus) +	suite.NoError(err) + +	// Check previous status edit matches original status content. +	previousEdit := latestStatus.Edits[len(latestStatus.Edits)-1] +	suite.Equal(status.Content, previousEdit.Content) +	suite.Equal(status.Text, previousEdit.Text) +	suite.Equal(status.ContentWarning, previousEdit.ContentWarning) +	suite.Equal(*status.Sensitive, *previousEdit.Sensitive) +	suite.Equal(status.Language, previousEdit.Language) +	suite.Equal(status.UpdatedAt, previousEdit.CreatedAt) +} + +func (suite *StatusEditTestSuite) TestEditAddPoll() { +	// Create cancellable context to use for test. +	ctx, cncl := context.WithCancel(context.Background()) +	defer cncl() + +	// Get a local account to use as test requester. +	requester := suite.testAccounts["local_account_1"] +	requester, _ = suite.state.DB.GetAccountByID(ctx, requester.ID) + +	// Get requester's existing status to perform an edit on. +	status := suite.testStatuses["local_account_1_status_9"] +	status, _ = suite.state.DB.GetStatusByID(ctx, status.ID) + +	// Prepare edit adding a status poll. +	form := &apimodel.StatusEditRequest{ +		Status:          "<p>this is some edited status text!</p>", +		SpoilerText:     "", +		Sensitive:       true, +		Language:        "fr", // hoh hoh hoh +		MediaIDs:        nil, +		MediaAttributes: nil, +		Poll: &apimodel.PollRequest{ +			Options:    []string{"yes", "no", "spiderman"}, +			ExpiresIn:  int(time.Minute), +			Multiple:   true, +			HideTotals: false, +		}, +	} + +	// Pass the prepared form to the status processor to perform the edit. +	apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form) +	suite.NotNil(apiStatus) +	suite.NoError(errWithCode) + +	// Check response against input form data. +	suite.Equal(form.Status, apiStatus.Text) +	suite.Equal(form.SpoilerText, apiStatus.SpoilerText) +	suite.Equal(form.Sensitive, apiStatus.Sensitive) +	suite.Equal(form.Language, *apiStatus.Language) +	suite.NotEqual(util.FormatISO8601(status.UpdatedAt), *apiStatus.EditedAt) +	suite.NotNil(apiStatus.Poll) +	suite.Equal(form.Poll.Options, xslices.Gather(nil, apiStatus.Poll.Options, func(opt apimodel.PollOption) string { +		return opt.Title +	})) + +	// Fetched the latest version of edited status from the database. +	latestStatus, err := suite.state.DB.GetStatusByID(ctx, status.ID) +	suite.NoError(err) + +	// Check latest status against input form data. +	suite.Equal(form.Status, latestStatus.Text) +	suite.Equal(form.SpoilerText, latestStatus.ContentWarning) +	suite.Equal(form.Sensitive, *latestStatus.Sensitive) +	suite.Equal(form.Language, latestStatus.Language) +	suite.Equal(len(status.EditIDs)+1, len(latestStatus.EditIDs)) +	suite.NotEqual(status.UpdatedAt, latestStatus.UpdatedAt) +	suite.NotNil(latestStatus.Poll) +	suite.Equal(form.Poll.Options, latestStatus.Poll.Options) + +	// Ensure that a poll expiry handler was scheduled on status edit. +	expiryWorker := suite.state.Workers.Scheduler.Cancel(latestStatus.PollID) +	suite.Equal(form.Poll.ExpiresIn > 0, expiryWorker) + +	// Populate all historical edits for this status. +	err = suite.state.DB.PopulateStatusEdits(ctx, latestStatus) +	suite.NoError(err) + +	// Check previous status edit matches original status content. +	previousEdit := latestStatus.Edits[len(latestStatus.Edits)-1] +	suite.Equal(status.Content, previousEdit.Content) +	suite.Equal(status.Text, previousEdit.Text) +	suite.Equal(status.ContentWarning, previousEdit.ContentWarning) +	suite.Equal(*status.Sensitive, *previousEdit.Sensitive) +	suite.Equal(status.Language, previousEdit.Language) +	suite.Equal(status.UpdatedAt, previousEdit.CreatedAt) +	suite.Equal(status.Poll != nil, len(previousEdit.PollOptions) > 0) +} + +func (suite *StatusEditTestSuite) TestEditAddPollNoExpiry() { +	// Create cancellable context to use for test. +	ctx, cncl := context.WithCancel(context.Background()) +	defer cncl() + +	// Get a local account to use as test requester. +	requester := suite.testAccounts["local_account_1"] +	requester, _ = suite.state.DB.GetAccountByID(ctx, requester.ID) + +	// Get requester's existing status to perform an edit on. +	status := suite.testStatuses["local_account_1_status_9"] +	status, _ = suite.state.DB.GetStatusByID(ctx, status.ID) + +	// Prepare edit adding an endless poll. +	form := &apimodel.StatusEditRequest{ +		Status:          "<p>this is some edited status text!</p>", +		SpoilerText:     "", +		Sensitive:       true, +		Language:        "fr", // hoh hoh hoh +		MediaIDs:        nil, +		MediaAttributes: nil, +		Poll: &apimodel.PollRequest{ +			Options:    []string{"yes", "no", "spiderman"}, +			ExpiresIn:  0, +			Multiple:   true, +			HideTotals: false, +		}, +	} + +	// Pass the prepared form to the status processor to perform the edit. +	apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form) +	suite.NotNil(apiStatus) +	suite.NoError(errWithCode) + +	// Check response against input form data. +	suite.Equal(form.Status, apiStatus.Text) +	suite.Equal(form.SpoilerText, apiStatus.SpoilerText) +	suite.Equal(form.Sensitive, apiStatus.Sensitive) +	suite.Equal(form.Language, *apiStatus.Language) +	suite.NotEqual(util.FormatISO8601(status.UpdatedAt), *apiStatus.EditedAt) +	suite.NotNil(apiStatus.Poll) +	suite.Equal(form.Poll.Options, xslices.Gather(nil, apiStatus.Poll.Options, func(opt apimodel.PollOption) string { +		return opt.Title +	})) + +	// Fetched the latest version of edited status from the database. +	latestStatus, err := suite.state.DB.GetStatusByID(ctx, status.ID) +	suite.NoError(err) + +	// Check latest status against input form data. +	suite.Equal(form.Status, latestStatus.Text) +	suite.Equal(form.SpoilerText, latestStatus.ContentWarning) +	suite.Equal(form.Sensitive, *latestStatus.Sensitive) +	suite.Equal(form.Language, latestStatus.Language) +	suite.Equal(len(status.EditIDs)+1, len(latestStatus.EditIDs)) +	suite.NotEqual(status.UpdatedAt, latestStatus.UpdatedAt) +	suite.NotNil(latestStatus.Poll) +	suite.Equal(form.Poll.Options, latestStatus.Poll.Options) + +	// Ensure that a poll expiry handler was *not* scheduled on status edit. +	expiryWorker := suite.state.Workers.Scheduler.Cancel(latestStatus.PollID) +	suite.Equal(form.Poll.ExpiresIn > 0, expiryWorker) + +	// Populate all historical edits for this status. +	err = suite.state.DB.PopulateStatusEdits(ctx, latestStatus) +	suite.NoError(err) + +	// Check previous status edit matches original status content. +	previousEdit := latestStatus.Edits[len(latestStatus.Edits)-1] +	suite.Equal(status.Content, previousEdit.Content) +	suite.Equal(status.Text, previousEdit.Text) +	suite.Equal(status.ContentWarning, previousEdit.ContentWarning) +	suite.Equal(*status.Sensitive, *previousEdit.Sensitive) +	suite.Equal(status.Language, previousEdit.Language) +	suite.Equal(status.UpdatedAt, previousEdit.CreatedAt) +	suite.Equal(status.Poll != nil, len(previousEdit.PollOptions) > 0) +} + +func (suite *StatusEditTestSuite) TestEditMediaDescription() { +	// Create cancellable context to use for test. +	ctx, cncl := context.WithCancel(context.Background()) +	defer cncl() + +	// Get a local account to use as test requester. +	requester := suite.testAccounts["local_account_1"] +	requester, _ = suite.state.DB.GetAccountByID(ctx, requester.ID) + +	// Get requester's existing status to perform an edit on. +	status := suite.testStatuses["local_account_1_status_4"] +	status, _ = suite.state.DB.GetStatusByID(ctx, status.ID) + +	// Prepare edit changing media description. +	form := &apimodel.StatusEditRequest{ +		Status:      "<p>this is some edited status text!</p>", +		SpoilerText: "this status is now missing media", +		Sensitive:   true, +		Language:    "en", +		MediaIDs:    status.AttachmentIDs, +		MediaAttributes: []apimodel.AttachmentAttributesRequest{ +			{ID: status.AttachmentIDs[0], Description: "hello world!"}, +			{ID: status.AttachmentIDs[1], Description: "media attachment numero two"}, +		}, +	} + +	// Pass the prepared form to the status processor to perform the edit. +	apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form) +	suite.NoError(errWithCode) + +	// Check response against input form data. +	suite.Equal(form.Status, apiStatus.Text) +	suite.Equal(form.SpoilerText, apiStatus.SpoilerText) +	suite.Equal(form.Sensitive, apiStatus.Sensitive) +	suite.Equal(form.Language, *apiStatus.Language) +	suite.NotEqual(util.FormatISO8601(status.UpdatedAt), *apiStatus.EditedAt) +	suite.Equal(form.MediaIDs, xslices.Gather(nil, apiStatus.MediaAttachments, func(media *apimodel.Attachment) string { +		return media.ID +	})) +	suite.Equal( +		xslices.Gather(nil, form.MediaAttributes, func(attr apimodel.AttachmentAttributesRequest) string { +			return attr.Description +		}), +		xslices.Gather(nil, apiStatus.MediaAttachments, func(media *apimodel.Attachment) string { +			return *media.Description +		}), +	) + +	// Fetched the latest version of edited status from the database. +	latestStatus, err := suite.state.DB.GetStatusByID(ctx, status.ID) +	suite.NoError(err) + +	// Check latest status against input form data. +	suite.Equal(form.Status, latestStatus.Text) +	suite.Equal(form.SpoilerText, latestStatus.ContentWarning) +	suite.Equal(form.Sensitive, *latestStatus.Sensitive) +	suite.Equal(form.Language, latestStatus.Language) +	suite.Equal(len(status.EditIDs)+1, len(latestStatus.EditIDs)) +	suite.NotEqual(status.UpdatedAt, latestStatus.UpdatedAt) +	suite.Equal(form.MediaIDs, latestStatus.AttachmentIDs) +	suite.Equal( +		xslices.Gather(nil, form.MediaAttributes, func(attr apimodel.AttachmentAttributesRequest) string { +			return attr.Description +		}), +		xslices.Gather(nil, latestStatus.Attachments, func(media *gtsmodel.MediaAttachment) string { +			return media.Description +		}), +	) + +	// Populate all historical edits for this status. +	err = suite.state.DB.PopulateStatusEdits(ctx, latestStatus) +	suite.NoError(err) + +	// Further populate edits to get attachments. +	for _, edit := range latestStatus.Edits { +		err = suite.state.DB.PopulateStatusEdit(ctx, edit) +		suite.NoError(err) +	} + +	// Check previous status edit matches original status content. +	previousEdit := latestStatus.Edits[len(latestStatus.Edits)-1] +	suite.Equal(status.Content, previousEdit.Content) +	suite.Equal(status.Text, previousEdit.Text) +	suite.Equal(status.ContentWarning, previousEdit.ContentWarning) +	suite.Equal(*status.Sensitive, *previousEdit.Sensitive) +	suite.Equal(status.Language, previousEdit.Language) +	suite.Equal(status.UpdatedAt, previousEdit.CreatedAt) +	suite.Equal(status.AttachmentIDs, previousEdit.AttachmentIDs) +	suite.Equal( +		xslices.Gather(nil, status.Attachments, func(media *gtsmodel.MediaAttachment) string { +			return media.Description +		}), +		previousEdit.AttachmentDescriptions, +	) +} + +func (suite *StatusEditTestSuite) TestEditAddMedia() { +	// Create cancellable context to use for test. +	ctx, cncl := context.WithCancel(context.Background()) +	defer cncl() + +	// Get a local account to use as test requester. +	requester := suite.testAccounts["local_account_1"] +	requester, _ = suite.state.DB.GetAccountByID(ctx, requester.ID) + +	// Get some of requester's existing media, and unattach from existing status. +	media1 := suite.testAttachments["local_account_1_status_4_attachment_1"] +	media2 := suite.testAttachments["local_account_1_status_4_attachment_2"] +	media1.StatusID, media2.StatusID = "", "" +	suite.NoError(suite.state.DB.UpdateAttachment(ctx, media1, "status_id")) +	suite.NoError(suite.state.DB.UpdateAttachment(ctx, media2, "status_id")) +	media1, _ = suite.state.DB.GetAttachmentByID(ctx, media1.ID) +	media2, _ = suite.state.DB.GetAttachmentByID(ctx, media2.ID) + +	// Get requester's existing status to perform an edit on. +	status := suite.testStatuses["local_account_1_status_9"] +	status, _ = suite.state.DB.GetStatusByID(ctx, status.ID) + +	// Prepare edit addding status media. +	form := &apimodel.StatusEditRequest{ +		Status:          "<p>this is some edited status text!</p>", +		SpoilerText:     "this status now has media", +		Sensitive:       true, +		Language:        "en", +		MediaIDs:        []string{media1.ID, media2.ID}, +		MediaAttributes: nil, +	} + +	// Pass the prepared form to the status processor to perform the edit. +	apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form) +	suite.NotNil(apiStatus) +	suite.NoError(errWithCode) + +	// Check response against input form data. +	suite.Equal(form.Status, apiStatus.Text) +	suite.Equal(form.SpoilerText, apiStatus.SpoilerText) +	suite.Equal(form.Sensitive, apiStatus.Sensitive) +	suite.Equal(form.Language, *apiStatus.Language) +	suite.NotEqual(util.FormatISO8601(status.UpdatedAt), *apiStatus.EditedAt) +	suite.Equal(form.MediaIDs, xslices.Gather(nil, apiStatus.MediaAttachments, func(media *apimodel.Attachment) string { +		return media.ID +	})) + +	// Fetched the latest version of edited status from the database. +	latestStatus, err := suite.state.DB.GetStatusByID(ctx, status.ID) +	suite.NoError(err) + +	// Check latest status against input form data. +	suite.Equal(form.Status, latestStatus.Text) +	suite.Equal(form.SpoilerText, latestStatus.ContentWarning) +	suite.Equal(form.Sensitive, *latestStatus.Sensitive) +	suite.Equal(form.Language, latestStatus.Language) +	suite.Equal(len(status.EditIDs)+1, len(latestStatus.EditIDs)) +	suite.NotEqual(status.UpdatedAt, latestStatus.UpdatedAt) +	suite.Equal(form.MediaIDs, latestStatus.AttachmentIDs) + +	// Populate all historical edits for this status. +	err = suite.state.DB.PopulateStatusEdits(ctx, latestStatus) +	suite.NoError(err) + +	// Check previous status edit matches original status content. +	previousEdit := latestStatus.Edits[len(latestStatus.Edits)-1] +	suite.Equal(status.Content, previousEdit.Content) +	suite.Equal(status.Text, previousEdit.Text) +	suite.Equal(status.ContentWarning, previousEdit.ContentWarning) +	suite.Equal(*status.Sensitive, *previousEdit.Sensitive) +	suite.Equal(status.Language, previousEdit.Language) +	suite.Equal(status.UpdatedAt, previousEdit.CreatedAt) +	suite.Equal(status.AttachmentIDs, previousEdit.AttachmentIDs) +} + +func (suite *StatusEditTestSuite) TestEditRemoveMedia() { +	// Create cancellable context to use for test. +	ctx, cncl := context.WithCancel(context.Background()) +	defer cncl() + +	// Get a local account to use as test requester. +	requester := suite.testAccounts["local_account_1"] +	requester, _ = suite.state.DB.GetAccountByID(ctx, requester.ID) + +	// Get requester's existing status to perform an edit on. +	status := suite.testStatuses["local_account_1_status_4"] +	status, _ = suite.state.DB.GetStatusByID(ctx, status.ID) + +	// Prepare edit removing status media. +	form := &apimodel.StatusEditRequest{ +		Status:          "<p>this is some edited status text!</p>", +		SpoilerText:     "this status is now missing media", +		Sensitive:       true, +		Language:        "en", +		MediaIDs:        nil, +		MediaAttributes: nil, +	} + +	// Pass the prepared form to the status processor to perform the edit. +	apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form) +	suite.NotNil(apiStatus) +	suite.NoError(errWithCode) + +	// Check response against input form data. +	suite.Equal(form.Status, apiStatus.Text) +	suite.Equal(form.SpoilerText, apiStatus.SpoilerText) +	suite.Equal(form.Sensitive, apiStatus.Sensitive) +	suite.Equal(form.Language, *apiStatus.Language) +	suite.NotEqual(util.FormatISO8601(status.UpdatedAt), *apiStatus.EditedAt) +	suite.Equal(form.MediaIDs, xslices.Gather(nil, apiStatus.MediaAttachments, func(media *apimodel.Attachment) string { +		return media.ID +	})) + +	// Fetched the latest version of edited status from the database. +	latestStatus, err := suite.state.DB.GetStatusByID(ctx, status.ID) +	suite.NoError(err) + +	// Check latest status against input form data. +	suite.Equal(form.Status, latestStatus.Text) +	suite.Equal(form.SpoilerText, latestStatus.ContentWarning) +	suite.Equal(form.Sensitive, *latestStatus.Sensitive) +	suite.Equal(form.Language, latestStatus.Language) +	suite.Equal(len(status.EditIDs)+1, len(latestStatus.EditIDs)) +	suite.NotEqual(status.UpdatedAt, latestStatus.UpdatedAt) +	suite.Equal(form.MediaIDs, latestStatus.AttachmentIDs) + +	// Populate all historical edits for this status. +	err = suite.state.DB.PopulateStatusEdits(ctx, latestStatus) +	suite.NoError(err) + +	// Check previous status edit matches original status content. +	previousEdit := latestStatus.Edits[len(latestStatus.Edits)-1] +	suite.Equal(status.Content, previousEdit.Content) +	suite.Equal(status.Text, previousEdit.Text) +	suite.Equal(status.ContentWarning, previousEdit.ContentWarning) +	suite.Equal(*status.Sensitive, *previousEdit.Sensitive) +	suite.Equal(status.Language, previousEdit.Language) +	suite.Equal(status.UpdatedAt, previousEdit.CreatedAt) +	suite.Equal(status.AttachmentIDs, previousEdit.AttachmentIDs) +} + +func (suite *StatusEditTestSuite) TestEditOthersStatus1() { +	// Create cancellable context to use for test. +	ctx, cncl := context.WithCancel(context.Background()) +	defer cncl() + +	// Get a local account to use as test requester. +	requester := suite.testAccounts["local_account_1"] +	requester, _ = suite.state.DB.GetAccountByID(ctx, requester.ID) + +	// Get remote accounts's status to attempt an edit on. +	status := suite.testStatuses["remote_account_1_status_1"] +	status, _ = suite.state.DB.GetStatusByID(ctx, status.ID) + +	// Prepare an empty request form, this +	// should be all we need to trigger it. +	form := &apimodel.StatusEditRequest{} + +	// Attempt to edit other remote account's status, this should return an error. +	apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form) +	suite.Nil(apiStatus) +	suite.Equal(http.StatusNotFound, errWithCode.Code()) +	suite.Equal("status does not belong to requester", errWithCode.Error()) +	suite.Equal("Not Found: target status not found", errWithCode.Safe()) +} + +func (suite *StatusEditTestSuite) TestEditOthersStatus2() { +	// Create cancellable context to use for test. +	ctx, cncl := context.WithCancel(context.Background()) +	defer cncl() + +	// Get a local account to use as test requester. +	requester := suite.testAccounts["local_account_1"] +	requester, _ = suite.state.DB.GetAccountByID(ctx, requester.ID) + +	// Get other local accounts's status to attempt edit on. +	status := suite.testStatuses["local_account_2_status_1"] +	status, _ = suite.state.DB.GetStatusByID(ctx, status.ID) + +	// Prepare an empty request form, this +	// should be all we need to trigger it. +	form := &apimodel.StatusEditRequest{} + +	// Attempt to edit other local account's status, this should return an error. +	apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form) +	suite.Nil(apiStatus) +	suite.Equal(http.StatusNotFound, errWithCode.Code()) +	suite.Equal("status does not belong to requester", errWithCode.Error()) +	suite.Equal("Not Found: target status not found", errWithCode.Safe()) +} + +func TestStatusEditTestSuite(t *testing.T) { +	suite.Run(t, new(StatusEditTestSuite)) +} diff --git a/internal/processing/status/get.go b/internal/processing/status/get.go index 470b93a8f..812f01683 100644 --- a/internal/processing/status/get.go +++ b/internal/processing/status/get.go @@ -19,47 +19,16 @@ package status  import (  	"context" +	"errors"  	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"  	"github.com/superseriousbusiness/gotosocial/internal/gtserror"  	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" -	"github.com/superseriousbusiness/gotosocial/internal/util"  ) -// HistoryGet gets edit history for the target status, taking account of privacy settings and blocks etc. -// TODO: currently this just returns the latest version of the status. -func (p *Processor) HistoryGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) ([]*apimodel.StatusEdit, gtserror.WithCode) { -	targetStatus, errWithCode := p.c.GetVisibleTargetStatus(ctx, -		requestingAccount, -		targetStatusID, -		nil, // default freshness -	) -	if errWithCode != nil { -		return nil, errWithCode -	} - -	apiStatus, errWithCode := p.c.GetAPIStatus(ctx, requestingAccount, targetStatus) -	if errWithCode != nil { -		return nil, errWithCode -	} - -	return []*apimodel.StatusEdit{ -		{ -			Content:          apiStatus.Content, -			SpoilerText:      apiStatus.SpoilerText, -			Sensitive:        apiStatus.Sensitive, -			CreatedAt:        util.FormatISO8601(targetStatus.UpdatedAt), -			Account:          apiStatus.Account, -			Poll:             apiStatus.Poll, -			MediaAttachments: apiStatus.MediaAttachments, -			Emojis:           apiStatus.Emojis, -		}, -	}, nil -} -  // Get gets the given status, taking account of privacy settings and blocks etc.  func (p *Processor) Get(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) { -	targetStatus, errWithCode := p.c.GetVisibleTargetStatus(ctx, +	target, errWithCode := p.c.GetVisibleTargetStatus(ctx,  		requestingAccount,  		targetStatusID,  		nil, // default freshness @@ -67,44 +36,25 @@ func (p *Processor) Get(ctx context.Context, requestingAccount *gtsmodel.Account  	if errWithCode != nil {  		return nil, errWithCode  	} -	return p.c.GetAPIStatus(ctx, requestingAccount, targetStatus) +	return p.c.GetAPIStatus(ctx, requestingAccount, target)  }  // SourceGet returns the *apimodel.StatusSource version of the targetStatusID.  // Status must belong to the requester, and must not be a boost. -func (p *Processor) SourceGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*apimodel.StatusSource, gtserror.WithCode) { -	targetStatus, errWithCode := p.c.GetVisibleTargetStatus(ctx, -		requestingAccount, -		targetStatusID, -		nil, // default freshness -	) +func (p *Processor) SourceGet(ctx context.Context, requester *gtsmodel.Account, statusID string) (*apimodel.StatusSource, gtserror.WithCode) { +	status, errWithCode := p.c.GetOwnStatus(ctx, requester, statusID)  	if errWithCode != nil {  		return nil, errWithCode  	} - -	// Redirect to wrapped status if boost. -	targetStatus, errWithCode = p.c.UnwrapIfBoost( -		ctx, -		requestingAccount, -		targetStatus, -	) -	if errWithCode != nil { -		return nil, errWithCode -	} - -	if targetStatus.AccountID != requestingAccount.ID { -		err := gtserror.Newf( -			"status %s does not belong to account %s", -			targetStatusID, requestingAccount.ID, +	if status.BoostOfID != "" { +		return nil, gtserror.NewErrorNotFound( +			errors.New("status is a boost wrapper"), +			"target status not found",  		) -		return nil, gtserror.NewErrorNotFound(err)  	} - -	statusSource, err := p.converter.StatusToAPIStatusSource(ctx, targetStatus) -	if err != nil { -		err = gtserror.Newf("error converting status: %w", err) -		return nil, gtserror.NewErrorInternalError(err) -	} - -	return statusSource, nil +	return &apimodel.StatusSource{ +		ID:          status.ID, +		Text:        status.Text, +		SpoilerText: status.ContentWarning, +	}, nil  } diff --git a/internal/processing/workers/fromfediapi.go b/internal/processing/workers/fromfediapi.go index 0d6ec1836..096e285f6 100644 --- a/internal/processing/workers/fromfediapi.go +++ b/internal/processing/workers/fromfediapi.go @@ -762,7 +762,7 @@ func (p *fediAPI) UpdateAccount(ctx context.Context, fMsg *messages.FromFediAPI)  		account,  		apubAcc, -		// Force refresh within 10s window. +		// Force refresh within 5s window.  		//  		// Missing account updates could be  		// detrimental to federation if they @@ -917,17 +917,25 @@ func (p *fediAPI) UpdateStatus(ctx context.Context, fMsg *messages.FromFediAPI)  		return gtserror.Newf("cannot cast %T -> *gtsmodel.Status", fMsg.GTSModel)  	} +	var freshness *dereferencing.FreshnessWindow +  	// Cast the updated ActivityPub statusable object .  	apStatus, _ := fMsg.APObject.(ap.Statusable) +	if apStatus != nil { +		// If an AP object was provided, we +		// allow very fast refreshes that likely +		// indicate a status edit after post. +		freshness = dereferencing.Freshest +	} +  	// Fetch up-to-date attach status attachments, etc.  	status, _, err := p.federate.RefreshStatus(  		ctx,  		fMsg.Receiving.Username,  		existing,  		apStatus, -		// Force refresh within 5min window. -		dereferencing.Fresh, +		freshness,  	)  	if err != nil {  		log.Errorf(ctx, "error refreshing status: %v", err) diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index e0276a53b..3208fcb51 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -1216,21 +1216,6 @@ func (c *Converter) StatusToWebStatus(  	return webStatus, nil  } -// StatusToAPIStatusSource returns the *apimodel.StatusSource of the given status. -// Callers should check beforehand whether a requester has permission to view the -// source of the status, and ensure they're passing only a local status into this function. -func (c *Converter) StatusToAPIStatusSource(ctx context.Context, s *gtsmodel.Status) (*apimodel.StatusSource, error) { -	// TODO: remove this when edit support is added. -	text := "**STATUS EDITS ARE NOT CURRENTLY SUPPORTED IN GOTOSOCIAL (coming in 2024)**\n" + -		"You can review the original text of your status below, but you will not be able to submit this edit.\n\n---\n\n" + s.Text - -	return &apimodel.StatusSource{ -		ID:          s.ID, -		Text:        text, -		SpoilerText: s.ContentWarning, -	}, nil -} -  // statusToFrontend is a package internal function for  // parsing a status into its initial frontend representation.  // @@ -1472,6 +1457,149 @@ func (c *Converter) baseStatusToFrontend(  	return apiStatus, nil  } +// StatusToAPIEdits converts a status and its historical edits (if any) to a slice of API model status edits. +func (c *Converter) StatusToAPIEdits(ctx context.Context, status *gtsmodel.Status) ([]*apimodel.StatusEdit, error) { +	var media map[string]*gtsmodel.MediaAttachment + +	// Gather attachments of status AND edits. +	attachmentIDs := status.AllAttachmentIDs() +	if len(attachmentIDs) > 0 { + +		// Fetch all of the gathered status attachments from the database. +		attachments, err := c.state.DB.GetAttachmentsByIDs(ctx, attachmentIDs) +		if err != nil { +			return nil, gtserror.Newf("error getting attachments from db: %w", err) +		} + +		// Generate a lookup map in 'media' of status attachments by their IDs. +		media = util.KeyBy(attachments, func(m *gtsmodel.MediaAttachment) string { +			return m.ID +		}) +	} + +	// Convert the status author account to API model. +	apiAccount, err := c.AccountToAPIAccountPublic(ctx, +		status.Account, +	) +	if err != nil { +		return nil, gtserror.Newf("error converting account: %w", err) +	} + +	// Convert status emojis to their API models, +	// this includes all status emojis both current +	// and historic, so it gets passed to each edit. +	apiEmojis, err := c.convertEmojisToAPIEmojis(ctx, +		nil, +		status.EmojiIDs, +	) +	if err != nil { +		return nil, gtserror.Newf("error converting emojis: %w", err) +	} + +	var votes []int +	var options []string + +	if status.Poll != nil { +		// Extract status poll options. +		options = status.Poll.Options + +		// Show votes only if closed / allowed. +		if !status.Poll.ClosedAt.IsZero() || +			!*status.Poll.HideCounts { +			votes = status.Poll.Votes +		} +	} + +	// Append status itself to final slot in the edits +	// so we can add its revision using the below loop. +	edits := append(status.Edits, >smodel.StatusEdit{ //nolint:gocritic +		Content:                status.Content, +		ContentWarning:         status.ContentWarning, +		Sensitive:              status.Sensitive, +		PollOptions:            options, +		PollVotes:              votes, +		AttachmentIDs:          status.AttachmentIDs, +		AttachmentDescriptions: nil, // no change from current +		CreatedAt:              status.UpdatedAt, +	}) + +	// Iterate through status edits, starting at newest. +	apiEdits := make([]*apimodel.StatusEdit, 0, len(edits)) +	for i := len(edits) - 1; i >= 0; i-- { +		edit := edits[i] + +		// Iterate through edit attachment IDs, getting model from 'media' lookup. +		apiAttachments := make([]*apimodel.Attachment, 0, len(edit.AttachmentIDs)) +		for _, id := range edit.AttachmentIDs { +			attachment, ok := media[id] +			if !ok { +				continue +			} + +			// Convert each media attachment to frontend API model. +			apiAttachment, err := c.AttachmentToAPIAttachment(ctx, +				attachment, +			) +			if err != nil { +				log.Error(ctx, "error converting attachment: %v", err) +				continue +			} + +			// Append converted media attachment to return slice. +			apiAttachments = append(apiAttachments, &apiAttachment) +		} + +		// If media descriptions are set, update API model descriptions. +		if len(edit.AttachmentIDs) == len(edit.AttachmentDescriptions) { +			var j int +			for i, id := range edit.AttachmentIDs { +				descr := edit.AttachmentDescriptions[i] +				for ; j < len(apiAttachments); j++ { +					if apiAttachments[j].ID == id { +						apiAttachments[j].Description = &descr +						break +					} +				} +			} +		} + +		// Attach status poll if set. +		var apiPoll *apimodel.Poll +		if len(edit.PollOptions) > 0 { +			apiPoll = new(apimodel.Poll) + +			// Iterate through poll options and attach to API poll model. +			apiPoll.Options = make([]apimodel.PollOption, len(edit.PollOptions)) +			for i, option := range edit.PollOptions { +				apiPoll.Options[i] = apimodel.PollOption{ +					Title: option, +				} +			} + +			// If poll votes are attached, set vote counts. +			if len(edit.PollVotes) == len(apiPoll.Options) { +				for i, votes := range edit.PollVotes { +					apiPoll.Options[i].VotesCount = &votes +				} +			} +		} + +		// Append this status edit to the return slice. +		apiEdits = append(apiEdits, &apimodel.StatusEdit{ +			CreatedAt:        util.FormatISO8601(edit.CreatedAt), +			Content:          edit.Content, +			SpoilerText:      edit.ContentWarning, +			Sensitive:        util.PtrOrZero(edit.Sensitive), +			Account:          apiAccount, +			Poll:             apiPoll, +			MediaAttachments: apiAttachments, +			Emojis:           apiEmojis, // same models used for whole status + all edits +		}) +	} + +	return apiEdits, nil +} +  // VisToAPIVis converts a gts visibility into its api equivalent  func (c *Converter) VisToAPIVis(ctx context.Context, m gtsmodel.Visibility) apimodel.Visibility {  	switch m { @@ -1488,7 +1616,7 @@ func (c *Converter) VisToAPIVis(ctx context.Context, m gtsmodel.Visibility) apim  }  // InstanceRuleToAdminAPIRule converts a local instance rule into its api equivalent for serving at /api/v1/admin/instance/rules/:id -func (c *Converter) InstanceRuleToAPIRule(r gtsmodel.Rule) apimodel.InstanceRule { +func InstanceRuleToAPIRule(r gtsmodel.Rule) apimodel.InstanceRule {  	return apimodel.InstanceRule{  		ID:   r.ID,  		Text: r.Text, @@ -1496,18 +1624,16 @@ func (c *Converter) InstanceRuleToAPIRule(r gtsmodel.Rule) apimodel.InstanceRule  }  // InstanceRulesToAPIRules converts all local instance rules into their api equivalent for serving at /api/v1/instance/rules -func (c *Converter) InstanceRulesToAPIRules(r []gtsmodel.Rule) []apimodel.InstanceRule { +func InstanceRulesToAPIRules(r []gtsmodel.Rule) []apimodel.InstanceRule {  	rules := make([]apimodel.InstanceRule, len(r)) -  	for i, v := range r { -		rules[i] = c.InstanceRuleToAPIRule(v) +		rules[i] = InstanceRuleToAPIRule(v)  	} -  	return rules  }  // InstanceRuleToAdminAPIRule converts a local instance rule into its api equivalent for serving at /api/v1/admin/instance/rules/:id -func (c *Converter) InstanceRuleToAdminAPIRule(r *gtsmodel.Rule) *apimodel.AdminInstanceRule { +func InstanceRuleToAdminAPIRule(r *gtsmodel.Rule) *apimodel.AdminInstanceRule {  	return &apimodel.AdminInstanceRule{  		ID:        r.ID,  		CreatedAt: util.FormatISO8601(r.CreatedAt), @@ -1540,7 +1666,7 @@ func (c *Converter) InstanceToAPIV1Instance(ctx context.Context, i *gtsmodel.Ins  		ApprovalRequired:     true,                               // approval always required  		InvitesEnabled:       false,                              // todo: not supported yet  		MaxTootChars:         uint(config.GetStatusesMaxChars()), // #nosec G115 -- Already validated. -		Rules:                c.InstanceRulesToAPIRules(i.Rules), +		Rules:                InstanceRulesToAPIRules(i.Rules),  		Terms:                i.Terms,  		TermsRaw:             i.TermsText,  	} @@ -1674,7 +1800,7 @@ func (c *Converter) InstanceToAPIV2Instance(ctx context.Context, i *gtsmodel.Ins  		CustomCSS:       i.CustomCSS,  		Usage:           apimodel.InstanceV2Usage{}, // todo: not implemented  		Languages:       config.GetInstanceLanguages().TagStrs(), -		Rules:           c.InstanceRulesToAPIRules(i.Rules), +		Rules:           InstanceRulesToAPIRules(i.Rules),  		Terms:           i.Terms,  		TermsText:       i.TermsText,  	} diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go index 0ec9ea05f..39a9bd9d4 100644 --- a/internal/typeutils/internaltofrontend_test.go +++ b/internal/typeutils/internaltofrontend_test.go @@ -3737,6 +3737,136 @@ func (suite *InternalToFrontendTestSuite) TestConversationToAPI() {  }`, string(b))  } +func (suite *InternalToFrontendTestSuite) TestStatusToAPIEdits() { +	ctx, cncl := context.WithCancel(context.Background()) +	defer cncl() + +	statusID := suite.testStatuses["local_account_1_status_9"].ID + +	status, err := suite.state.DB.GetStatusByID(ctx, statusID) +	suite.NoError(err) + +	err = suite.state.DB.PopulateStatusEdits(ctx, status) +	suite.NoError(err) + +	apiEdits, err := suite.typeconverter.StatusToAPIEdits(ctx, status) +	suite.NoError(err) + +	b, err := json.MarshalIndent(apiEdits, "", "    ") +	suite.NoError(err) + +	suite.Equal(`[ +    { +        "content": "\u003cp\u003ethis is the latest revision of the status, with a content-warning\u003c/p\u003e", +        "spoiler_text": "edited status", +        "sensitive": false, +        "created_at": "2024-11-01T09:02:00.000Z", +        "account": { +            "id": "01F8MH1H7YV1Z7D2C8K2730QBF", +            "username": "the_mighty_zork", +            "acct": "the_mighty_zork", +            "display_name": "original zork (he/they)", +            "locked": false, +            "discoverable": true, +            "bot": false, +            "created_at": "2022-05-20T11:09:18.000Z", +            "note": "\u003cp\u003ehey yo this is my profile!\u003c/p\u003e", +            "url": "http://localhost:8080/@the_mighty_zork", +            "avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg", +            "avatar_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.webp", +            "avatar_description": "a green goblin looking nasty", +            "avatar_media_id": "01F8MH58A357CV5K7R7TJMSH6S", +            "header": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", +            "header_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.webp", +            "header_description": "A very old-school screenshot of the original team fortress mod for quake", +            "header_media_id": "01PFPMWK2FF0D9WMHEJHR07C3Q", +            "followers_count": 2, +            "following_count": 2, +            "statuses_count": 9, +            "last_status_at": "2024-11-01", +            "emojis": [], +            "fields": [], +            "enable_rss": true +        }, +        "poll": null, +        "media_attachments": [], +        "emojis": [] +    }, +    { +        "content": "\u003cp\u003ethis is the first status edit! now with content-warning\u003c/p\u003e", +        "spoiler_text": "edited status", +        "sensitive": false, +        "created_at": "2024-11-01T09:01:00.000Z", +        "account": { +            "id": "01F8MH1H7YV1Z7D2C8K2730QBF", +            "username": "the_mighty_zork", +            "acct": "the_mighty_zork", +            "display_name": "original zork (he/they)", +            "locked": false, +            "discoverable": true, +            "bot": false, +            "created_at": "2022-05-20T11:09:18.000Z", +            "note": "\u003cp\u003ehey yo this is my profile!\u003c/p\u003e", +            "url": "http://localhost:8080/@the_mighty_zork", +            "avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg", +            "avatar_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.webp", +            "avatar_description": "a green goblin looking nasty", +            "avatar_media_id": "01F8MH58A357CV5K7R7TJMSH6S", +            "header": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", +            "header_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.webp", +            "header_description": "A very old-school screenshot of the original team fortress mod for quake", +            "header_media_id": "01PFPMWK2FF0D9WMHEJHR07C3Q", +            "followers_count": 2, +            "following_count": 2, +            "statuses_count": 9, +            "last_status_at": "2024-11-01", +            "emojis": [], +            "fields": [], +            "enable_rss": true +        }, +        "poll": null, +        "media_attachments": [], +        "emojis": [] +    }, +    { +        "content": "\u003cp\u003ethis is the original status\u003c/p\u003e", +        "spoiler_text": "", +        "sensitive": false, +        "created_at": "2024-11-01T09:00:00.000Z", +        "account": { +            "id": "01F8MH1H7YV1Z7D2C8K2730QBF", +            "username": "the_mighty_zork", +            "acct": "the_mighty_zork", +            "display_name": "original zork (he/they)", +            "locked": false, +            "discoverable": true, +            "bot": false, +            "created_at": "2022-05-20T11:09:18.000Z", +            "note": "\u003cp\u003ehey yo this is my profile!\u003c/p\u003e", +            "url": "http://localhost:8080/@the_mighty_zork", +            "avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg", +            "avatar_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.webp", +            "avatar_description": "a green goblin looking nasty", +            "avatar_media_id": "01F8MH58A357CV5K7R7TJMSH6S", +            "header": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", +            "header_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.webp", +            "header_description": "A very old-school screenshot of the original team fortress mod for quake", +            "header_media_id": "01PFPMWK2FF0D9WMHEJHR07C3Q", +            "followers_count": 2, +            "following_count": 2, +            "statuses_count": 9, +            "last_status_at": "2024-11-01", +            "emojis": [], +            "fields": [], +            "enable_rss": true +        }, +        "poll": null, +        "media_attachments": [], +        "emojis": [] +    } +]`, string(b)) +} +  func TestInternalToFrontendTestSuite(t *testing.T) {  	suite.Run(t, new(InternalToFrontendTestSuite))  }  | 
