",
"created_at": "right the hell just now babyee",
+ "edited_at": null,
"emojis": [],
"favourited": false,
"favourites_count": 0,
@@ -829,6 +837,7 @@ func (suite *StatusCreateTestSuite) TestAttachNewMediaSuccess() {
"card": null,
"content": "
here's an image attachment
",
"created_at": "right the hell just now babyee",
+ "edited_at": null,
"emojis": [],
"favourited": false,
"favourites_count": 0,
@@ -933,6 +942,7 @@ func (suite *StatusCreateTestSuite) TestPostNewStatusWithNoncanonicalLanguageTag
"card": null,
"content": "
English? what's English? i speak American
",
"created_at": "right the hell just now babyee",
+ "edited_at": null,
"emojis": [],
"favourited": false,
"favourites_count": 0,
@@ -1007,6 +1017,7 @@ func (suite *StatusCreateTestSuite) TestPostNewStatusWithPollForm() {
"card": null,
"content": "
this is a status with a poll!
",
"created_at": "right the hell just now babyee",
+ "edited_at": null,
"emojis": [],
"favourited": false,
"favourites_count": 0,
@@ -1103,6 +1114,7 @@ func (suite *StatusCreateTestSuite) TestPostNewStatusWithPollJSON() {
"card": null,
"content": "
this is a status with a poll!
",
"created_at": "right the hell just now babyee",
+ "edited_at": null,
"emojis": [],
"favourited": false,
"favourites_count": 0,
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 .
+
+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 .
+
+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/statusfave_test.go b/internal/api/client/statuses/statusfave_test.go
index bd81c0cf9..8851b4d58 100644
--- a/internal/api/client/statuses/statusfave_test.go
+++ b/internal/api/client/statuses/statusfave_test.go
@@ -105,6 +105,7 @@ func (suite *StatusFaveTestSuite) TestPostFave() {
"card": null,
"content": "🐕🐕🐕🐕🐕",
"created_at": "right the hell just now babyee",
+ "edited_at": null,
"emojis": [],
"favourited": true,
"favourites_count": 1,
@@ -228,6 +229,7 @@ func (suite *StatusFaveTestSuite) TestPostFaveImplicitAccept() {
"card": null,
"content": "
",
"created_at": "right the hell just now babyee",
+ "edited_at": null,
"emojis": [],
"favourited": true,
"favourites_count": 1,
diff --git a/internal/api/client/statuses/statushistory_test.go b/internal/api/client/statuses/statushistory_test.go
index aea666dbb..3878f54e4 100644
--- a/internal/api/client/statuses/statushistory_test.go
+++ b/internal/api/client/statuses/statushistory_test.go
@@ -116,8 +116,8 @@ func (suite *StatusHistoryTestSuite) TestGetHistory() {
"header_media_id": "01PFPMWK2FF0D9WMHEJHR07C3Q",
"followers_count": 2,
"following_count": 2,
- "statuses_count": 8,
- "last_status_at": "2024-01-10",
+ "statuses_count": 9,
+ "last_status_at": "2024-11-01",
"emojis": [],
"fields": [],
"enable_rss": true
diff --git a/internal/api/client/statuses/statusmute_test.go b/internal/api/client/statuses/statusmute_test.go
index 384761fc6..66bd4a420 100644
--- a/internal/api/client/statuses/statusmute_test.go
+++ b/internal/api/client/statuses/statusmute_test.go
@@ -91,6 +91,7 @@ func (suite *StatusMuteTestSuite) TestMuteUnmuteStatus() {
suite.Equal(`{
"id": "01F8MHAMCHF6Y650WCRSCP4WMY",
"created_at": "2021-10-20T10:40:37.000Z",
+ "edited_at": null,
"in_reply_to_id": null,
"in_reply_to_account_id": null,
"sensitive": true,
@@ -134,8 +135,8 @@ func (suite *StatusMuteTestSuite) TestMuteUnmuteStatus() {
"header_media_id": "01PFPMWK2FF0D9WMHEJHR07C3Q",
"followers_count": 2,
"following_count": 2,
- "statuses_count": 8,
- "last_status_at": "2024-01-10",
+ "statuses_count": 9,
+ "last_status_at": "2024-11-01",
"emojis": [],
"fields": [],
"enable_rss": true
@@ -178,6 +179,7 @@ func (suite *StatusMuteTestSuite) TestMuteUnmuteStatus() {
suite.Equal(`{
"id": "01F8MHAMCHF6Y650WCRSCP4WMY",
"created_at": "2021-10-20T10:40:37.000Z",
+ "edited_at": null,
"in_reply_to_id": null,
"in_reply_to_account_id": null,
"sensitive": true,
@@ -221,8 +223,8 @@ func (suite *StatusMuteTestSuite) TestMuteUnmuteStatus() {
"header_media_id": "01PFPMWK2FF0D9WMHEJHR07C3Q",
"followers_count": 2,
"following_count": 2,
- "statuses_count": 8,
- "last_status_at": "2024-01-10",
+ "statuses_count": 9,
+ "last_status_at": "2024-11-01",
"emojis": [],
"fields": [],
"enable_rss": true
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 @@
//
// 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/content.go b/internal/api/model/content.go
index 7da389ed1..5af81b11b 100644
--- a/internal/api/model/content.go
+++ b/internal/api/model/content.go
@@ -19,7 +19,6 @@
import (
"io"
- "time"
"github.com/superseriousbusiness/gotosocial/internal/storage"
)
@@ -30,8 +29,6 @@ type Content struct {
ContentType string
// ContentLength in bytes
ContentLength int64
- // Time when the content was last updated.
- ContentUpdated time.Time
// Actual content
Content io.ReadCloser
// Resource URL to forward to if the file can be fetched from the storage directly (e.g signed S3 URL)
diff --git a/internal/api/model/instance.go b/internal/api/model/instance.go
index 5232e8d66..d59424fa5 100644
--- a/internal/api/model/instance.go
+++ b/internal/api/model/instance.go
@@ -33,6 +33,8 @@ type InstanceSettingsUpdateRequest struct {
ShortDescription *string `form:"short_description" json:"short_description" xml:"short_description"`
// Longer description of the instance, max 5,000 chars. HTML formatting accepted.
Description *string `form:"description" json:"description" xml:"description"`
+ // Custom CSS for the instance.
+ CustomCSS *string `form:"custom_css" json:"custom_css,omitempty" xml:"custom_css"`
// Terms and conditions of the instance, max 5,000 chars. HTML formatting accepted.
Terms *string `form:"terms" json:"terms" xml:"terms"`
// Image to use as the instance thumbnail.
diff --git a/internal/api/model/instancev1.go b/internal/api/model/instancev1.go
index efa6d6faa..6dedd04cc 100644
--- a/internal/api/model/instancev1.go
+++ b/internal/api/model/instancev1.go
@@ -38,6 +38,8 @@ type InstanceV1 struct {
//
// This should be displayed on the 'about' page for an instance.
Description string `json:"description"`
+ // Custom CSS for the instance.
+ CustomCSS string `json:"custom_css,omitempty"`
// Raw (unparsed) version of description.
DescriptionText string `json:"description_text,omitempty"`
// A shorter description of the instance.
diff --git a/internal/api/model/instancev2.go b/internal/api/model/instancev2.go
index dcbd14ec0..b3d11dee2 100644
--- a/internal/api/model/instancev2.go
+++ b/internal/api/model/instancev2.go
@@ -53,6 +53,8 @@ type InstanceV2 struct {
Description string `json:"description"`
// Raw (unparsed) version of description.
DescriptionText string `json:"description_text,omitempty"`
+ // Instance Custom Css
+ CustomCSS string `json:"custom_css,omitempty"`
// Basic anonymous usage data for this instance.
Usage InstanceV2Usage `json:"usage"`
// An image used to represent this instance.
diff --git a/internal/api/model/status.go b/internal/api/model/status.go
index c29ab3e82..ea9fbaa35 100644
--- a/internal/api/model/status.go
+++ b/internal/api/model/status.go
@@ -29,6 +29,10 @@ type Status struct {
// The date when this status was created (ISO 8601 Datetime).
// example: 2021-07-30T09:20:25+00:00
CreatedAt string `json:"created_at"`
+ // Timestamp of when the status was last edited (ISO 8601 Datetime).
+ // example: 2021-07-30T09:20:25+00:00
+ // nullable: true
+ EditedAt *string `json:"edited_at"`
// ID of the status being replied to.
// example: 01FBVD42CQ3ZEEVMW180SBX03B
// nullable: true
@@ -193,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"`
}
@@ -232,6 +250,7 @@ type StatusCreateRequest struct {
//
// swagger:ignore
type StatusInteractionPolicyForm struct {
+
// Interaction policy to use for this status.
InteractionPolicy *InteractionPolicy `form:"interaction_policy" json:"-"`
}
@@ -246,13 +265,18 @@ type StatusInteractionPolicyForm struct {
// 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"
)
@@ -264,7 +288,8 @@ type StatusInteractionPolicyForm struct {
// 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"
@@ -276,11 +301,14 @@ type StatusInteractionPolicyForm struct {
//
// 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"`
}
@@ -290,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:
Hey this is a status!
Content string `json:"content"`
+
// Subject, summary, or content warning for the status at this revision.
// example: warning nsfw
SpoilerText string `json:"spoiler_text"`
+
// Status marked sensitive at this revision.
// example: false
Sensitive bool `json:"sensitive"`
+
// The date when this revision was created (ISO 8601 Datetime).
// example: 2021-07-30T09:20:25+00:00
CreatedAt string `json:"created_at"`
+
// The account that authored this status.
Account *Account `json:"account"`
+
// The poll attached to the status at this revision.
// Note that edits changing the poll options will be collapsed together into one edit, since this action resets the poll.
// nullable: true
Poll *Poll `json:"poll"`
+
// Media that is attached to this status.
MediaAttachments []*Attachment `json:"media_attachments"`
+
// Custom emoji to be used when rendering status content.
Emojis []Emoji `json:"emojis"`
}
+
+// StatusEditRequest models status edit parameters.
+//
+// swagger:ignore
+type StatusEditRequest struct {
+
+ // Text content of the status.
+ // If media_ids is provided, this becomes optional.
+ // Attaching a poll is optional while status is provided.
+ Status string `form:"status" json:"status"`
+
+ // Text to be shown as a warning or subject before the actual content.
+ // Statuses are generally collapsed behind this field.
+ SpoilerText string `form:"spoiler_text" json:"spoiler_text"`
+
+ // Content type to use when parsing this status.
+ ContentType StatusContentType `form:"content_type" json:"content_type"`
+
+ // Status and attached media should be marked as sensitive.
+ Sensitive bool `form:"sensitive" json:"sensitive"`
+
+ // ISO 639 language code for this status.
+ Language string `form:"language" json:"language"`
+
+ // Array of Attachment ids to be attached as media.
+ // If provided, status becomes optional, and poll cannot be used.
+ MediaIDs []string `form:"media_ids[]" json:"media_ids"`
+
+ // Array of Attachment attributes to be updated in attached media.
+ MediaAttributes []AttachmentAttributesRequest `form:"media_attributes[]" json:"media_attributes"`
+
+ // Poll to include with this status.
+ Poll *PollRequest `form:"poll" json:"poll"`
+}
diff --git a/internal/api/util/parseform.go b/internal/api/util/parseform.go
index 3eab065f2..8bb10012c 100644
--- a/internal/api/util/parseform.go
+++ b/internal/api/util/parseform.go
@@ -18,13 +18,55 @@
package util
import (
+ "errors"
"fmt"
"strconv"
+ "strings"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
+// ParseFocus parses a media attachment focus parameters from incoming API string.
+func ParseFocus(focus string) (focusx, focusy float32, errWithCode gtserror.WithCode) {
+ if focus == "" {
+ return
+ }
+ spl := strings.Split(focus, ",")
+ if len(spl) != 2 {
+ const text = "missing comma separator"
+ errWithCode = gtserror.NewErrorBadRequest(
+ errors.New(text),
+ text,
+ )
+ return
+ }
+ xStr := spl[0]
+ yStr := spl[1]
+ fx, err := strconv.ParseFloat(xStr, 32)
+ if err != nil || fx > 1 || fx < -1 {
+ text := fmt.Sprintf("invalid x focus: %s", xStr)
+ errWithCode = gtserror.NewErrorBadRequest(
+ errors.New(text),
+ text,
+ )
+ return
+ }
+ fy, err := strconv.ParseFloat(yStr, 32)
+ if err != nil || fy > 1 || fy < -1 {
+ text := fmt.Sprintf("invalid y focus: %s", xStr)
+ errWithCode = gtserror.NewErrorBadRequest(
+ errors.New(text),
+ text,
+ )
+ return
+ }
+ focusx = float32(fx)
+ focusy = float32(fy)
+ return
+}
+
// ParseDuration parses the given raw interface belonging
// the given fieldName as an integer duration.
func ParseDuration(rawI any, fieldName string) (*int, error) {
diff --git a/internal/cache/cache.go b/internal/cache/cache.go
index 152ae33d7..0154c0ff0 100644
--- a/internal/cache/cache.go
+++ b/internal/cache/cache.go
@@ -105,6 +105,7 @@ func (c *Caches) Init() {
c.initStatus()
c.initStatusBookmark()
c.initStatusBookmarkIDs()
+ c.initStatusEdit()
c.initStatusFave()
c.initStatusFaveIDs()
c.initTag()
diff --git a/internal/cache/db.go b/internal/cache/db.go
index c264d5567..8638cce62 100644
--- a/internal/cache/db.go
+++ b/internal/cache/db.go
@@ -226,6 +226,9 @@ type DBCaches struct {
// StatusBookmarkIDs provides access to the status bookmark IDs list database cache.
StatusBookmarkIDs SliceCache[string]
+ // StatusEdit provides access to the gtsmodel StatusEdit database cache.
+ StatusEdit StructCache[*gtsmodel.StatusEdit]
+
// StatusFave provides access to the gtsmodel StatusFave database cache.
StatusFave StructCache[*gtsmodel.StatusFave]
@@ -1394,6 +1397,38 @@ func (c *Caches) initStatusBookmarkIDs() {
c.DB.StatusBookmarkIDs.Init(0, cap)
}
+func (c *Caches) initStatusEdit() {
+ // Calculate maximum cache size.
+ cap := calculateResultCacheMax(
+ sizeofStatusEdit(), // model in-mem size.
+ config.GetCacheStatusEditMemRatio(),
+ )
+
+ log.Infof(nil, "cache size = %d", cap)
+
+ copyF := func(s1 *gtsmodel.StatusEdit) *gtsmodel.StatusEdit {
+ s2 := new(gtsmodel.StatusEdit)
+ *s2 = *s1
+
+ // Don't include ptr fields that
+ // will be populated separately.
+ s2.Attachments = nil
+
+ return s2
+ }
+
+ c.DB.StatusEdit.Init(structr.CacheConfig[*gtsmodel.StatusEdit]{
+ Indices: []structr.IndexConfig{
+ {Fields: "ID"},
+ {Fields: "StatusID", Multiple: true},
+ },
+ MaxSize: cap,
+ IgnoreErr: ignoreErrors,
+ Copy: copyF,
+ Invalidate: c.OnInvalidateStatusEdit,
+ })
+}
+
func (c *Caches) initStatusFave() {
// Calculate maximum cache size.
cap := calculateResultCacheMax(
diff --git a/internal/cache/invalidate.go b/internal/cache/invalidate.go
index be3eaa735..555c73cd7 100644
--- a/internal/cache/invalidate.go
+++ b/internal/cache/invalidate.go
@@ -273,6 +273,11 @@ func (c *Caches) OnInvalidateStatusBookmark(bookmark *gtsmodel.StatusBookmark) {
c.DB.StatusBookmarkIDs.Invalidate(bookmark.StatusID)
}
+func (c *Caches) OnInvalidateStatusEdit(edit *gtsmodel.StatusEdit) {
+ // Invalidate cache of related status model.
+ c.DB.Status.Invalidate("ID", edit.StatusID)
+}
+
func (c *Caches) OnInvalidateStatusFave(fave *gtsmodel.StatusFave) {
// Invalidate status fave ID list for this status.
c.DB.StatusFaveIDs.Invalidate(fave.StatusID)
diff --git a/internal/cache/size.go b/internal/cache/size.go
index abed1e3b6..f5b2b4d5c 100644
--- a/internal/cache/size.go
+++ b/internal/cache/size.go
@@ -513,7 +513,6 @@ func sizeofMedia() uintptr {
URL: exampleURI,
RemoteURL: exampleURI,
CreatedAt: exampleTime,
- UpdatedAt: exampleTime,
Type: gtsmodel.FileTypeImage,
AccountID: exampleID,
Description: exampleText,
@@ -540,7 +539,6 @@ func sizeofMention() uintptr {
ID: exampleURI,
StatusID: exampleURI,
CreatedAt: exampleTime,
- UpdatedAt: exampleTime,
OriginAccountID: exampleURI,
OriginAccountURI: exampleURI,
TargetAccountID: exampleID,
@@ -682,6 +680,23 @@ func sizeofStatusBookmark() uintptr {
}))
}
+func sizeofStatusEdit() uintptr {
+ return uintptr(size.Of(>smodel.StatusEdit{
+ ID: exampleID,
+ Content: exampleText,
+ ContentWarning: exampleUsername, // similar length
+ Text: exampleText,
+ Language: "en",
+ Sensitive: func() *bool { ok := false; return &ok }(),
+ AttachmentIDs: []string{exampleID, exampleID, exampleID},
+ Attachments: nil,
+ PollOptions: []string{exampleTextSmall, exampleTextSmall, exampleTextSmall, exampleTextSmall},
+ PollVotes: []int{69, 420, 1337, 1969},
+ StatusID: exampleID,
+ CreatedAt: exampleTime,
+ }))
+}
+
func sizeofStatusFave() uintptr {
return uintptr(size.Of(>smodel.StatusFave{
ID: exampleID,
diff --git a/internal/config/config.go b/internal/config/config.go
index d9491740e..fd6cc82db 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -238,6 +238,7 @@ type CacheConfiguration struct {
StatusMemRatio float64 `name:"status-mem-ratio"`
StatusBookmarkMemRatio float64 `name:"status-bookmark-mem-ratio"`
StatusBookmarkIDsMemRatio float64 `name:"status-bookmark-ids-mem-ratio"`
+ StatusEditMemRatio float64 `name:"status-edit-mem-ratio"`
StatusFaveMemRatio float64 `name:"status-fave-mem-ratio"`
StatusFaveIDsMemRatio float64 `name:"status-fave-ids-mem-ratio"`
TagMemRatio float64 `name:"tag-mem-ratio"`
diff --git a/internal/config/defaults.go b/internal/config/defaults.go
index 0b28b9025..b0aed5422 100644
--- a/internal/config/defaults.go
+++ b/internal/config/defaults.go
@@ -199,6 +199,7 @@
StatusMemRatio: 5,
StatusBookmarkMemRatio: 0.5,
StatusBookmarkIDsMemRatio: 2,
+ StatusEditMemRatio: 2,
StatusFaveMemRatio: 2,
StatusFaveIDsMemRatio: 3,
TagMemRatio: 2,
diff --git a/internal/config/helpers.gen.go b/internal/config/helpers.gen.go
index 2c554d87a..9ec33e6d9 100644
--- a/internal/config/helpers.gen.go
+++ b/internal/config/helpers.gen.go
@@ -3912,6 +3912,31 @@ func GetCacheStatusBookmarkIDsMemRatio() float64 { return global.GetCacheStatusB
// SetCacheStatusBookmarkIDsMemRatio safely sets the value for global configuration 'Cache.StatusBookmarkIDsMemRatio' field
func SetCacheStatusBookmarkIDsMemRatio(v float64) { global.SetCacheStatusBookmarkIDsMemRatio(v) }
+// GetCacheStatusEditMemRatio safely fetches the Configuration value for state's 'Cache.StatusEditMemRatio' field
+func (st *ConfigState) GetCacheStatusEditMemRatio() (v float64) {
+ st.mutex.RLock()
+ v = st.config.Cache.StatusEditMemRatio
+ st.mutex.RUnlock()
+ return
+}
+
+// SetCacheStatusEditMemRatio safely sets the Configuration value for state's 'Cache.StatusEditMemRatio' field
+func (st *ConfigState) SetCacheStatusEditMemRatio(v float64) {
+ st.mutex.Lock()
+ defer st.mutex.Unlock()
+ st.config.Cache.StatusEditMemRatio = v
+ st.reloadToViper()
+}
+
+// CacheStatusEditMemRatioFlag returns the flag name for the 'Cache.StatusEditMemRatio' field
+func CacheStatusEditMemRatioFlag() string { return "cache-status-edit-mem-ratio" }
+
+// GetCacheStatusEditMemRatio safely fetches the value for global configuration 'Cache.StatusEditMemRatio' field
+func GetCacheStatusEditMemRatio() float64 { return global.GetCacheStatusEditMemRatio() }
+
+// SetCacheStatusEditMemRatio safely sets the value for global configuration 'Cache.StatusEditMemRatio' field
+func SetCacheStatusEditMemRatio(v float64) { global.SetCacheStatusEditMemRatio(v) }
+
// GetCacheStatusFaveMemRatio safely fetches the Configuration value for state's 'Cache.StatusFaveMemRatio' field
func (st *ConfigState) GetCacheStatusFaveMemRatio() (v float64) {
st.mutex.RLock()
diff --git a/internal/db/bundb/account_test.go b/internal/db/bundb/account_test.go
index 7dcc0f9e7..879250408 100644
--- a/internal/db/bundb/account_test.go
+++ b/internal/db/bundb/account_test.go
@@ -46,7 +46,7 @@ type AccountTestSuite struct {
func (suite *AccountTestSuite) TestGetAccountStatuses() {
statuses, err := suite.db.GetAccountStatuses(context.Background(), suite.testAccounts["local_account_1"].ID, 20, false, false, "", "", false, false)
suite.NoError(err)
- suite.Len(statuses, 8)
+ suite.Len(statuses, 9)
}
func (suite *AccountTestSuite) TestGetAccountStatusesPageDown() {
@@ -69,7 +69,7 @@ func (suite *AccountTestSuite) TestGetAccountStatusesPageDown() {
if err != nil {
suite.FailNow(err.Error())
}
- suite.Len(statuses, 2)
+ suite.Len(statuses, 3)
// try to get the last page (should be empty)
statuses, err = suite.db.GetAccountStatuses(context.Background(), suite.testAccounts["local_account_1"].ID, 3, false, false, statuses[len(statuses)-1].ID, "", false, false)
@@ -80,13 +80,13 @@ func (suite *AccountTestSuite) TestGetAccountStatusesPageDown() {
func (suite *AccountTestSuite) TestGetAccountStatusesExcludeRepliesAndReblogs() {
statuses, err := suite.db.GetAccountStatuses(context.Background(), suite.testAccounts["local_account_1"].ID, 20, true, true, "", "", false, false)
suite.NoError(err)
- suite.Len(statuses, 7)
+ suite.Len(statuses, 8)
}
func (suite *AccountTestSuite) TestGetAccountStatusesExcludeRepliesAndReblogsPublicOnly() {
statuses, err := suite.db.GetAccountStatuses(context.Background(), suite.testAccounts["local_account_1"].ID, 20, true, true, "", "", false, true)
suite.NoError(err)
- suite.Len(statuses, 3)
+ suite.Len(statuses, 4)
}
// populateTestStatus adds mandatory fields to a partially populated status.
@@ -173,7 +173,7 @@ func (suite *AccountTestSuite) TestGetAccountStatusesExcludeRepliesExcludesSelfR
testAccount := suite.testAccounts["local_account_1"]
statuses, err := suite.db.GetAccountStatuses(context.Background(), testAccount.ID, 20, true, true, "", "", false, false)
suite.NoError(err)
- suite.Len(statuses, 8)
+ suite.Len(statuses, 9)
for _, status := range statuses {
if status.InReplyToID != "" && status.InReplyToAccountID != testAccount.ID {
suite.FailNowf("", "Status with ID %s is a non-self reply and should have been excluded", status.ID)
diff --git a/internal/db/bundb/basic_test.go b/internal/db/bundb/basic_test.go
index 56159dc25..e20aab765 100644
--- a/internal/db/bundb/basic_test.go
+++ b/internal/db/bundb/basic_test.go
@@ -114,7 +114,7 @@ func (suite *BasicTestSuite) TestGetAllStatuses() {
s := []*gtsmodel.Status{}
err := suite.db.GetAll(context.Background(), &s)
suite.NoError(err)
- suite.Len(s, 25)
+ suite.Len(s, 28)
}
func (suite *BasicTestSuite) TestGetAllNotNull() {
diff --git a/internal/db/bundb/bundb.go b/internal/db/bundb/bundb.go
index c307e0356..c9dd7866d 100644
--- a/internal/db/bundb/bundb.go
+++ b/internal/db/bundb/bundb.go
@@ -81,6 +81,7 @@ type DBService struct {
db.SinBinStatus
db.Status
db.StatusBookmark
+ db.StatusEdit
db.StatusFave
db.Tag
db.Thread
@@ -273,6 +274,10 @@ func NewBunDBService(ctx context.Context, state *state.State) (db.DB, error) {
db: db,
state: state,
},
+ StatusEdit: &statusEditDB{
+ db: db,
+ state: state,
+ },
StatusFave: &statusFaveDB{
db: db,
state: state,
diff --git a/internal/db/bundb/bundb_test.go b/internal/db/bundb/bundb_test.go
index e976199e4..2fcf61aed 100644
--- a/internal/db/bundb/bundb_test.go
+++ b/internal/db/bundb/bundb_test.go
@@ -57,6 +57,7 @@ type BunDBStandardTestSuite struct {
testPolls map[string]*gtsmodel.Poll
testPollVotes map[string]*gtsmodel.PollVote
testInteractionRequests map[string]*gtsmodel.InteractionRequest
+ testStatusEdits map[string]*gtsmodel.StatusEdit
}
func (suite *BunDBStandardTestSuite) SetupSuite() {
@@ -83,6 +84,7 @@ func (suite *BunDBStandardTestSuite) SetupSuite() {
suite.testPolls = testrig.NewTestPolls()
suite.testPollVotes = testrig.NewTestPollVotes()
suite.testInteractionRequests = testrig.NewTestInteractionRequests()
+ suite.testStatusEdits = testrig.NewTestStatusEdits()
}
func (suite *BunDBStandardTestSuite) SetupTest() {
diff --git a/internal/db/bundb/instance_test.go b/internal/db/bundb/instance_test.go
index 4b8ec9962..1364bacc2 100644
--- a/internal/db/bundb/instance_test.go
+++ b/internal/db/bundb/instance_test.go
@@ -47,13 +47,13 @@ func (suite *InstanceTestSuite) TestCountInstanceUsersRemote() {
func (suite *InstanceTestSuite) TestCountInstanceStatuses() {
count, err := suite.db.CountInstanceStatuses(context.Background(), config.GetHost())
suite.NoError(err)
- suite.Equal(19, count)
+ suite.Equal(21, count)
}
func (suite *InstanceTestSuite) TestCountInstanceStatusesRemote() {
count, err := suite.db.CountInstanceStatuses(context.Background(), "fossbros-anonymous.io")
suite.NoError(err)
- suite.Equal(3, count)
+ suite.Equal(4, count)
}
func (suite *InstanceTestSuite) TestCountInstanceDomains() {
diff --git a/internal/db/bundb/interaction_test.go b/internal/db/bundb/interaction_test.go
index 37684f18c..1eb8154c1 100644
--- a/internal/db/bundb/interaction_test.go
+++ b/internal/db/bundb/interaction_test.go
@@ -59,11 +59,7 @@ func (suite *InteractionTestSuite) markInteractionsPending(
// Put an interaction request
// in the DB for this reply.
- req, err := typeutils.StatusToInteractionRequest(ctx, reply)
- if err != nil {
- suite.FailNow(err.Error())
- }
-
+ req := typeutils.StatusToInteractionRequest(reply)
if err := suite.state.DB.PutInteractionRequest(ctx, req); err != nil {
suite.FailNow(err.Error())
}
@@ -90,11 +86,7 @@ func (suite *InteractionTestSuite) markInteractionsPending(
// Put an interaction request
// in the DB for this boost.
- req, err := typeutils.StatusToInteractionRequest(ctx, boost)
- if err != nil {
- suite.FailNow(err.Error())
- }
-
+ req := typeutils.StatusToInteractionRequest(boost)
if err := suite.state.DB.PutInteractionRequest(ctx, req); err != nil {
suite.FailNow(err.Error())
}
@@ -121,11 +113,7 @@ func (suite *InteractionTestSuite) markInteractionsPending(
// Put an interaction request
// in the DB for this fave.
- req, err := typeutils.StatusFaveToInteractionRequest(ctx, fave)
- if err != nil {
- suite.FailNow(err.Error())
- }
-
+ req := typeutils.StatusFaveToInteractionRequest(fave)
if err := suite.state.DB.PutInteractionRequest(ctx, req); err != nil {
suite.FailNow(err.Error())
}
diff --git a/internal/db/bundb/media.go b/internal/db/bundb/media.go
index 453ad856a..09c8188f0 100644
--- a/internal/db/bundb/media.go
+++ b/internal/db/bundb/media.go
@@ -104,12 +104,6 @@ func (m *mediaDB) PutAttachment(ctx context.Context, media *gtsmodel.MediaAttach
}
func (m *mediaDB) UpdateAttachment(ctx context.Context, media *gtsmodel.MediaAttachment, columns ...string) error {
- media.UpdatedAt = time.Now()
- if len(columns) > 0 {
- // If we're updating by column, ensure "updated_at" is included.
- columns = append(columns, "updated_at")
- }
-
return m.state.Caches.DB.Media.Store(media, func() error {
_, err := m.db.NewUpdate().
Model(media).
diff --git a/internal/db/bundb/migrations/20240809134448_interaction_requests_client_api.go b/internal/db/bundb/migrations/20240809134448_interaction_requests_client_api.go
index 82c2b4016..a3fb8675e 100644
--- a/internal/db/bundb/migrations/20240809134448_interaction_requests_client_api.go
+++ b/internal/db/bundb/migrations/20240809134448_interaction_requests_client_api.go
@@ -93,11 +93,7 @@ func init() {
// For each currently pending status, check whether it's a reply or
// a boost, and insert a corresponding interaction request into the db.
for _, pendingStatus := range pendingStatuses {
- req, err := typeutils.StatusToInteractionRequest(ctx, pendingStatus)
- if err != nil {
- return err
- }
-
+ req := typeutils.StatusToInteractionRequest(pendingStatus)
if _, err := tx.
NewInsert().
Model(req).
@@ -125,10 +121,7 @@ func init() {
}
for _, pendingFave := range pendingFaves {
- req, err := typeutils.StatusFaveToInteractionRequest(ctx, pendingFave)
- if err != nil {
- return err
- }
+ req := typeutils.StatusFaveToInteractionRequest(pendingFave)
if _, err := tx.
NewInsert().
diff --git a/internal/db/bundb/migrations/20240924222938_add_instance_custom_css.go b/internal/db/bundb/migrations/20240924222938_add_instance_custom_css.go
new file mode 100644
index 000000000..14231927a
--- /dev/null
+++ b/internal/db/bundb/migrations/20240924222938_add_instance_custom_css.go
@@ -0,0 +1,44 @@
+// 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 .
+
+package migrations
+
+import (
+ "context"
+ "strings"
+
+ "github.com/uptrace/bun"
+)
+
+func init() {
+ up := func(ctx context.Context, db *bun.DB) error {
+ _, err := db.ExecContext(ctx, "ALTER TABLE ? ADD COLUMN ? TEXT", bun.Ident("instances"), bun.Ident("custom_css"))
+ if err != nil && !(strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "duplicate column name") || strings.Contains(err.Error(), "SQLSTATE 42701")) {
+ return err
+ }
+ return nil
+ }
+
+ down := func(ctx context.Context, db *bun.DB) error {
+ _, err := db.ExecContext(ctx, "ALTER TABLE ? DROP COLUMN ?", bun.Ident("instances"), bun.Ident("custom_css"))
+ return err
+ }
+
+ if err := Migrations.Register(up, down); err != nil {
+ panic(err)
+ }
+}
diff --git a/internal/db/bundb/migrations/20241113151042_remove_mention_updated_at.go b/internal/db/bundb/migrations/20241113151042_remove_mention_updated_at.go
new file mode 100644
index 000000000..ba6e0bd3a
--- /dev/null
+++ b/internal/db/bundb/migrations/20241113151042_remove_mention_updated_at.go
@@ -0,0 +1,59 @@
+// 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 .
+
+package migrations
+
+import (
+ "context"
+
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/log"
+ "github.com/uptrace/bun"
+)
+
+func init() {
+ up := func(ctx context.Context, db *bun.DB) error {
+ return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
+
+ // Check for 'updated_at' column on mentions table, else return.
+ exists, err := doesColumnExist(ctx, tx, "mentions", "updated_at")
+ if err != nil {
+ return err
+ } else if !exists {
+ return nil
+ }
+
+ // Remove 'updated_at' column.
+ log.Info(ctx, "removing unused updated_at column from mentions to save space, please wait...")
+ _, err = tx.NewDropColumn().
+ Model((*gtsmodel.Mention)(nil)).
+ Column("updated_at").
+ Exec(ctx)
+ return err
+ })
+ }
+
+ down := func(ctx context.Context, db *bun.DB) error {
+ return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
+ return nil
+ })
+ }
+
+ if err := Migrations.Register(up, down); err != nil {
+ panic(err)
+ }
+}
diff --git a/internal/db/bundb/migrations/20241113152126_add_status_edits.go b/internal/db/bundb/migrations/20241113152126_add_status_edits.go
new file mode 100644
index 000000000..5d4fb7b3e
--- /dev/null
+++ b/internal/db/bundb/migrations/20241113152126_add_status_edits.go
@@ -0,0 +1,69 @@
+// 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 .
+
+package migrations
+
+import (
+ "context"
+ "reflect"
+
+ gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/bundb/migrations/20241113152126_add_status_edits"
+ "github.com/superseriousbusiness/gotosocial/internal/log"
+
+ "github.com/uptrace/bun"
+)
+
+func init() {
+ up := func(ctx context.Context, db *bun.DB) error {
+ return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
+ statusType := reflect.TypeOf((*gtsmodel.Status)(nil))
+
+ // Generate new Status.EditIDs column definition from bun.
+ colDef, err := getBunColumnDef(tx, statusType, "EditIDs")
+ if err != nil {
+ return err
+ }
+
+ // Add EditIDs column to Status table.
+ log.Info(ctx, "adding edits column to statuses table...")
+ _, err = tx.NewAddColumn().
+ Model((*gtsmodel.Status)(nil)).
+ ColumnExpr(colDef).
+ Exec(ctx)
+ if err != nil {
+ return err
+ }
+
+ // Create the main StatusEdits table.
+ _, err = tx.NewCreateTable().
+ IfNotExists().
+ Model((*gtsmodel.StatusEdit)(nil)).
+ Exec(ctx)
+ return err
+ })
+ }
+
+ down := func(ctx context.Context, db *bun.DB) error {
+ return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
+ return nil
+ })
+ }
+
+ if err := Migrations.Register(up, down); err != nil {
+ panic(err)
+ }
+}
diff --git a/internal/db/bundb/migrations/20241113152126_add_status_edits/status.go b/internal/db/bundb/migrations/20241113152126_add_status_edits/status.go
new file mode 100644
index 000000000..1b7d93f70
--- /dev/null
+++ b/internal/db/bundb/migrations/20241113152126_add_status_edits/status.go
@@ -0,0 +1,97 @@
+// 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 .
+
+package gtsmodel
+
+import (
+ "time"
+
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+)
+
+// Status represents a user-created 'post' or 'status' in the database, either remote or local
+type Status struct {
+ ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
+ CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
+ UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated
+ FetchedAt time.Time `bun:"type:timestamptz,nullzero"` // when was item (remote) last fetched.
+ PinnedAt time.Time `bun:"type:timestamptz,nullzero"` // Status was pinned by owning account at this time.
+ URI string `bun:",unique,nullzero,notnull"` // activitypub URI of this status
+ URL string `bun:",nullzero"` // web url for viewing this status
+ Content string `bun:""` // content of this status; likely html-formatted but not guaranteed
+ AttachmentIDs []string `bun:"attachments,array"` // Database IDs of any media attachments associated with this status
+ Attachments []*gtsmodel.MediaAttachment `bun:"attached_media,rel:has-many"` // Attachments corresponding to attachmentIDs
+ TagIDs []string `bun:"tags,array"` // Database IDs of any tags used in this status
+ Tags []*gtsmodel.Tag `bun:"attached_tags,m2m:status_to_tags"` // Tags corresponding to tagIDs. https://bun.uptrace.dev/guide/relations.html#many-to-many-relation
+ MentionIDs []string `bun:"mentions,array"` // Database IDs of any mentions in this status
+ Mentions []*gtsmodel.Mention `bun:"attached_mentions,rel:has-many"` // Mentions corresponding to mentionIDs
+ EmojiIDs []string `bun:"emojis,array"` // Database IDs of any emojis used in this status
+ Emojis []*gtsmodel.Emoji `bun:"attached_emojis,m2m:status_to_emojis"` // Emojis corresponding to emojiIDs. https://bun.uptrace.dev/guide/relations.html#many-to-many-relation
+ Local *bool `bun:",nullzero,notnull,default:false"` // is this status from a local account?
+ AccountID string `bun:"type:CHAR(26),nullzero,notnull"` // which account posted this status?
+ Account *gtsmodel.Account `bun:"rel:belongs-to"` // account corresponding to accountID
+ AccountURI string `bun:",nullzero,notnull"` // activitypub uri of the owner of this status
+ InReplyToID string `bun:"type:CHAR(26),nullzero"` // id of the status this status replies to
+ InReplyToURI string `bun:",nullzero"` // activitypub uri of the status this status is a reply to
+ InReplyToAccountID string `bun:"type:CHAR(26),nullzero"` // id of the account that this status replies to
+ InReplyTo *Status `bun:"-"` // status corresponding to inReplyToID
+ InReplyToAccount *gtsmodel.Account `bun:"rel:belongs-to"` // account corresponding to inReplyToAccountID
+ BoostOfID string `bun:"type:CHAR(26),nullzero"` // id of the status this status is a boost of
+ BoostOfURI string `bun:"-"` // URI of the status this status is a boost of; field not inserted in the db, just for dereferencing purposes.
+ BoostOfAccountID string `bun:"type:CHAR(26),nullzero"` // id of the account that owns the boosted status
+ BoostOf *Status `bun:"-"` // status that corresponds to boostOfID
+ BoostOfAccount *gtsmodel.Account `bun:"rel:belongs-to"` // account that corresponds to boostOfAccountID
+ ThreadID string `bun:"type:CHAR(26),nullzero"` // id of the thread to which this status belongs; only set for remote statuses if a local account is involved at some point in the thread, otherwise null
+ EditIDs []string `bun:"edits,array"` //
+ Edits []*StatusEdit `bun:"-"` //
+ PollID string `bun:"type:CHAR(26),nullzero"` //
+ Poll *gtsmodel.Poll `bun:"-"` //
+ ContentWarning string `bun:",nullzero"` // cw string for this status
+ Visibility Visibility `bun:",nullzero,notnull"` // visibility entry for this status
+ Sensitive *bool `bun:",nullzero,notnull,default:false"` // mark the status as sensitive?
+ Language string `bun:",nullzero"` // what language is this status written in?
+ CreatedWithApplicationID string `bun:"type:CHAR(26),nullzero"` // Which application was used to create this status?
+ CreatedWithApplication *gtsmodel.Application `bun:"rel:belongs-to"` // application corresponding to createdWithApplicationID
+ ActivityStreamsType string `bun:",nullzero,notnull"` // What is the activitystreams type of this status? See: https://www.w3.org/TR/activitystreams-vocabulary/#object-types. Will probably almost always be Note but who knows!.
+ Text string `bun:""` // Original text of the status without formatting
+ Federated *bool `bun:",notnull"` // This status will be federated beyond the local timeline(s)
+ InteractionPolicy *gtsmodel.InteractionPolicy `bun:""` // InteractionPolicy for this status. If null then the default InteractionPolicy should be assumed for this status's Visibility. Always null for boost wrappers.
+ PendingApproval *bool `bun:",nullzero,notnull,default:false"` // If true then status is a reply or boost wrapper that must be Approved by the reply-ee or boost-ee before being fully distributed.
+ PreApproved bool `bun:"-"` // If true, then status is a reply to or boost wrapper of a status on our instance, has permission to do the interaction, and an Accept should be sent out for it immediately. Field not stored in the DB.
+ ApprovedByURI string `bun:",nullzero"` // URI of an Accept Activity that approves the Announce or Create Activity that this status was/will be attached to.
+}
+
+// Visibility represents the visibility granularity of a status.
+type Visibility string
+
+const (
+ // VisibilityNone means nobody can see this.
+ // It's only used for web status visibility.
+ VisibilityNone Visibility = "none"
+ // VisibilityPublic means this status will be visible to everyone on all timelines.
+ VisibilityPublic Visibility = "public"
+ // VisibilityUnlocked means this status will be visible to everyone, but will only show on home timeline to followers, and in lists.
+ VisibilityUnlocked Visibility = "unlocked"
+ // VisibilityFollowersOnly means this status is viewable to followers only.
+ VisibilityFollowersOnly Visibility = "followers_only"
+ // VisibilityMutualsOnly means this status is visible to mutual followers only.
+ VisibilityMutualsOnly Visibility = "mutuals_only"
+ // VisibilityDirect means this status is visible only to mentioned recipients.
+ VisibilityDirect Visibility = "direct"
+ // VisibilityDefault is used when no other setting can be found.
+ VisibilityDefault Visibility = VisibilityUnlocked
+)
diff --git a/internal/db/bundb/migrations/20241113152126_add_status_edits/statusedit.go b/internal/db/bundb/migrations/20241113152126_add_status_edits/statusedit.go
new file mode 100644
index 000000000..b27c3b343
--- /dev/null
+++ b/internal/db/bundb/migrations/20241113152126_add_status_edits/statusedit.go
@@ -0,0 +1,48 @@
+// 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 .
+
+package gtsmodel
+
+import (
+ "time"
+)
+
+// StatusEdit represents a **historical** view of a Status
+// after a received edit. The Status itself will always
+// contain the latest up-to-date information.
+//
+// Note that stored status edits may not exactly match that
+// of the origin server, they are a best-effort by receiver
+// to store version history. There is no AP history endpoint.
+type StatusEdit struct {
+ ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // ID of this item in the database.
+ Content string `bun:""` // Content of status at time of edit; likely html-formatted but not guaranteed.
+ ContentWarning string `bun:",nullzero"` // Content warning of status at time of edit.
+ Text string `bun:""` // Original status text, without formatting, at time of edit.
+ Language string `bun:",nullzero"` // Status language at time of edit.
+ Sensitive *bool `bun:",nullzero,notnull,default:false"` // Status sensitive flag at time of edit.
+ AttachmentIDs []string `bun:"attachments,array"` // Database IDs of media attachments associated with status at time of edit.
+ AttachmentDescriptions []string `bun:",array"` // Previous media descriptions of media attachments associated with status at time of edit.
+ PollOptions []string `bun:",array"` // Poll options of status at time of edit, only set if status contains a poll.
+ PollVotes []int `bun:",array"` // Poll vote count at time of status edit, only set if poll votes were reset.
+ StatusID string `bun:"type:CHAR(26),nullzero,notnull"` // The originating status ID this is a historical edit of.
+ CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // The creation time of this version of the status content (according to receiving server).
+
+ // We don't bother having a *gtsmodel.Status model here
+ // as the StatusEdit is always just attached to a Status,
+ // so it doesn't need a self-reference back to it.
+}
diff --git a/internal/db/bundb/migrations/20241121121623_enum_strings_to_ints.go b/internal/db/bundb/migrations/20241121121623_enum_strings_to_ints.go
index b6328c6b6..5f3eb1409 100644
--- a/internal/db/bundb/migrations/20241121121623_enum_strings_to_ints.go
+++ b/internal/db/bundb/migrations/20241121121623_enum_strings_to_ints.go
@@ -19,10 +19,8 @@
import (
"context"
- "errors"
old_gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/bundb/migrations/20241121121623_enum_strings_to_ints"
- "github.com/superseriousbusiness/gotosocial/internal/gtserror"
new_gtsmodel "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/util"
@@ -71,7 +69,9 @@ func init() {
// Before making changes to the visibility col
// we must drop all indices that rely on it.
+ log.Info(ctx, "dropping old visibility indexes...")
for _, index := range visIndices {
+ log.Info(ctx, "dropping old index %s...", index.name)
if _, err := tx.NewDropIndex().
Index(index.name).
Exec(ctx); err != nil {
@@ -91,7 +91,9 @@ func init() {
}
// Recreate the visibility indices.
+ log.Info(ctx, "creating new visibility indexes...")
for _, index := range visIndices {
+ log.Info(ctx, "creating new index %s...", index.name)
q := tx.NewCreateIndex().
Table("statuses").
Index(index.name).
@@ -128,97 +130,6 @@ func init() {
}
}
-// convertEnums performs a transaction that converts
-// a table's column of our old-style enums (strings) to
-// more performant and space-saving integer types.
-func convertEnums[OldType ~string, NewType ~int16](
- ctx context.Context,
- tx bun.Tx,
- table string,
- column string,
- mapping map[OldType]NewType,
- defaultValue *NewType,
-) error {
- if len(mapping) == 0 {
- return errors.New("empty mapping")
- }
-
- // Generate new column name.
- newColumn := column + "_new"
-
- log.Infof(ctx, "converting %s.%s enums; "+
- "this may take a while, please don't interrupt!",
- table, column,
- )
-
- // Ensure a default value.
- if defaultValue == nil {
- var zero NewType
- defaultValue = &zero
- }
-
- // Add new column to database.
- if _, err := tx.NewAddColumn().
- Table(table).
- ColumnExpr("? SMALLINT NOT NULL DEFAULT ?",
- bun.Ident(newColumn),
- *defaultValue).
- Exec(ctx); err != nil {
- return gtserror.Newf("error adding new column: %w", err)
- }
-
- // Get a count of all in table.
- total, err := tx.NewSelect().
- Table(table).
- Count(ctx)
- if err != nil {
- return gtserror.Newf("error selecting total count: %w", err)
- }
-
- var updated int
- for old, new := range mapping {
-
- // Update old to new values.
- res, err := tx.NewUpdate().
- Table(table).
- Where("? = ?", bun.Ident(column), old).
- Set("? = ?", bun.Ident(newColumn), new).
- Exec(ctx)
- if err != nil {
- return gtserror.Newf("error updating old column values: %w", err)
- }
-
- // Count number items updated.
- n, _ := res.RowsAffected()
- updated += int(n)
- }
-
- // Check total updated.
- if total != updated {
- log.Warnf(ctx, "total=%d does not match updated=%d", total, updated)
- }
-
- // Drop the old column from table.
- if _, err := tx.NewDropColumn().
- Table(table).
- ColumnExpr("?", bun.Ident(column)).
- Exec(ctx); err != nil {
- return gtserror.Newf("error dropping old column: %w", err)
- }
-
- // Rename new to old name.
- if _, err := tx.NewRaw(
- "ALTER TABLE ? RENAME COLUMN ? TO ?",
- bun.Ident(table),
- bun.Ident(newColumn),
- bun.Ident(column),
- ).Exec(ctx); err != nil {
- return gtserror.Newf("error renaming new column: %w", err)
- }
-
- return nil
-}
-
// visibilityEnumMapping maps old Visibility enum values to their newer integer type.
func visibilityEnumMapping[T ~string]() map[T]new_gtsmodel.Visibility {
return map[T]new_gtsmodel.Visibility{
diff --git a/internal/db/bundb/migrations/20241203124608_remove_media_attachment_updated_at.go b/internal/db/bundb/migrations/20241203124608_remove_media_attachment_updated_at.go
new file mode 100644
index 000000000..63e5d5f90
--- /dev/null
+++ b/internal/db/bundb/migrations/20241203124608_remove_media_attachment_updated_at.go
@@ -0,0 +1,59 @@
+// 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 .
+
+package migrations
+
+import (
+ "context"
+
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/log"
+ "github.com/uptrace/bun"
+)
+
+func init() {
+ up := func(ctx context.Context, db *bun.DB) error {
+ return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
+
+ // Check for 'updated_at' column on media attachments table, else return.
+ exists, err := doesColumnExist(ctx, tx, "media_attachments", "updated_at")
+ if err != nil {
+ return err
+ } else if !exists {
+ return nil
+ }
+
+ // Remove 'updated_at' column.
+ log.Info(ctx, "removing unused updated_at column from media attachments to save space, please wait...")
+ _, err = tx.NewDropColumn().
+ Model((*gtsmodel.MediaAttachment)(nil)).
+ Column("updated_at").
+ Exec(ctx)
+ return err
+ })
+ }
+
+ down := func(ctx context.Context, db *bun.DB) error {
+ return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
+ return nil
+ })
+ }
+
+ if err := Migrations.Register(up, down); err != nil {
+ panic(err)
+ }
+}
diff --git a/internal/db/bundb/migrations/util.go b/internal/db/bundb/migrations/util.go
index 47de09e23..edf7c1d05 100644
--- a/internal/db/bundb/migrations/util.go
+++ b/internal/db/bundb/migrations/util.go
@@ -19,11 +19,209 @@
import (
"context"
+ "errors"
+ "fmt"
+ "reflect"
+ "strconv"
+ "strings"
+ "codeberg.org/gruf/go-byteutil"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
+ "github.com/uptrace/bun/dialect/feature"
+ "github.com/uptrace/bun/dialect/sqltype"
+ "github.com/uptrace/bun/schema"
)
+// convertEnums performs a transaction that converts
+// a table's column of our old-style enums (strings) to
+// more performant and space-saving integer types.
+func convertEnums[OldType ~string, NewType ~int16](
+ ctx context.Context,
+ tx bun.Tx,
+ table string,
+ column string,
+ mapping map[OldType]NewType,
+ defaultValue *NewType,
+) error {
+ if len(mapping) == 0 {
+ return errors.New("empty mapping")
+ }
+
+ // Generate new column name.
+ newColumn := column + "_new"
+
+ log.Infof(ctx, "converting %s.%s enums; "+
+ "this may take a while, please don't interrupt!",
+ table, column,
+ )
+
+ // Ensure a default value.
+ if defaultValue == nil {
+ var zero NewType
+ defaultValue = &zero
+ }
+
+ // Add new column to database.
+ if _, err := tx.NewAddColumn().
+ Table(table).
+ ColumnExpr("? SMALLINT NOT NULL DEFAULT ?",
+ bun.Ident(newColumn),
+ *defaultValue).
+ Exec(ctx); err != nil {
+ return gtserror.Newf("error adding new column: %w", err)
+ }
+
+ // Get a count of all in table.
+ total, err := tx.NewSelect().
+ Table(table).
+ Count(ctx)
+ if err != nil {
+ return gtserror.Newf("error selecting total count: %w", err)
+ }
+
+ var updated int
+ for old, new := range mapping {
+
+ // Update old to new values.
+ res, err := tx.NewUpdate().
+ Table(table).
+ Where("? = ?", bun.Ident(column), old).
+ Set("? = ?", bun.Ident(newColumn), new).
+ Exec(ctx)
+ if err != nil {
+ return gtserror.Newf("error updating old column values: %w", err)
+ }
+
+ // Count number items updated.
+ n, _ := res.RowsAffected()
+ updated += int(n)
+ }
+
+ // Check total updated.
+ if total != updated {
+ log.Warnf(ctx, "total=%d does not match updated=%d", total, updated)
+ }
+
+ // Drop the old column from table.
+ if _, err := tx.NewDropColumn().
+ Table(table).
+ ColumnExpr("?", bun.Ident(column)).
+ Exec(ctx); err != nil {
+ return gtserror.Newf("error dropping old column: %w", err)
+ }
+
+ // Rename new to old name.
+ if _, err := tx.NewRaw(
+ "ALTER TABLE ? RENAME COLUMN ? TO ?",
+ bun.Ident(table),
+ bun.Ident(newColumn),
+ bun.Ident(column),
+ ).Exec(ctx); err != nil {
+ return gtserror.Newf("error renaming new column: %w", err)
+ }
+
+ return nil
+}
+
+// getBunColumnDef generates a column definition string for the SQL table represented by
+// Go type, with the SQL column represented by the given Go field name. This ensures when
+// adding a new column for table by migration that it will end up as bun would create it.
+//
+// NOTE: this function must stay in sync with (*bun.CreateTableQuery{}).AppendQuery(),
+// specifically where it loops over table fields appending each column definition.
+func getBunColumnDef(db bun.IDB, rtype reflect.Type, fieldName string) (string, error) {
+ d := db.Dialect()
+ f := d.Features()
+
+ // Get bun schema definitions for Go type and its field.
+ field, table, err := getModelField(db, rtype, fieldName)
+ if err != nil {
+ return "", err
+ }
+
+ // Start with reasonable buf.
+ buf := make([]byte, 0, 64)
+
+ // Start with the SQL column name.
+ buf = append(buf, field.SQLName...)
+ buf = append(buf, " "...)
+
+ // Append the SQL
+ // type information.
+ switch {
+
+ // Most of the time these two will match, but for the cases where DiscoveredSQLType is dialect-specific,
+ // e.g. pgdialect would change sqltype.SmallInt to pgTypeSmallSerial for columns that have `bun:",autoincrement"`
+ case !strings.EqualFold(field.CreateTableSQLType, field.DiscoveredSQLType):
+ buf = append(buf, field.CreateTableSQLType...)
+
+ // For all common SQL types except VARCHAR, both UserDefinedSQLType and DiscoveredSQLType specify the correct type,
+ // and we needn't modify it. For VARCHAR columns, we will stop to check if a valid length has been set in .Varchar(int).
+ case !strings.EqualFold(field.CreateTableSQLType, sqltype.VarChar):
+ buf = append(buf, field.CreateTableSQLType...)
+
+ // All else falls back
+ // to a default varchar.
+ default:
+ if d.Name() == dialect.Oracle {
+ buf = append(buf, "VARCHAR2"...)
+ } else {
+ buf = append(buf, sqltype.VarChar...)
+ }
+ buf = append(buf, "("...)
+ buf = strconv.AppendInt(buf, int64(d.DefaultVarcharLen()), 10)
+ buf = append(buf, ")"...)
+ }
+
+ // Append not null definition if field requires.
+ if field.NotNull && d.Name() != dialect.Oracle {
+ buf = append(buf, " NOT NULL"...)
+ }
+
+ // Append autoincrement definition if field requires.
+ if field.Identity && f.Has(feature.GeneratedIdentity) ||
+ (field.AutoIncrement && (f.Has(feature.AutoIncrement) || f.Has(feature.Identity))) {
+ buf = d.AppendSequence(buf, table, field)
+ }
+
+ // Append any default value.
+ if field.SQLDefault != "" {
+ buf = append(buf, " DEFAULT "...)
+ buf = append(buf, field.SQLDefault...)
+ }
+
+ return byteutil.B2S(buf), nil
+}
+
+// getModelField returns the uptrace/bun schema details for given Go type and field name.
+func getModelField(db bun.IDB, rtype reflect.Type, fieldName string) (*schema.Field, *schema.Table, error) {
+
+ // Get the associated table for Go type.
+ table := db.Dialect().Tables().Get(rtype)
+ if table == nil {
+ return nil, nil, fmt.Errorf("no table found for type: %s", rtype)
+ }
+
+ var field *schema.Field
+
+ // Look for field matching Go name.
+ for i := range table.Fields {
+ if table.Fields[i].GoName == fieldName {
+ field = table.Fields[i]
+ break
+ }
+ }
+
+ if field == nil {
+ return nil, nil, fmt.Errorf("no bun field found on %s with name: %s", rtype, fieldName)
+ }
+
+ return field, table, nil
+}
+
// doesColumnExist safely checks whether given column exists on table, handling both SQLite and PostgreSQL appropriately.
func doesColumnExist(ctx context.Context, tx bun.Tx, table, col string) (bool, error) {
var n int
diff --git a/internal/db/bundb/poll.go b/internal/db/bundb/poll.go
index b9384774b..e8c3e7e54 100644
--- a/internal/db/bundb/poll.go
+++ b/internal/db/bundb/poll.go
@@ -88,12 +88,15 @@ func (p *pollDB) getPoll(ctx context.Context, lookup string, dbQuery func(*gtsmo
func (p *pollDB) GetOpenPolls(ctx context.Context) ([]*gtsmodel.Poll, error) {
var pollIDs []string
- // Select all polls with unset `closed_at` time.
+ // Select all polls with:
+ // - UNSET `closed_at`
+ // - SET `expires_at`
if err := p.db.NewSelect().
Table("polls").
Column("polls.id").
Join("JOIN ? ON ? = ?", bun.Ident("statuses"), bun.Ident("polls.id"), bun.Ident("statuses.poll_id")).
Where("? = true", bun.Ident("statuses.local")).
+ Where("? IS NOT NULL", bun.Ident("polls.expires_at")).
Where("? IS NULL", bun.Ident("polls.closed_at")).
Scan(ctx, &pollIDs); err != nil {
return nil, err
diff --git a/internal/db/bundb/status.go b/internal/db/bundb/status.go
index 45e9864a3..fea5594dd 100644
--- a/internal/db/bundb/status.go
+++ b/internal/db/bundb/status.go
@@ -21,7 +21,6 @@
"context"
"errors"
"slices"
- "time"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
@@ -181,7 +180,7 @@ func (s *statusDB) getStatus(ctx context.Context, lookup string, dbQuery func(*g
func (s *statusDB) PopulateStatus(ctx context.Context, status *gtsmodel.Status) error {
var (
err error
- errs = gtserror.NewMultiError(9)
+ errs gtserror.MultiError
)
if status.Account == nil {
@@ -257,7 +256,7 @@ func (s *statusDB) PopulateStatus(ctx context.Context, status *gtsmodel.Status)
if !status.AttachmentsPopulated() {
// Status attachments are out-of-date with IDs, repopulate.
status.Attachments, err = s.state.DB.GetAttachmentsByIDs(
- ctx, // these are already barebones
+ gtscontext.SetBarebones(ctx),
status.AttachmentIDs,
)
if err != nil {
@@ -268,7 +267,7 @@ func (s *statusDB) PopulateStatus(ctx context.Context, status *gtsmodel.Status)
if !status.TagsPopulated() {
// Status tags are out-of-date with IDs, repopulate.
status.Tags, err = s.state.DB.GetTags(
- ctx,
+ gtscontext.SetBarebones(ctx),
status.TagIDs,
)
if err != nil {
@@ -279,7 +278,7 @@ func (s *statusDB) PopulateStatus(ctx context.Context, status *gtsmodel.Status)
if !status.MentionsPopulated() {
// Status mentions are out-of-date with IDs, repopulate.
status.Mentions, err = s.state.DB.GetMentions(
- ctx, // leave fully populated for now
+ ctx, // TODO: manually populate mentions for places expecting these populated
status.MentionIDs,
)
if err != nil {
@@ -290,7 +289,7 @@ func (s *statusDB) PopulateStatus(ctx context.Context, status *gtsmodel.Status)
if !status.EmojisPopulated() {
// Status emojis are out-of-date with IDs, repopulate.
status.Emojis, err = s.state.DB.GetEmojisByIDs(
- ctx, // these are already barebones
+ gtscontext.SetBarebones(ctx),
status.EmojiIDs,
)
if err != nil {
@@ -301,7 +300,7 @@ func (s *statusDB) PopulateStatus(ctx context.Context, status *gtsmodel.Status)
if status.CreatedWithApplicationID != "" && status.CreatedWithApplication == nil {
// Populate the status' expected CreatedWithApplication (not always set).
status.CreatedWithApplication, err = s.state.DB.GetApplicationByID(
- ctx, // these are already barebones
+ gtscontext.SetBarebones(ctx),
status.CreatedWithApplicationID,
)
if err != nil {
@@ -312,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
@@ -350,14 +366,14 @@ func (s *statusDB) PutStatus(ctx context.Context, status *gtsmodel.Status) error
}
}
- // change the status ID of the media attachments to the new status
+ // change the status ID of the media
+ // attachments to the current status
for _, a := range status.Attachments {
a.StatusID = status.ID
- a.UpdatedAt = time.Now()
if _, err := tx.
NewUpdate().
Model(a).
- Column("status_id", "updated_at").
+ Column("status_id").
Where("? = ?", bun.Ident("media_attachment.id"), a.ID).
Exec(ctx); err != nil {
if !errors.Is(err, db.ErrAlreadyExists) {
@@ -384,19 +400,15 @@ func (s *statusDB) PutStatus(ctx context.Context, status *gtsmodel.Status) error
}
// Finally, insert the status
- _, err := tx.NewInsert().Model(status).Exec(ctx)
+ _, err := tx.NewInsert().
+ Model(status).
+ Exec(ctx)
return err
})
})
}
func (s *statusDB) UpdateStatus(ctx context.Context, status *gtsmodel.Status, columns ...string) error {
- status.UpdatedAt = time.Now()
- if len(columns) > 0 {
- // If we're updating by column, ensure "updated_at" is included.
- columns = append(columns, "updated_at")
- }
-
return s.state.Caches.DB.Status.Store(status, func() error {
// It is safe to run this database transaction within cache.Store
// as the cache does not attempt a mutex lock until AFTER hook.
@@ -434,13 +446,14 @@ func (s *statusDB) UpdateStatus(ctx context.Context, status *gtsmodel.Status, co
}
}
- // change the status ID of the media attachments to the new status
+ // change the status ID of the media
+ // attachments to the current status.
for _, a := range status.Attachments {
a.StatusID = status.ID
- a.UpdatedAt = time.Now()
if _, err := tx.
NewUpdate().
Model(a).
+ Column("status_id").
Where("? = ?", bun.Ident("media_attachment.id"), a.ID).
Exec(ctx); err != nil {
if !errors.Is(err, db.ErrAlreadyExists) {
@@ -467,8 +480,7 @@ func (s *statusDB) UpdateStatus(ctx context.Context, status *gtsmodel.Status, co
}
// Finally, update the status
- _, err := tx.
- NewUpdate().
+ _, err := tx.NewUpdate().
Model(status).
Column(columns...).
Where("? = ?", bun.Ident("status.id"), status.ID).
diff --git a/internal/db/bundb/statusedit.go b/internal/db/bundb/statusedit.go
new file mode 100644
index 000000000..c932968fd
--- /dev/null
+++ b/internal/db/bundb/statusedit.go
@@ -0,0 +1,198 @@
+// 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 .
+
+package bundb
+
+import (
+ "context"
+ "errors"
+ "slices"
+
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtscontext"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/log"
+ "github.com/superseriousbusiness/gotosocial/internal/state"
+ "github.com/superseriousbusiness/gotosocial/internal/util/xslices"
+ "github.com/uptrace/bun"
+)
+
+type statusEditDB struct {
+ db *bun.DB
+ state *state.State
+}
+
+func (s *statusEditDB) GetStatusEditByID(ctx context.Context, id string) (*gtsmodel.StatusEdit, error) {
+ // Fetch edit from database cache with loader callback.
+ edit, err := s.state.Caches.DB.StatusEdit.LoadOne("ID",
+ func() (*gtsmodel.StatusEdit, error) {
+ var edit gtsmodel.StatusEdit
+
+ // Not cached, load edit
+ // from database by its ID.
+ if err := s.db.NewSelect().
+ Model(&edit).
+ Where("? = ?", bun.Ident("id"), id).
+ Scan(ctx); err != nil {
+ return nil, err
+ }
+
+ return &edit, nil
+ }, id,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ if gtscontext.Barebones(ctx) {
+ // no need to fully populate.
+ return edit, nil
+ }
+
+ // Further populate the edit fields where applicable.
+ if err := s.PopulateStatusEdit(ctx, edit); err != nil {
+ return nil, err
+ }
+
+ return edit, nil
+}
+
+func (s *statusEditDB) GetStatusEditsByIDs(ctx context.Context, ids []string) ([]*gtsmodel.StatusEdit, error) {
+ // Load status edits for IDs via cache loader callbacks.
+ edits, err := s.state.Caches.DB.StatusEdit.LoadIDs("ID",
+ ids,
+ func(uncached []string) ([]*gtsmodel.StatusEdit, error) {
+ // Preallocate expected length of uncached edits.
+ edits := make([]*gtsmodel.StatusEdit, 0, len(uncached))
+
+ // Perform database query scanning
+ // the remaining (uncached) edit IDs.
+ if err := s.db.NewSelect().
+ Model(&edits).
+ Where("? IN (?)", bun.Ident("id"), bun.In(uncached)).
+ Scan(ctx); err != nil {
+ return nil, err
+ }
+
+ return edits, nil
+ },
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ // Reorder the edits by their
+ // IDs to ensure in correct order.
+ getID := func(e *gtsmodel.StatusEdit) string { return e.ID }
+ xslices.OrderBy(edits, ids, getID)
+
+ if gtscontext.Barebones(ctx) {
+ // no need to fully populate.
+ return edits, nil
+ }
+
+ // Populate all loaded edits, removing those we fail to
+ // populate (removes needing so many nil checks everywhere).
+ edits = slices.DeleteFunc(edits, func(edit *gtsmodel.StatusEdit) bool {
+ if err := s.PopulateStatusEdit(ctx, edit); err != nil {
+ log.Errorf(ctx, "error populating edit %s: %v", edit.ID, err)
+ return true
+ }
+ return false
+ })
+
+ return edits, nil
+}
+
+func (s *statusEditDB) PopulateStatusEdit(ctx context.Context, edit *gtsmodel.StatusEdit) error {
+ var err error
+ var errs gtserror.MultiError
+
+ // For sub-models we only want
+ // barebones versions of them.
+ ctx = gtscontext.SetBarebones(ctx)
+
+ if !edit.AttachmentsPopulated() {
+ // Fetch all attachments for status edit's IDs.
+ edit.Attachments, err = s.state.DB.GetAttachmentsByIDs(
+ ctx,
+ edit.AttachmentIDs,
+ )
+ if err != nil {
+ errs.Appendf("error populating edit attachments: %w", err)
+ }
+ }
+
+ return errs.Combine()
+}
+
+func (s *statusEditDB) PutStatusEdit(ctx context.Context, edit *gtsmodel.StatusEdit) error {
+ return s.state.Caches.DB.StatusEdit.Store(edit, func() error {
+ _, err := s.db.NewInsert().Model(edit).Exec(ctx)
+ return err
+ })
+}
+
+func (s *statusEditDB) DeleteStatusEdits(ctx context.Context, ids []string) error {
+ // Gather necessary fields from
+ // deleted for cache invalidation.
+ deleted := make([]*gtsmodel.StatusEdit, 0, len(ids))
+
+ // Delete all edits with IDs pertaining
+ // to given slice, returning status IDs.
+ if _, err := s.db.NewDelete().
+ Model(&deleted).
+ Where("? IN (?)", bun.Ident("id"), bun.In(ids)).
+ Returning("?", bun.Ident("status_id")).
+ Exec(ctx); err != nil &&
+ !errors.Is(err, db.ErrNoEntries) {
+ return err
+ }
+
+ // Check for no deletes.
+ if len(deleted) == 0 {
+ return nil
+ }
+
+ // Invalidate all the cached status edits with IDs.
+ s.state.Caches.DB.StatusEdit.InvalidateIDs("ID", ids)
+
+ // With each invalidate hook mark status ID of
+ // edit we just called for. We only want to call
+ // invalidate hooks of edits from unique statuses.
+ invalidated := make(map[string]struct{}, 1)
+
+ // Invalidate the first delete manually, this
+ // opt negates need for initial hashmap lookup.
+ s.state.Caches.OnInvalidateStatusEdit(deleted[0])
+ invalidated[deleted[0].StatusID] = struct{}{}
+
+ for _, edit := range deleted {
+ // Check not already called for status.
+ _, ok := invalidated[edit.StatusID]
+ if ok {
+ continue
+ }
+
+ // Manually call status edit invalidate hook.
+ s.state.Caches.OnInvalidateStatusEdit(edit)
+ invalidated[edit.StatusID] = struct{}{}
+ }
+
+ return nil
+}
diff --git a/internal/db/bundb/statusedit_test.go b/internal/db/bundb/statusedit_test.go
new file mode 100644
index 000000000..b6a15e825
--- /dev/null
+++ b/internal/db/bundb/statusedit_test.go
@@ -0,0 +1,168 @@
+// 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 .
+
+package bundb_test
+
+import (
+ "context"
+ "errors"
+ "reflect"
+ "slices"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+)
+
+type StatusEditTestSuite struct {
+ BunDBStandardTestSuite
+}
+
+func (suite *StatusEditTestSuite) TestGetStatusEditBy() {
+ t := suite.T()
+
+ // Create a new context for this test.
+ ctx, cncl := context.WithCancel(context.Background())
+ defer cncl()
+
+ // Sentinel error to mark avoiding a test case.
+ sentinelErr := errors.New("sentinel")
+
+ for _, edit := range suite.testStatusEdits {
+ for lookup, dbfunc := range map[string]func() (*gtsmodel.StatusEdit, error){
+ "id": func() (*gtsmodel.StatusEdit, error) {
+ return suite.db.GetStatusEditByID(ctx, edit.ID)
+ },
+ } {
+ // Clear database caches.
+ suite.state.Caches.Init()
+
+ t.Logf("checking database lookup %q", lookup)
+
+ // Perform database function.
+ checkEdit, err := dbfunc()
+ if err != nil {
+ if err == sentinelErr {
+ continue
+ }
+
+ t.Errorf("error encountered for database lookup %q: %v", lookup, err)
+ continue
+ }
+
+ // Check received account data.
+ if !areEditsEqual(edit, checkEdit) {
+ t.Errorf("edit does not contain expected data: %+v", checkEdit)
+ continue
+ }
+ }
+ }
+}
+
+func (suite *StatusEditTestSuite) TestGetStatusEditsByIDs() {
+ t := suite.T()
+
+ // Create a new context for this test.
+ ctx, cncl := context.WithCancel(context.Background())
+ defer cncl()
+
+ // editsByStatus returns all test edits by the given status with ID.
+ editsByStatus := func(status *gtsmodel.Status) []*gtsmodel.StatusEdit {
+ var edits []*gtsmodel.StatusEdit
+ for _, edit := range suite.testStatusEdits {
+ if edit.StatusID == status.ID {
+ edits = append(edits, edit)
+ }
+ }
+ return edits
+ }
+
+ for _, status := range suite.testStatuses {
+ // Get test status edit models
+ // that should be found for status.
+ check := editsByStatus(status)
+
+ // Fetch edits for the slice of IDs attached to status from database.
+ edits, err := suite.state.DB.GetStatusEditsByIDs(ctx, status.EditIDs)
+ suite.NoError(err)
+
+ // Ensure both slices
+ // sorted the same.
+ sortEdits(check)
+ sortEdits(edits)
+
+ // Check whether slices of status edits match.
+ if !slices.EqualFunc(check, edits, areEditsEqual) {
+ t.Error("status edit slices do not match")
+ }
+ }
+}
+
+func (suite *StatusEditTestSuite) TestDeleteStatusEdits() {
+ // Create a new context for this test.
+ ctx, cncl := context.WithCancel(context.Background())
+ defer cncl()
+
+ for _, status := range suite.testStatuses {
+ // Delete all edits for status with given IDs from database.
+ err := suite.state.DB.DeleteStatusEdits(ctx, status.EditIDs)
+ suite.NoError(err)
+
+ // Now attempt to fetch these edits from database, should be empty.
+ edits, err := suite.state.DB.GetStatusEditsByIDs(ctx, status.EditIDs)
+ suite.NoError(err)
+ suite.Empty(edits)
+ }
+}
+
+func TestStatusEditTestSuite(t *testing.T) {
+ suite.Run(t, new(StatusEditTestSuite))
+}
+
+func areEditsEqual(e1, e2 *gtsmodel.StatusEdit) bool {
+ // Clone the 1st status edit.
+ e1Copy := new(gtsmodel.StatusEdit)
+ *e1Copy = *e1
+ e1 = e1Copy
+
+ // Clone the 2nd status edit.
+ e2Copy := new(gtsmodel.StatusEdit)
+ *e2Copy = *e2
+ e2 = e2Copy
+
+ // Clear populated sub-models.
+ e1.Attachments = nil
+ e2.Attachments = nil
+
+ // Clear database-set fields.
+ e1.CreatedAt = time.Time{}
+ e2.CreatedAt = time.Time{}
+
+ return reflect.DeepEqual(*e1, *e2)
+}
+
+func sortEdits(edits []*gtsmodel.StatusEdit) {
+ slices.SortFunc(edits, func(a, b *gtsmodel.StatusEdit) int {
+ if a.CreatedAt.Before(b.CreatedAt) {
+ return +1
+ } else if b.CreatedAt.Before(a.CreatedAt) {
+ return -1
+ }
+ return 0
+ })
+}
diff --git a/internal/db/bundb/timeline.go b/internal/db/bundb/timeline.go
index bcb7953d4..fcea0178a 100644
--- a/internal/db/bundb/timeline.go
+++ b/internal/db/bundb/timeline.go
@@ -123,13 +123,8 @@ func (t *timelineDB) GetHomeTimeline(ctx context.Context, accountID string, maxI
if maxID == "" || maxID >= id.Highest {
const future = 24 * time.Hour
- var err error
-
// don't return statuses more than 24hr in the future
- maxID, err = id.NewULIDFromTime(time.Now().Add(future))
- if err != nil {
- return nil, err
- }
+ maxID = id.NewULIDFromTime(time.Now().Add(future))
}
// return only statuses LOWER (ie., older) than maxID
@@ -223,13 +218,8 @@ func (t *timelineDB) GetPublicTimeline(ctx context.Context, maxID string, sinceI
if maxID == "" || maxID >= id.Highest {
const future = 24 * time.Hour
- var err error
-
// don't return statuses more than 24hr in the future
- maxID, err = id.NewULIDFromTime(time.Now().Add(future))
- if err != nil {
- return nil, err
- }
+ maxID = id.NewULIDFromTime(time.Now().Add(future))
}
// return only statuses LOWER (ie., older) than maxID
@@ -409,13 +399,8 @@ func (t *timelineDB) GetListTimeline(
if maxID == "" || maxID >= id.Highest {
const future = 24 * time.Hour
- var err error
-
// don't return statuses more than 24hr in the future
- maxID, err = id.NewULIDFromTime(time.Now().Add(future))
- if err != nil {
- return nil, err
- }
+ maxID = id.NewULIDFromTime(time.Now().Add(future))
}
// return only statuses LOWER (ie., older) than maxID
@@ -508,13 +493,8 @@ func (t *timelineDB) GetTagTimeline(
if maxID == "" || maxID >= id.Highest {
const future = 24 * time.Hour
- var err error
-
// don't return statuses more than 24hr in the future
- maxID, err = id.NewULIDFromTime(time.Now().Add(future))
- if err != nil {
- return nil, err
- }
+ maxID = id.NewULIDFromTime(time.Now().Add(future))
}
// return only statuses LOWER (ie., older) than maxID
diff --git a/internal/db/bundb/timeline_test.go b/internal/db/bundb/timeline_test.go
index 00df2b3a6..75a335512 100644
--- a/internal/db/bundb/timeline_test.go
+++ b/internal/db/bundb/timeline_test.go
@@ -37,10 +37,7 @@ type TimelineTestSuite struct {
func getFutureStatus() *gtsmodel.Status {
theDistantFuture := time.Now().Add(876600 * time.Hour)
- id, err := id.NewULIDFromTime(theDistantFuture)
- if err != nil {
- panic(err)
- }
+ id := id.NewULIDFromTime(theDistantFuture)
return >smodel.Status{
ID: id,
@@ -182,7 +179,7 @@ func (suite *TimelineTestSuite) TestGetHomeTimelineIgnoreExclusive() {
if err != nil {
suite.FailNow(err.Error())
}
- suite.checkStatuses(s, id.Highest, id.Lowest, 8)
+ suite.checkStatuses(s, id.Highest, id.Lowest, 9)
// Remove admin account from the exclusive list.
listEntry := suite.testListEntries["local_account_1_list_1_entry_2"]
@@ -196,7 +193,7 @@ func (suite *TimelineTestSuite) TestGetHomeTimelineIgnoreExclusive() {
if err != nil {
suite.FailNow(err.Error())
}
- suite.checkStatuses(s, id.Highest, id.Lowest, 12)
+ suite.checkStatuses(s, id.Highest, id.Lowest, 13)
}
func (suite *TimelineTestSuite) TestGetHomeTimelineNoFollowing() {
@@ -228,7 +225,7 @@ func (suite *TimelineTestSuite) TestGetHomeTimelineNoFollowing() {
suite.FailNow(err.Error())
}
- suite.checkStatuses(s, id.Highest, id.Lowest, 8)
+ suite.checkStatuses(s, id.Highest, id.Lowest, 9)
}
func (suite *TimelineTestSuite) TestGetHomeTimelineWithFutureStatus() {
@@ -281,8 +278,8 @@ func (suite *TimelineTestSuite) TestGetHomeTimelineFromHighest() {
}
suite.checkStatuses(s, id.Highest, id.Lowest, 5)
- suite.Equal("01J2M1HPFSS54S60Y0KYV23KJE", s[0].ID)
- suite.Equal("01G36SF3V6Y6V5BF9P4R7PQG7G", s[len(s)-1].ID)
+ suite.Equal("01JDPZEZ77X1NX0TY9M10BK1HM", s[0].ID)
+ suite.Equal("01HEN2RZ8BG29Y5Z9VJC73HZW7", s[len(s)-1].ID)
}
func (suite *TimelineTestSuite) TestGetListTimelineNoParams() {
@@ -296,7 +293,7 @@ func (suite *TimelineTestSuite) TestGetListTimelineNoParams() {
suite.FailNow(err.Error())
}
- suite.checkStatuses(s, id.Highest, id.Lowest, 12)
+ suite.checkStatuses(s, id.Highest, id.Lowest, 13)
}
func (suite *TimelineTestSuite) TestGetListTimelineMaxID() {
@@ -311,8 +308,8 @@ func (suite *TimelineTestSuite) TestGetListTimelineMaxID() {
}
suite.checkStatuses(s, id.Highest, id.Lowest, 5)
- suite.Equal("01HEN2PRXT0TF4YDRA64FZZRN7", s[0].ID)
- suite.Equal("01FF25D5Q0DH7CHD57CTRS6WK0", s[len(s)-1].ID)
+ suite.Equal("01JDPZEZ77X1NX0TY9M10BK1HM", s[0].ID)
+ suite.Equal("01FN3VJGFH10KR7S2PB0GFJZYG", s[len(s)-1].ID)
}
func (suite *TimelineTestSuite) TestGetListTimelineMinID() {
diff --git a/internal/db/db.go b/internal/db/db.go
index b7e2b29bd..16796ae49 100644
--- a/internal/db/db.go
+++ b/internal/db/db.go
@@ -51,6 +51,7 @@ type DB interface {
SinBinStatus
Status
StatusBookmark
+ StatusEdit
StatusFave
Tag
Thread
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/db/statusedit.go b/internal/db/statusedit.go
new file mode 100644
index 000000000..32e770fb9
--- /dev/null
+++ b/internal/db/statusedit.go
@@ -0,0 +1,43 @@
+// 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 .
+
+package db
+
+import (
+ "context"
+
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+)
+
+type StatusEdit interface {
+
+ // GetStatusEditByID fetches the StatusEdit with given ID from the database.
+ GetStatusEditByID(ctx context.Context, id string) (*gtsmodel.StatusEdit, error)
+
+ // GetStatusEditsByIDs fetches all StatusEdits with given IDs from database,
+ // this is optimized and faster than multiple calls to GetStatusEditByID.
+ GetStatusEditsByIDs(ctx context.Context, ids []string) ([]*gtsmodel.StatusEdit, error)
+
+ // PopulateStatusEdit ensures the given StatusEdit's sub-models are populated.
+ PopulateStatusEdit(ctx context.Context, edit *gtsmodel.StatusEdit) error
+
+ // PutStatusEdit inserts the given new StatusEdit into the database.
+ PutStatusEdit(ctx context.Context, edit *gtsmodel.StatusEdit) error
+
+ // DeleteStatusEdits deletes the StatusEdits with given IDs from the database.
+ DeleteStatusEdits(ctx context.Context, ids []string) error
+}
diff --git a/internal/federation/dereferencing/announce.go b/internal/federation/dereferencing/announce.go
index a3eaf199d..eb949f159 100644
--- a/internal/federation/dereferencing/announce.go
+++ b/internal/federation/dereferencing/announce.go
@@ -87,7 +87,7 @@ func (d *Dereferencer) EnrichAnnounce(
boost.Federated = target.Federated
// Ensure this Announce is permitted by the Announcee.
- permit, err := d.isPermittedStatus(ctx, requestUser, nil, boost)
+ permit, err := d.isPermittedStatus(ctx, requestUser, nil, boost, true)
if err != nil {
return nil, gtserror.Newf("error checking permitted status %s: %w", boost.URI, err)
}
@@ -99,10 +99,7 @@ func (d *Dereferencer) EnrichAnnounce(
}
// Generate an ID for the boost wrapper status.
- boost.ID, err = id.NewULIDFromTime(boost.CreatedAt)
- if err != nil {
- return nil, gtserror.Newf("error generating id: %w", err)
- }
+ boost.ID = id.NewULIDFromTime(boost.CreatedAt)
// Store the boost wrapper status in database.
switch err = d.state.DB.PutStatus(ctx, boost); {
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 @@
// 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 @@
//
// 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/media.go b/internal/federation/dereferencing/media.go
index 3bed4b198..d22eeb237 100644
--- a/internal/federation/dereferencing/media.go
+++ b/internal/federation/dereferencing/media.go
@@ -128,6 +128,7 @@ func (d *Dereferencer) RefreshMedia(
// Check emoji is up-to-date
// with provided extra info.
switch {
+ case force:
case info.Blurhash != nil &&
*info.Blurhash != attach.Blurhash:
attach.Blurhash = *info.Blurhash
diff --git a/internal/federation/dereferencing/status.go b/internal/federation/dereferencing/status.go
index c90730826..223389ad7 100644
--- a/internal/federation/dereferencing/status.go
+++ b/internal/federation/dereferencing/status.go
@@ -35,6 +35,7 @@
"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
@@ -302,6 +303,7 @@ func (d *Dereferencer) enrichStatusSafely(
uri,
status,
statusable,
+ isNew,
)
// Check for a returned HTTP code via error.
@@ -374,6 +376,7 @@ func (d *Dereferencer) enrichStatus(
uri *url.URL,
status *gtsmodel.Status,
statusable ap.Statusable,
+ isNew bool,
) (
*gtsmodel.Status,
ap.Statusable,
@@ -476,8 +479,7 @@ func (d *Dereferencer) enrichStatus(
// Ensure the final parsed status URI or URL matches
// the input URI we fetched (or received) it as.
- matches, err := util.URIMatches(
- uri,
+ matches, err := util.URIMatches(uri,
append(
ap.GetURL(statusable), // status URL(s)
ap.GetJSONLDId(statusable), // status URI
@@ -497,21 +499,18 @@ func (d *Dereferencer) enrichStatus(
)
}
- var isNew bool
-
- // Based on the original provided
- // status model, determine whether
- // this is a new insert / update.
- if isNew = (status.ID == ""); isNew {
+ if isNew {
// Generate new status ID from the provided creation date.
- latestStatus.ID, err = id.NewULIDFromTime(latestStatus.CreatedAt)
- if err != nil {
- log.Errorf(ctx, "invalid created at date (falling back to 'now'): %v", err)
- latestStatus.ID = id.NewULID() // just use "now"
- }
+ latestStatus.ID = id.NewULIDFromTime(latestStatus.CreatedAt)
} else {
+ // Ensure that status isn't trying to re-date itself.
+ if !latestStatus.CreatedAt.Equal(status.CreatedAt) {
+ err := gtserror.Newf("status %s 'published' changed", uri)
+ return nil, nil, gtserror.SetMalformed(err)
+ }
+
// Reuse existing status ID.
latestStatus.ID = status.ID
}
@@ -519,7 +518,6 @@ func (d *Dereferencer) enrichStatus(
// Set latest fetch time and carry-
// over some values from "old" status.
latestStatus.FetchedAt = time.Now()
- latestStatus.UpdatedAt = status.UpdatedAt
latestStatus.Local = status.Local
latestStatus.PinnedAt = status.PinnedAt
@@ -538,8 +536,9 @@ func (d *Dereferencer) enrichStatus(
}
// Check if this is a permitted status we should accept.
- // Function also sets "PendingApproval" bool as necessary.
- permit, err := d.isPermittedStatus(ctx, requestUser, status, latestStatus)
+ // Function also sets "PendingApproval" bool as necessary,
+ // and handles removal of existing statuses no longer permitted.
+ permit, err := d.isPermittedStatus(ctx, requestUser, status, latestStatus, isNew)
if err != nil {
return nil, nil, gtserror.Newf("error checking permissibility for status %s: %w", uri, err)
}
@@ -550,59 +549,113 @@ func (d *Dereferencer) enrichStatus(
return nil, nil, gtserror.SetNotPermitted(err)
}
- // Ensure the status' mentions are populated, and pass in existing to check for changes.
- if err := d.fetchStatusMentions(ctx, requestUser, status, latestStatus); err != nil {
+ // Insert / update any attached status poll.
+ pollChanged, err := d.handleStatusPoll(ctx,
+ status,
+ latestStatus,
+ )
+ if err != nil {
+ return nil, nil, gtserror.Newf("error handling poll for status %s: %w", uri, err)
+ }
+
+ // Populate mentions associated with status, passing
+ // in existing status to reuse old where possible.
+ // (especially important here to reduce need to dereference).
+ mentionsChanged, err := d.fetchStatusMentions(ctx,
+ requestUser,
+ status,
+ latestStatus,
+ )
+ if err != nil {
return nil, nil, gtserror.Newf("error populating mentions for status %s: %w", uri, err)
}
- // Ensure the status' poll remains consistent, else reset the poll.
- if err := d.fetchStatusPoll(ctx, status, latestStatus); err != nil {
- return nil, nil, gtserror.Newf("error populating poll for status %s: %w", uri, err)
+ // Ensure status in a thread is connected.
+ threadChanged, err := d.threadStatus(ctx,
+ status,
+ latestStatus,
+ )
+ if err != nil {
+ return nil, nil, gtserror.Newf("error handling threading for status %s: %w", uri, err)
}
- // Now that we know who this status replies to (handled by ASStatusToStatus)
- // and who it mentions, we can add a ThreadID to it if necessary.
- if err := d.threadStatus(ctx, latestStatus); err != nil {
- return nil, nil, gtserror.Newf("error checking / creating threadID for status %s: %w", uri, err)
- }
-
- // Ensure the status' tags are populated, (changes are expected / okay).
- if err := d.fetchStatusTags(ctx, status, latestStatus); err != nil {
+ // Populate tags associated with status, passing
+ // in existing status to reuse old where possible.
+ tagsChanged, err := d.fetchStatusTags(ctx,
+ status,
+ latestStatus,
+ )
+ if err != nil {
return nil, nil, gtserror.Newf("error populating tags for status %s: %w", uri, err)
}
- // Ensure the status' media attachments are populated, passing in existing to check for changes.
- if err := d.fetchStatusAttachments(ctx, requestUser, status, latestStatus); err != nil {
+ // Populate media attachments associated with status,
+ // passing in existing status to reuse old where possible
+ // (especially important here to reduce need to dereference).
+ mediaChanged, err := d.fetchStatusAttachments(ctx,
+ requestUser,
+ status,
+ latestStatus,
+ )
+ if err != nil {
return nil, nil, gtserror.Newf("error populating attachments for status %s: %w", uri, err)
}
- // Ensure the status' emoji attachments are populated, passing in existing to check for changes.
- if err := d.fetchStatusEmojis(ctx, status, latestStatus); err != nil {
+ // Populate emoji associated with status, passing
+ // in existing status to reuse old where possible
+ // (especially important here to reduce need to dereference).
+ emojiChanged, err := d.fetchStatusEmojis(ctx,
+ status,
+ latestStatus,
+ )
+ if err != nil {
return nil, nil, gtserror.Newf("error populating emojis for status %s: %w", uri, err)
}
if isNew {
- // This is new, put the status in the database.
- err := d.state.DB.PutStatus(ctx, latestStatus)
- if err != nil {
- return nil, nil, gtserror.Newf("error putting in database: %w", err)
+ // Simplest case, insert this new status into the database.
+ if err := d.state.DB.PutStatus(ctx, latestStatus); err != nil {
+ return nil, nil, gtserror.Newf("error inserting new status %s: %w", uri, err)
}
} else {
- // This is an existing status, update the model in the database.
- if err := d.state.DB.UpdateStatus(ctx, latestStatus); err != nil {
- return nil, nil, gtserror.Newf("error updating database: %w", err)
+ // Check for and handle any edits to status, inserting
+ // historical edit if necessary. Also determines status
+ // columns that need updating in below query.
+ cols, err := d.handleStatusEdit(ctx,
+ status,
+ latestStatus,
+ pollChanged,
+ mentionsChanged,
+ threadChanged,
+ tagsChanged,
+ mediaChanged,
+ emojiChanged,
+ )
+ if err != nil {
+ return nil, nil, gtserror.Newf("error handling edit for status %s: %w", uri, err)
+ }
+
+ // With returned changed columns, now update the existing status entry.
+ if err := d.state.DB.UpdateStatus(ctx, latestStatus, cols...); err != nil {
+ return nil, nil, gtserror.Newf("error updating existing status %s: %w", uri, err)
}
}
return latestStatus, statusable, nil
}
+// fetchStatusMentions populates the mentions on 'status', creating
+// new where needed, or using unchanged mentions from 'existing' status.
func (d *Dereferencer) fetchStatusMentions(
ctx context.Context,
requestUser string,
existing *gtsmodel.Status,
status *gtsmodel.Status,
-) error {
+) (
+ changed bool,
+ err error,
+) {
+
// Allocate new slice to take the yet-to-be created mention IDs.
status.MentionIDs = make([]string, len(status.Mentions))
@@ -610,7 +663,6 @@ func (d *Dereferencer) fetchStatusMentions(
var (
mention = status.Mentions[i]
alreadyExists bool
- err error
)
// Search existing status for a mention already stored,
@@ -633,19 +685,16 @@ func (d *Dereferencer) fetchStatusMentions(
continue
}
+ // Mark status as
+ // having changed.
+ changed = true
+
// This mention didn't exist yet.
- // Generate new ID according to status creation.
- // TODO: update this to use "edited_at" when we add
- // support for edited status revision history.
- mention.ID, err = id.NewULIDFromTime(status.CreatedAt)
- if err != nil {
- log.Errorf(ctx, "invalid created at date (falling back to 'now'): %v", err)
- mention.ID = id.NewULID() // just use "now"
- }
+ // Generate new ID according to latest update.
+ mention.ID = id.NewULIDFromTime(status.UpdatedAt)
// Set known further mention details.
- mention.CreatedAt = status.CreatedAt
- mention.UpdatedAt = status.UpdatedAt
+ mention.CreatedAt = status.UpdatedAt
mention.OriginAccount = status.Account
mention.OriginAccountID = status.AccountID
mention.OriginAccountURI = status.AccountURI
@@ -657,7 +706,7 @@ func (d *Dereferencer) fetchStatusMentions(
// Place the new mention into the database.
if err := d.state.DB.PutMention(ctx, mention); err != nil {
- return gtserror.Newf("error putting mention in database: %w", err)
+ return changed, gtserror.Newf("error putting mention in database: %w", err)
}
// Set the *new* mention and ID.
@@ -678,17 +727,42 @@ func (d *Dereferencer) fetchStatusMentions(
i++
}
- return nil
+ return changed, nil
}
-func (d *Dereferencer) threadStatus(ctx context.Context, status *gtsmodel.Status) error {
- if status.InReplyTo != nil {
- if parentThreadID := status.InReplyTo.ThreadID; parentThreadID != "" {
- // Simplest case: parent status
- // is threaded, so inherit threadID.
- status.ThreadID = parentThreadID
- return nil
+// threadStatus ensures that given status is threaded correctly
+// where necessary. that is it will inherit a thread ID from the
+// existing copy if it is threaded correctly, else it will inherit
+// a thread ID from a parent with existing thread, else it will
+// generate a new thread ID if status mentions a local account.
+func (d *Dereferencer) threadStatus(
+ ctx context.Context,
+ existing *gtsmodel.Status,
+ status *gtsmodel.Status,
+) (
+ changed bool,
+ err error,
+) {
+
+ // Check for existing status
+ // that is already threaded.
+ if existing.ThreadID != "" {
+
+ // Existing is threaded correctly.
+ if existing.InReplyTo == nil ||
+ existing.InReplyTo.ThreadID == existing.ThreadID {
+ status.ThreadID = existing.ThreadID
+ return false, nil
}
+
+ // TODO: delete incorrect thread
+ }
+
+ // Check for existing parent to inherit threading from.
+ if inReplyTo := status.InReplyTo; inReplyTo != nil &&
+ inReplyTo.ThreadID != "" {
+ status.ThreadID = inReplyTo.ThreadID
+ return true, nil
}
// Parent wasn't threaded. If this
@@ -711,7 +785,7 @@ func(m *gtsmodel.Mention) bool {
// Status doesn't mention a
// local account, so we don't
// need to thread it.
- return nil
+ return false, nil
}
// Status mentions a local account.
@@ -719,24 +793,30 @@ func(m *gtsmodel.Mention) bool {
// it to the status.
threadID := id.NewULID()
- if err := d.state.DB.PutThread(
- ctx,
- >smodel.Thread{
- ID: threadID,
- },
+ // Insert new thread model into db.
+ if err := d.state.DB.PutThread(ctx,
+ >smodel.Thread{ID: threadID},
); err != nil {
- return gtserror.Newf("error inserting new thread in db: %w", err)
+ return false, gtserror.Newf("error inserting new thread in db: %w", err)
}
+ // Set thread on latest status.
status.ThreadID = threadID
- return nil
+ return true, nil
}
+// fetchStatusTags populates the tags on 'status', fetching existing
+// from the database and creating new where needed. 'existing' is used
+// to fetch tags that have not changed since previous stored status.
func (d *Dereferencer) fetchStatusTags(
ctx context.Context,
existing *gtsmodel.Status,
status *gtsmodel.Status,
-) error {
+) (
+ changed bool,
+ err error,
+) {
+
// Allocate new slice to take the yet-to-be determined tag IDs.
status.TagIDs = make([]string, len(status.Tags))
@@ -751,10 +831,14 @@ func (d *Dereferencer) fetchStatusTags(
continue
}
+ // Mark status as
+ // having changed.
+ changed = true
+
// Look for existing tag with name in the database.
existing, err := d.state.DB.GetTagByName(ctx, tag.Name)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
- return gtserror.Newf("db error getting tag %s: %w", tag.Name, err)
+ return changed, gtserror.Newf("db error getting tag %s: %w", tag.Name, err)
} else if existing != nil {
status.Tags[i] = existing
status.TagIDs[i] = existing.ID
@@ -788,106 +872,21 @@ func (d *Dereferencer) fetchStatusTags(
i++
}
- return nil
-}
-
-func (d *Dereferencer) fetchStatusPoll(
- ctx context.Context,
- existing *gtsmodel.Status,
- status *gtsmodel.Status,
-) error {
- var (
- // insertStatusPoll generates ID and inserts the poll attached to status into the database.
- insertStatusPoll = func(ctx context.Context, status *gtsmodel.Status) error {
- var err error
-
- // Generate new ID for poll from the status CreatedAt.
- // TODO: update this to use "edited_at" when we add
- // support for edited status revision history.
- status.Poll.ID, err = id.NewULIDFromTime(status.CreatedAt)
- if err != nil {
- log.Errorf(ctx, "invalid created at date (falling back to 'now'): %v", err)
- status.Poll.ID = id.NewULID() // just use "now"
- }
-
- // Update the status<->poll links.
- status.PollID = status.Poll.ID
- status.Poll.StatusID = status.ID
- status.Poll.Status = status
-
- // Insert this latest poll into the database.
- err = d.state.DB.PutPoll(ctx, status.Poll)
- if err != nil {
- return gtserror.Newf("error putting in database: %w", err)
- }
-
- return nil
- }
-
- // deleteStatusPoll deletes the poll with ID, and all attached votes, from the database.
- deleteStatusPoll = func(ctx context.Context, pollID string) error {
- if err := d.state.DB.DeletePollByID(ctx, pollID); err != nil {
- return gtserror.Newf("error deleting existing poll from database: %w", err)
- }
- return nil
- }
- )
-
- switch {
- case existing.Poll == nil && status.Poll == nil:
- // no poll before or after, nothing to do.
- return nil
-
- case existing.Poll == nil && status.Poll != nil:
- // no previous poll, insert new poll!
- return insertStatusPoll(ctx, status)
-
- case status.Poll == nil:
- // existing poll has been deleted, remove this.
- return deleteStatusPoll(ctx, existing.PollID)
-
- case pollChanged(existing.Poll, status.Poll):
- // poll has changed since original, delete and reinsert new.
- if err := deleteStatusPoll(ctx, existing.PollID); err != nil {
- return err
- }
- return insertStatusPoll(ctx, status)
-
- case pollUpdated(existing.Poll, status.Poll):
- // Since we last saw it, the poll has updated!
- // Whether that be stats, or close time.
- poll := existing.Poll
- poll.Closing = pollJustClosed(existing.Poll, status.Poll)
- poll.ClosedAt = status.Poll.ClosedAt
- poll.Voters = status.Poll.Voters
- poll.Votes = status.Poll.Votes
-
- // Update poll model in the database (specifically only the possible changed columns).
- if err := d.state.DB.UpdatePoll(ctx, poll, "closed_at", "voters", "votes"); err != nil {
- return gtserror.Newf("error updating poll: %w", err)
- }
-
- // Update poll on status.
- status.PollID = poll.ID
- status.Poll = poll
- return nil
-
- default:
- // latest and existing
- // polls are up to date.
- poll := existing.Poll
- status.PollID = poll.ID
- status.Poll = poll
- return nil
- }
+ return changed, nil
}
+// fetchStatusAttachments populates the attachments on 'status', creating new database
+// entries where needed and dereferencing it, or using unchanged from 'existing' status.
func (d *Dereferencer) fetchStatusAttachments(
ctx context.Context,
requestUser string,
existing *gtsmodel.Status,
status *gtsmodel.Status,
-) error {
+) (
+ changed bool,
+ err error,
+) {
+
// Allocate new slice to take the yet-to-be fetched attachment IDs.
status.AttachmentIDs = make([]string, len(status.Attachments))
@@ -897,9 +896,26 @@ func (d *Dereferencer) fetchStatusAttachments(
// Look for existing media attachment with remote URL first.
existing, ok := existing.GetAttachmentByRemoteURL(placeholder.RemoteURL)
if ok && existing.ID != "" {
+ var info media.AdditionalMediaInfo
- // Ensure the existing media attachment is up-to-date and cached.
- existing, err := d.updateAttachment(ctx, requestUser, existing, placeholder)
+ // Look for any difference in stored media description.
+ diff := (existing.Description != placeholder.Description)
+ if diff {
+ info.Description = &placeholder.Description
+ }
+
+ // If description changed,
+ // we mark media as changed.
+ changed = changed || diff
+
+ // Store any attachment updates and
+ // ensure media is locally cached.
+ existing, err := d.RefreshMedia(ctx,
+ requestUser,
+ existing,
+ info,
+ diff,
+ )
if err != nil {
log.Errorf(ctx, "error updating existing attachment: %v", err)
@@ -915,9 +931,12 @@ func (d *Dereferencer) fetchStatusAttachments(
continue
}
+ // Mark status as
+ // having changed.
+ changed = true
+
// Load this new media attachment.
- attachment, err := d.GetMedia(
- ctx,
+ attachment, err := d.GetMedia(ctx,
requestUser,
status.AccountID,
placeholder.RemoteURL,
@@ -955,42 +974,316 @@ func (d *Dereferencer) fetchStatusAttachments(
i++
}
- return nil
+ return changed, nil
}
+// fetchStatusEmojis populates the emojis on 'status', creating new database entries
+// where needed and dereferencing it, or using unchanged from 'existing' status.
func (d *Dereferencer) fetchStatusEmojis(
ctx context.Context,
existing *gtsmodel.Status,
status *gtsmodel.Status,
-) error {
+) (
+ changed bool,
+ err error,
+) {
+
// Fetch the updated emojis for our status.
emojis, changed, err := d.fetchEmojis(ctx,
existing.Emojis,
status.Emojis,
)
if err != nil {
- return gtserror.Newf("error fetching emojis: %w", err)
+ return changed, gtserror.Newf("error fetching emojis: %w", err)
}
if !changed {
// Use existing status emoji objects.
status.EmojiIDs = existing.EmojiIDs
status.Emojis = existing.Emojis
- return nil
+ return false, nil
}
// 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
+}
+
+// handleStatusPoll handles both inserting of new status poll or the
+// update of an existing poll. this handles the case of simple vote
+// count updates (without being classified as a change of the poll
+// itself), as well as full poll changes that delete existing instance.
+func (d *Dereferencer) handleStatusPoll(
+ ctx context.Context,
+ existing *gtsmodel.Status,
+ status *gtsmodel.Status,
+) (
+ changed bool,
+ err error,
+) {
+ switch {
+ case existing.Poll == nil && status.Poll == nil:
+ // no poll before or after, nothing to do.
+ return false, nil
+
+ case existing.Poll == nil && status.Poll != nil:
+ // no previous poll, insert new status poll!
+ return true, d.insertStatusPoll(ctx, status)
+
+ case status.Poll == nil:
+ // existing status poll has been deleted, remove this from the database.
+ if err = d.state.DB.DeletePollByID(ctx, existing.Poll.ID); err != nil {
+ err = gtserror.Newf("error deleting poll from database: %w", err)
+ }
+ return true, err
+
+ case pollChanged(existing.Poll, status.Poll):
+ // existing status poll has been changed, remove this from the database.
+ if err = d.state.DB.DeletePollByID(ctx, existing.Poll.ID); err != nil {
+ return true, gtserror.Newf("error deleting poll from database: %w", err)
+ }
+
+ // insert latest poll version into database.
+ return true, d.insertStatusPoll(ctx, status)
+
+ case pollStateUpdated(existing.Poll, status.Poll):
+ // Since we last saw it, the poll has updated!
+ // Whether that be stats, or close time.
+ poll := existing.Poll
+ poll.Closing = pollJustClosed(existing.Poll, status.Poll)
+ poll.ClosedAt = status.Poll.ClosedAt
+ poll.Voters = status.Poll.Voters
+ poll.Votes = status.Poll.Votes
+
+ // Update poll model in the database (specifically only the possible changed columns).
+ if err = d.state.DB.UpdatePoll(ctx, poll, "closed_at", "voters", "votes"); err != nil {
+ return false, gtserror.Newf("error updating poll: %w", err)
+ }
+
+ // Update poll on status.
+ status.PollID = poll.ID
+ status.Poll = poll
+ return false, nil
+
+ default:
+ // latest and existing
+ // polls are up to date.
+ poll := existing.Poll
+ status.PollID = poll.ID
+ status.Poll = poll
+ return false, nil
+ }
+}
+
+// insertStatusPoll inserts an assumed new poll attached to status into the database, this
+// also handles generating new ID for the poll and setting necessary fields on the status.
+func (d *Dereferencer) insertStatusPoll(ctx context.Context, status *gtsmodel.Status) error {
+ var err error
+
+ // Generate new ID for poll from latest updated time.
+ status.Poll.ID = id.NewULIDFromTime(status.UpdatedAt)
+
+ // Update the status<->poll links.
+ status.PollID = status.Poll.ID
+ status.Poll.StatusID = status.ID
+ status.Poll.Status = status
+
+ // Insert this latest poll into the database.
+ err = d.state.DB.PutPoll(ctx, status.Poll)
+ if err != nil {
+ return gtserror.Newf("error putting poll in database: %w", err)
+ }
+
return nil
}
+// handleStatusEdit compiles a list of changed status table columns between
+// existing and latest status model, and where necessary inserts a historic
+// edit of the status into the database to store its previous state. the
+// returned slice is a list of columns requiring updating in the database.
+func (d *Dereferencer) handleStatusEdit(
+ ctx context.Context,
+ existing *gtsmodel.Status,
+ status *gtsmodel.Status,
+ pollChanged bool,
+ mentionsChanged bool,
+ threadChanged bool,
+ tagsChanged bool,
+ mediaChanged bool,
+ emojiChanged bool,
+) (
+ cols []string,
+ err error,
+) {
+ var edited bool
+
+ // Preallocate max slice length.
+ cols = make([]string, 1, 13)
+
+ // Always update `fetched_at`.
+ cols[0] = "fetched_at"
+
+ // Check for edited status content.
+ if existing.Content != status.Content {
+ cols = append(cols, "content")
+ edited = true
+ }
+
+ // Check for edited status content warning.
+ if existing.ContentWarning != status.ContentWarning {
+ cols = append(cols, "content_warning")
+ edited = true
+ }
+
+ // Check for edited status sensitive flag.
+ if *existing.Sensitive != *status.Sensitive {
+ cols = append(cols, "sensitive")
+ edited = true
+ }
+
+ // Check for edited status language tag.
+ if existing.Language != status.Language {
+ cols = append(cols, "language")
+ edited = true
+ }
+
+ if pollChanged {
+ // Attached poll was changed.
+ cols = append(cols, "poll_id")
+ edited = true
+ }
+
+ if mentionsChanged {
+ cols = append(cols, "mentions") // i.e. MentionIDs
+
+ // Mentions changed doesn't necessarily
+ // indicate an edit, it may just not have
+ // been previously populated properly.
+ }
+
+ if threadChanged {
+ cols = append(cols, "thread_id")
+
+ // Thread changed doesn't necessarily
+ // indicate an edit, it may just now
+ // actually be included in a thread.
+ }
+
+ if tagsChanged {
+ cols = append(cols, "tags") // i.e. TagIDs
+
+ // Tags changed doesn't necessarily
+ // indicate an edit, it may just not have
+ // been previously populated properly.
+ }
+
+ if mediaChanged {
+ // Attached media was changed.
+ cols = append(cols, "attachments") // i.e. AttachmentIDs
+ edited = true
+ }
+
+ if emojiChanged {
+ // 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.
+ }
+
+ if edited {
+ // ensure that updated_at hasn't remained the same
+ // but an edit was received. manually intervene here.
+ if status.UpdatedAt.Equal(existing.UpdatedAt) ||
+ status.CreatedAt.Equal(status.UpdatedAt) {
+
+ // Simply use current fetching time.
+ status.UpdatedAt = status.FetchedAt
+ }
+
+ // Status has been editted since last
+ // we saw it, take snapshot of existing.
+ var edit gtsmodel.StatusEdit
+ edit.ID = id.NewULIDFromTime(status.UpdatedAt)
+ edit.Content = existing.Content
+ edit.ContentWarning = existing.ContentWarning
+ edit.Text = existing.Text
+ edit.Language = existing.Language
+ edit.Sensitive = existing.Sensitive
+ edit.StatusID = status.ID
+
+ // Copy existing attachments and descriptions.
+ edit.AttachmentIDs = existing.AttachmentIDs
+ edit.Attachments = existing.Attachments
+ if l := len(existing.Attachments); l > 0 {
+ edit.AttachmentDescriptions = make([]string, l)
+ for i, attach := range existing.Attachments {
+ edit.AttachmentDescriptions[i] = attach.Description
+ }
+ }
+
+ // Edit creation is last update time.
+ edit.CreatedAt = existing.UpdatedAt
+
+ if existing.Poll != nil {
+ // Poll only set if existing contained them.
+ edit.PollOptions = existing.Poll.Options
+
+ 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.
+ edit.PollVotes = existing.Poll.Votes
+ }
+ }
+
+ // Insert this new edit of existing status into database.
+ if err := d.state.DB.PutStatusEdit(ctx, &edit); err != nil {
+ return nil, gtserror.Newf("error putting edit in database: %w", err)
+ }
+
+ // Add edit to list of edits on the status.
+ status.EditIDs = append(status.EditIDs, edit.ID)
+ status.Edits = append(status.Edits, &edit)
+
+ // Add edit to list of cols.
+ cols = append(cols, "edits")
+ }
+
+ if !existing.UpdatedAt.Equal(status.UpdatedAt) {
+ // Whether status edited or not,
+ // updated_at column has changed.
+ cols = append(cols, "updated_at")
+ }
+
+ return cols, nil
+}
+
// getPopulatedMention tries to populate the given
// mention with the correct TargetAccount and (if not
// yet set) TargetAccountURI, returning the populated
diff --git a/internal/federation/dereferencing/status_permitted.go b/internal/federation/dereferencing/status_permitted.go
index 9ad425c2f..5d05c5de4 100644
--- a/internal/federation/dereferencing/status_permitted.go
+++ b/internal/federation/dereferencing/status_permitted.go
@@ -62,6 +62,7 @@ func (d *Dereferencer) isPermittedStatus(
requestUser string,
existing *gtsmodel.Status,
status *gtsmodel.Status,
+ isNew bool,
) (
permitted bool, // is permitted?
err error,
@@ -98,7 +99,7 @@ func (d *Dereferencer) isPermittedStatus(
permitted = true
}
- if !permitted && existing != nil {
+ if !permitted && !isNew {
log.Infof(ctx, "deleting unpermitted: %s", existing.URI)
// Delete existing status from database as it's no longer permitted.
@@ -110,11 +111,13 @@ func (d *Dereferencer) isPermittedStatus(
return
}
+// isPermittedReply ...
func (d *Dereferencer) isPermittedReply(
ctx context.Context,
requestUser string,
reply *gtsmodel.Status,
) (bool, error) {
+
var (
replyURI = reply.URI // Definitely set.
inReplyToURI = reply.InReplyToURI // Definitely set.
@@ -149,8 +152,7 @@ func (d *Dereferencer) isPermittedReply(
// If this status's parent was rejected,
// implicitly this reply should be too;
// there's nothing more to check here.
- return false, d.unpermittedByParent(
- ctx,
+ return false, d.unpermittedByParent(ctx,
reply,
thisReq,
parentReq,
@@ -164,6 +166,7 @@ func (d *Dereferencer) isPermittedReply(
// be approved, then we should just reject it
// again, as nothing's changed since last time.
if thisRejected && acceptIRI == "" {
+
// Nothing changed,
// still rejected.
return false, nil
@@ -174,6 +177,7 @@ func (d *Dereferencer) isPermittedReply(
// to be approved. Continue permission checks.
if inReplyTo == nil {
+
// If we didn't have the replied-to status
// in our database (yet), we can't check
// right now if this reply is permitted.
diff --git a/internal/federation/dereferencing/status_test.go b/internal/federation/dereferencing/status_test.go
index 3b2c2bff2..4b3bd6d67 100644
--- a/internal/federation/dereferencing/status_test.go
+++ b/internal/federation/dereferencing/status_test.go
@@ -21,14 +21,21 @@
"context"
"fmt"
"testing"
+ "time"
"github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/activity/streams"
"github.com/superseriousbusiness/gotosocial/internal/ap"
"github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/federation/dereferencing"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/testrig"
)
+// instantFreshness is the shortest possible freshness window.
+var instantFreshness = util.Ptr(dereferencing.FreshnessWindow(0))
+
type StatusTestSuite struct {
DereferencerStandardTestSuite
}
@@ -229,6 +236,219 @@ func (suite *StatusTestSuite) TestDereferenceStatusWithNonMatchingURI() {
suite.Nil(fetchedStatus)
}
+func (suite *StatusTestSuite) TestDereferencerRefreshStatusUpdated() {
+ // Create a new context for this test.
+ ctx, cncl := context.WithCancel(context.Background())
+ defer cncl()
+
+ // The local account we will be fetching statuses as.
+ fetchingAccount := suite.testAccounts["local_account_1"]
+
+ // The test status in question that we will be dereferencing from "remote".
+ testURIStr := "https://unknown-instance.com/users/brand_new_person/statuses/01FE4NTHKWW7THT67EF10EB839"
+ testURI := testrig.URLMustParse(testURIStr)
+ testStatusable := suite.client.TestRemoteStatuses[testURIStr]
+
+ // Fetch the remote status first to load it into instance.
+ testStatus, statusable, err := suite.dereferencer.GetStatusByURI(ctx,
+ fetchingAccount.Username,
+ testURI,
+ )
+ suite.NotNil(statusable)
+ suite.NoError(err)
+
+ // Run through multiple possible edits.
+ for _, testCase := range []struct {
+ editedContent string
+ editedContentWarning string
+ editedLanguage string
+ editedSensitive bool
+ editedAttachmentIDs []string
+ editedPollOptions []string
+ editedPollVotes []int
+ editedAt time.Time
+ }{
+ {
+ editedContent: "updated status content!",
+ editedContentWarning: "CW: edited status content",
+ editedLanguage: testStatus.Language, // no change
+ editedSensitive: *testStatus.Sensitive, // no change
+ editedAttachmentIDs: testStatus.AttachmentIDs, // no change
+ editedPollOptions: getPollOptions(testStatus), // no change
+ editedPollVotes: getPollVotes(testStatus), // no change
+ editedAt: time.Now(),
+ },
+ } {
+ // Take a snapshot of current
+ // state of the test status.
+ testStatus = copyStatus(testStatus)
+
+ // Edit the "remote" statusable obj.
+ suite.editStatusable(testStatusable,
+ testCase.editedContent,
+ testCase.editedContentWarning,
+ testCase.editedLanguage,
+ testCase.editedSensitive,
+ testCase.editedAttachmentIDs,
+ testCase.editedPollOptions,
+ testCase.editedPollVotes,
+ testCase.editedAt,
+ )
+
+ // Refresh with a given statusable to updated to edited copy.
+ latest, statusable, err := suite.dereferencer.RefreshStatus(ctx,
+ fetchingAccount.Username,
+ testStatus,
+ nil, // NOTE: can provide testStatusable here to test as being received (not deref'd)
+ instantFreshness,
+ )
+ suite.NotNil(statusable)
+ suite.NoError(err)
+
+ // verify updated status details.
+ suite.verifyEditedStatusUpdate(
+
+ // the original status
+ // before any changes.
+ testStatus,
+
+ // latest status
+ // being tested.
+ latest,
+
+ // expected current state.
+ >smodel.StatusEdit{
+ Content: testCase.editedContent,
+ ContentWarning: testCase.editedContentWarning,
+ Language: testCase.editedLanguage,
+ Sensitive: &testCase.editedSensitive,
+ AttachmentIDs: testCase.editedAttachmentIDs,
+ PollOptions: testCase.editedPollOptions,
+ PollVotes: testCase.editedPollVotes,
+ // createdAt never changes
+ },
+
+ // expected historic edit.
+ >smodel.StatusEdit{
+ Content: testStatus.Content,
+ ContentWarning: testStatus.ContentWarning,
+ Language: testStatus.Language,
+ Sensitive: testStatus.Sensitive,
+ AttachmentIDs: testStatus.AttachmentIDs,
+ PollOptions: getPollOptions(testStatus),
+ PollVotes: getPollVotes(testStatus),
+ CreatedAt: testStatus.UpdatedAt,
+ },
+ )
+ }
+}
+
+// editStatusable updates the given statusable attributes.
+// note that this acts on the original object, no copying.
+func (suite *StatusTestSuite) editStatusable(
+ statusable ap.Statusable,
+ content string,
+ contentWarning string,
+ language string,
+ sensitive bool,
+ attachmentIDs []string, // TODO: this will require some thinking as to how ...
+ pollOptions []string, // TODO: this will require changing statusable type to question
+ pollVotes []int, // TODO: this will require changing statusable type to question
+ editedAt time.Time,
+) {
+ // simply reset all mentions / emojis / tags
+ statusable.SetActivityStreamsTag(nil)
+
+ // Update the statusable content property + language (if set).
+ contentProp := streams.NewActivityStreamsContentProperty()
+ statusable.SetActivityStreamsContent(contentProp)
+ contentProp.AppendXMLSchemaString(content)
+ if language != "" {
+ contentProp.AppendRDFLangString(map[string]string{
+ language: content,
+ })
+ }
+
+ // Update the statusable content-warning property.
+ summaryProp := streams.NewActivityStreamsSummaryProperty()
+ statusable.SetActivityStreamsSummary(summaryProp)
+ summaryProp.AppendXMLSchemaString(contentWarning)
+
+ // Update the statusable sensitive property.
+ sensitiveProp := streams.NewActivityStreamsSensitiveProperty()
+ statusable.SetActivityStreamsSensitive(sensitiveProp)
+ sensitiveProp.AppendXMLSchemaBoolean(sensitive)
+
+ // Update the statusable updated property.
+ ap.SetUpdated(statusable, editedAt)
+}
+
+// verifyEditedStatusUpdate verifies that a given status has
+// the expected number of historic edits, the 'current' status
+// attributes (encapsulated as an edit for minimized no. args),
+// and the last given 'historic' status edit attributes.
+func (suite *StatusTestSuite) verifyEditedStatusUpdate(
+ testStatus *gtsmodel.Status, // the original model
+ status *gtsmodel.Status, // the status to check
+ current *gtsmodel.StatusEdit, // expected current state
+ historic *gtsmodel.StatusEdit, // historic edit we expect to have
+) {
+ // don't use this func
+ // name in error msgs.
+ suite.T().Helper()
+
+ // Check we have expected number of edits.
+ previousEdits := len(testStatus.Edits)
+ suite.Len(status.Edits, previousEdits+1)
+ suite.Len(status.EditIDs, previousEdits+1)
+
+ // Check current state of status.
+ suite.Equal(current.Content, status.Content)
+ suite.Equal(current.ContentWarning, status.ContentWarning)
+ suite.Equal(current.Language, status.Language)
+ suite.Equal(*current.Sensitive, *status.Sensitive)
+ suite.Equal(current.AttachmentIDs, status.AttachmentIDs)
+ suite.Equal(current.PollOptions, getPollOptions(status))
+ suite.Equal(current.PollVotes, getPollVotes(status))
+
+ // Check the latest historic edit matches expected.
+ latestEdit := status.Edits[len(status.Edits)-1]
+ suite.Equal(historic.Content, latestEdit.Content)
+ suite.Equal(historic.ContentWarning, latestEdit.ContentWarning)
+ suite.Equal(historic.Language, latestEdit.Language)
+ suite.Equal(*historic.Sensitive, *latestEdit.Sensitive)
+ suite.Equal(historic.AttachmentIDs, latestEdit.AttachmentIDs)
+ suite.Equal(historic.PollOptions, latestEdit.PollOptions)
+ suite.Equal(historic.PollVotes, latestEdit.PollVotes)
+ suite.Equal(historic.CreatedAt, latestEdit.CreatedAt)
+
+ // The status creation date should never change.
+ suite.Equal(testStatus.CreatedAt, status.CreatedAt)
+}
+
func TestStatusTestSuite(t *testing.T) {
suite.Run(t, new(StatusTestSuite))
}
+
+// copyStatus returns a copy of the given status model (not including sub-structs).
+func copyStatus(status *gtsmodel.Status) *gtsmodel.Status {
+ copy := new(gtsmodel.Status)
+ *copy = *status
+ return copy
+}
+
+// getPollOptions extracts poll option strings from status (if poll is set).
+func getPollOptions(status *gtsmodel.Status) []string {
+ if status.Poll != nil {
+ return status.Poll.Options
+ }
+ return nil
+}
+
+// getPollVotes extracts poll vote counts from status (if poll is set).
+func getPollVotes(status *gtsmodel.Status) []int {
+ if status.Poll != nil {
+ return status.Poll.Votes
+ }
+ return nil
+}
diff --git a/internal/federation/dereferencing/util.go b/internal/federation/dereferencing/util.go
index 297e90adc..208117660 100644
--- a/internal/federation/dereferencing/util.go
+++ b/internal/federation/dereferencing/util.go
@@ -52,15 +52,15 @@ func emojiChanged(existing, latest *gtsmodel.Emoji) bool {
// pollChanged returns whether a poll has changed in way that
// indicates that this should be an entirely new poll. i.e. if
-// the available options have changed, or the expiry has increased.
+// the available options have changed, or the expiry has changed.
func pollChanged(existing, latest *gtsmodel.Poll) bool {
return !slices.Equal(existing.Options, latest.Options) ||
!existing.ExpiresAt.Equal(latest.ExpiresAt)
}
-// pollUpdated returns whether a poll has updated, i.e. if the
+// pollStateUpdated returns whether a poll has updated, i.e. if
// vote counts have changed, or if it has expired / been closed.
-func pollUpdated(existing, latest *gtsmodel.Poll) bool {
+func pollStateUpdated(existing, latest *gtsmodel.Poll) bool {
return *existing.Voters != *latest.Voters ||
!slices.Equal(existing.Votes, latest.Votes) ||
!existing.ClosedAt.Equal(latest.ClosedAt)
diff --git a/internal/federation/federatingdb/announce_test.go b/internal/federation/federatingdb/announce_test.go
index 264279253..5bb2fc877 100644
--- a/internal/federation/federatingdb/announce_test.go
+++ b/internal/federation/federatingdb/announce_test.go
@@ -79,7 +79,7 @@ func (suite *AnnounceTestSuite) TestAnnounceTwice() {
// Insert the boost-of status into the
// DB cache to emulate processor handling
- boost.ID, _ = id.NewULIDFromTime(boost.CreatedAt)
+ boost.ID = id.NewULIDFromTime(boost.CreatedAt)
suite.state.Caches.DB.Status.Put(boost)
// only the URI will be set for the boosted status
diff --git a/internal/gtsmodel/instance.go b/internal/gtsmodel/instance.go
index 027d8fba4..97c0268ce 100644
--- a/internal/gtsmodel/instance.go
+++ b/internal/gtsmodel/instance.go
@@ -34,6 +34,7 @@ type Instance struct {
ShortDescriptionText string `bun:""` // Raw text version of short description (before parsing).
Description string `bun:""` // Longer description of this instance.
DescriptionText string `bun:""` // Raw text version of long description (before parsing).
+ CustomCSS string `bun:",nullzero"` // Custom CSS for the instance.
Terms string `bun:""` // Terms and conditions of this instance.
TermsText string `bun:""` // Raw text version of terms (before parsing).
ContactEmail string `bun:""` // Contact email address for this instance
diff --git a/internal/gtsmodel/mediaattachment.go b/internal/gtsmodel/mediaattachment.go
index f4bfb5929..5cf6f60a6 100644
--- a/internal/gtsmodel/mediaattachment.go
+++ b/internal/gtsmodel/mediaattachment.go
@@ -26,7 +26,6 @@
type MediaAttachment struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
- UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated
StatusID string `bun:"type:CHAR(26),nullzero"` // ID of the status to which this is attached
URL string `bun:",nullzero"` // Where can the attachment be retrieved on *this* server
RemoteURL string `bun:",nullzero"` // Where can the attachment be retrieved on a remote server (empty for local media)
diff --git a/internal/gtsmodel/mention.go b/internal/gtsmodel/mention.go
index 24e83f904..180193f0f 100644
--- a/internal/gtsmodel/mention.go
+++ b/internal/gtsmodel/mention.go
@@ -26,7 +26,6 @@
type Mention struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
- UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated
StatusID string `bun:"type:CHAR(26),nullzero,notnull"` // ID of the status this mention originates from
Status *Status `bun:"rel:belongs-to"` // status referred to by statusID
OriginAccountID string `bun:"type:CHAR(26),nullzero,notnull"` // ID of the mention creator account
diff --git a/internal/gtsmodel/status.go b/internal/gtsmodel/status.go
index f8bd068ab..4c65d8a88 100644
--- a/internal/gtsmodel/status.go
+++ b/internal/gtsmodel/status.go
@@ -20,6 +20,8 @@
import (
"slices"
"time"
+
+ "github.com/superseriousbusiness/gotosocial/internal/util/xslices"
)
// Status represents a user-created 'post' or 'status' in the database, either remote or local
@@ -55,6 +57,8 @@ type Status struct {
BoostOf *Status `bun:"-"` // status that corresponds to boostOfID
BoostOfAccount *Account `bun:"rel:belongs-to"` // account that corresponds to boostOfAccountID
ThreadID string `bun:"type:CHAR(26),nullzero"` // id of the thread to which this status belongs; only set for remote statuses if a local account is involved at some point in the thread, otherwise null
+ EditIDs []string `bun:"edits,array"` //
+ Edits []*StatusEdit `bun:"-"` //
PollID string `bun:"type:CHAR(26),nullzero"` //
Poll *Poll `bun:"-"` //
ContentWarning string `bun:",nullzero"` // cw string for this status
@@ -92,7 +96,8 @@ func (s *Status) GetBoostOfAccountID() string {
return s.BoostOfAccountID
}
-// AttachmentsPopulated returns whether media attachments are populated according to current AttachmentIDs.
+// AttachmentsPopulated returns whether media attachments
+// are populated according to current AttachmentIDs.
func (s *Status) AttachmentsPopulated() bool {
if len(s.AttachmentIDs) != len(s.Attachments) {
// this is the quickest indicator.
@@ -106,7 +111,8 @@ func (s *Status) AttachmentsPopulated() bool {
return true
}
-// TagsPopulated returns whether tags are populated according to current TagIDs.
+// TagsPopulated returns whether tags are
+// populated according to current TagIDs.
func (s *Status) TagsPopulated() bool {
if len(s.TagIDs) != len(s.Tags) {
// this is the quickest indicator.
@@ -120,7 +126,8 @@ func (s *Status) TagsPopulated() bool {
return true
}
-// MentionsPopulated returns whether mentions are populated according to current MentionIDs.
+// MentionsPopulated returns whether mentions are
+// populated according to current MentionIDs.
func (s *Status) MentionsPopulated() bool {
if len(s.MentionIDs) != len(s.Mentions) {
// this is the quickest indicator.
@@ -134,7 +141,8 @@ func (s *Status) MentionsPopulated() bool {
return true
}
-// EmojisPopulated returns whether emojis are populated according to current EmojiIDs.
+// EmojisPopulated returns whether emojis are
+// populated according to current EmojiIDs.
func (s *Status) EmojisPopulated() bool {
if len(s.EmojiIDs) != len(s.Emojis) {
// this is the quickest indicator.
@@ -148,6 +156,21 @@ func (s *Status) EmojisPopulated() bool {
return true
}
+// EditsPopulated returns whether edits are
+// populated according to current EditIDs.
+func (s *Status) EditsPopulated() bool {
+ if len(s.EditIDs) != len(s.Edits) {
+ // this is quickest indicator.
+ return false
+ }
+ for i, id := range s.EditIDs {
+ if s.Edits[i].ID != id {
+ return false
+ }
+ }
+ return true
+}
+
// EmojissUpToDate returns whether status emoji attachments of receiving status are up-to-date
// according to emoji attachments of the passed status, by comparing their emoji URIs. We don't
// use IDs as this is used to determine whether there are new emojis to fetch.
@@ -247,6 +270,35 @@ func (s *Status) IsLocalOnly() bool {
return s.Federated == nil || !*s.Federated
}
+// AllAttachmentIDs gathers ALL media attachment IDs from both the
+// receiving Status{}, and any historical Status{}.Edits. Note that
+// this function will panic if Status{}.Edits is not populated.
+func (s *Status) AllAttachmentIDs() []string {
+ var total int
+
+ if len(s.EditIDs) != len(s.Edits) {
+ panic("status edits not populated")
+ }
+
+ // Get count of attachment IDs.
+ total += len(s.Attachments)
+ for _, edit := range s.Edits {
+ total += len(edit.AttachmentIDs)
+ }
+
+ // Start gathering of all IDs with *current* attachment IDs.
+ attachmentIDs := make([]string, len(s.AttachmentIDs), total)
+ copy(attachmentIDs, s.AttachmentIDs)
+
+ // Append IDs of historical edits.
+ for _, edit := range s.Edits {
+ attachmentIDs = append(attachmentIDs, edit.AttachmentIDs...)
+ }
+
+ // Deduplicate these IDs in case of shared media.
+ return xslices.Deduplicate(attachmentIDs)
+}
+
// StatusToTag is an intermediate struct to facilitate the many2many relationship between a status and one or more tags.
type StatusToTag struct {
StatusID string `bun:"type:CHAR(26),unique:statustag,nullzero,notnull"`
diff --git a/internal/gtsmodel/statusedit.go b/internal/gtsmodel/statusedit.go
new file mode 100644
index 000000000..199d47736
--- /dev/null
+++ b/internal/gtsmodel/statusedit.go
@@ -0,0 +1,62 @@
+// 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 .
+
+package gtsmodel
+
+import "time"
+
+// StatusEdit represents a **historical** view of a Status
+// after a received edit. The Status itself will always
+// contain the latest up-to-date information.
+//
+// Note that stored status edits may not exactly match that
+// of the origin server, they are a best-effort by receiver
+// to store version history. There is no AP history endpoint.
+type StatusEdit struct {
+ ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // ID of this item in the database.
+ Content string `bun:""` // Content of status at time of edit; likely html-formatted but not guaranteed.
+ ContentWarning string `bun:",nullzero"` // Content warning of status at time of edit.
+ Text string `bun:""` // Original status text, without formatting, at time of edit.
+ Language string `bun:",nullzero"` // Status language at time of edit.
+ Sensitive *bool `bun:",nullzero,notnull,default:false"` // Status sensitive flag at time of edit.
+ AttachmentIDs []string `bun:"attachments,array"` // Database IDs of media attachments associated with status at time of edit.
+ AttachmentDescriptions []string `bun:",array"` // Previous media descriptions of media attachments associated with status at time of edit.
+ Attachments []*MediaAttachment `bun:"-"` // Media attachments relating to .AttachmentIDs field (not always populated).
+ PollOptions []string `bun:",array"` // Poll options of status at time of edit, only set if status contains a poll.
+ PollVotes []int `bun:",array"` // Poll vote count at time of status edit, only set if poll votes were reset.
+ StatusID string `bun:"type:CHAR(26),nullzero,notnull"` // The originating status ID this is a historical edit of.
+ CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // The creation time of this version of the status content (according to receiving server).
+
+ // We don't bother having a *gtsmodel.Status model here
+ // as the StatusEdit is always just attached to a Status,
+ // so it doesn't need a self-reference back to it.
+}
+
+// AttachmentsPopulated returns whether media attachments
+// are populated according to current AttachmentIDs.
+func (e *StatusEdit) AttachmentsPopulated() bool {
+ if len(e.AttachmentIDs) != len(e.Attachments) {
+ // this is the quickest indicator.
+ return false
+ }
+ for i, id := range e.AttachmentIDs {
+ if e.Attachments[i].ID != id {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/id/ulid.go b/internal/id/ulid.go
index 8de4cc4cc..8c0b1e94c 100644
--- a/internal/id/ulid.go
+++ b/internal/id/ulid.go
@@ -22,7 +22,9 @@
"math/big"
"time"
+ "codeberg.org/gruf/go-kv"
"github.com/oklog/ulid"
+ "github.com/superseriousbusiness/gotosocial/internal/log"
)
const (
@@ -45,13 +47,19 @@ func NewULID() string {
return ulid.String()
}
-// NewULIDFromTime returns a new ULID string using the given time, or an error if something goes wrong.
-func NewULIDFromTime(t time.Time) (string, error) {
- newUlid, err := ulid.New(ulid.Timestamp(t), rand.Reader)
- if err != nil {
- return "", err
+// NewULIDFromTime returns a new ULID string using
+// given time, or from current time on any error.
+func NewULIDFromTime(t time.Time) string {
+ ts := ulid.Timestamp(t)
+ if ts > ulid.MaxTime() {
+ log.WarnKVs(nil, kv.Fields{
+ {K: "caller", V: log.Caller(2)},
+ {K: "value", V: t},
+ {K: "msg", V: "invalid ulid time"},
+ }...)
+ ts = ulid.Now()
}
- return newUlid.String(), nil
+ return ulid.MustNew(ts, rand.Reader).String()
}
// NewRandomULID returns a new ULID string using a random time in an ~80 year range around the current datetime, or an error if something goes wrong.
diff --git a/internal/media/manager.go b/internal/media/manager.go
index 2807848bd..6aa13c17b 100644
--- a/internal/media/manager.go
+++ b/internal/media/manager.go
@@ -118,15 +118,11 @@ func (m *Manager) CreateMedia(
Header: util.Ptr(false),
Cached: util.Ptr(false),
CreatedAt: now,
- UpdatedAt: now,
}
// Check if we were provided additional info
// to add to the attachment, and overwrite
// some of the attachment fields if so.
- if info.CreatedAt != nil {
- attachment.CreatedAt = *info.CreatedAt
- }
if info.StatusID != nil {
attachment.StatusID = *info.StatusID
}
@@ -372,9 +368,6 @@ func (m *Manager) createOrUpdateEmoji(
if info.URI != nil {
emoji.URI = *info.URI
}
- if info.CreatedAt != nil {
- emoji.CreatedAt = *info.CreatedAt
- }
if info.Domain != nil {
emoji.Domain = *info.Domain
}
diff --git a/internal/media/manager_test.go b/internal/media/manager_test.go
index e175369f5..5b6882100 100644
--- a/internal/media/manager_test.go
+++ b/internal/media/manager_test.go
@@ -109,7 +109,6 @@ func (suite *ManagerTestSuite) TestEmojiProcessRefresh() {
emojiToUpdate,
data,
media.AdditionalEmojiInfo{
- CreatedAt: &emojiToUpdate.CreatedAt,
Domain: &emojiToUpdate.Domain,
ImageRemoteURL: &newImageRemoteURL,
},
diff --git a/internal/media/types.go b/internal/media/types.go
index 9631a15bd..827752941 100644
--- a/internal/media/types.go
+++ b/internal/media/types.go
@@ -20,7 +20,6 @@
import (
"context"
"io"
- "time"
)
type Size string
@@ -44,10 +43,6 @@
// should be added to attachment when processing a piece of media.
type AdditionalMediaInfo struct {
- // Time that this media was
- // created; defaults to time.Now().
- CreatedAt *time.Time
-
// ID of the status to which this
// media is attached; defaults to "".
StatusID *string
@@ -93,10 +88,6 @@ type AdditionalEmojiInfo struct {
// this remote emoji.
URI *string
- // Time that this emoji was
- // created; defaults to time.Now().
- CreatedAt *time.Time
-
// Domain the emoji originated from. Blank
// for this instance's domain. Defaults to "".
Domain *string
diff --git a/internal/processing/account/rss_test.go b/internal/processing/account/rss_test.go
index e4706d3b7..5606151c2 100644
--- a/internal/processing/account/rss_test.go
+++ b/internal/processing/account/rss_test.go
@@ -70,7 +70,7 @@ func (suite *GetRSSTestSuite) TestGetAccountRSSAdmin() {
func (suite *GetRSSTestSuite) TestGetAccountRSSZork() {
getFeed, lastModified, err := suite.accountProcessor.GetRSSFeedForUsername(context.Background(), "the_mighty_zork")
suite.NoError(err)
- suite.EqualValues(1704878640, lastModified.Unix())
+ suite.EqualValues(1730451600, lastModified.Unix())
feed, err := getFeed()
suite.NoError(err)
@@ -79,13 +79,23 @@ func (suite *GetRSSTestSuite) TestGetAccountRSSZork() {
Posts from @the_mighty_zork@localhost:8080
http://localhost:8080/@the_mighty_zork
Posts from @the_mighty_zork@localhost:8080
- Wed, 10 Jan 2024 09:24:00 +0000
- Wed, 10 Jan 2024 09:24:00 +0000
+ Fri, 01 Nov 2024 09:00:00 +0000
+ Fri, 01 Nov 2024 09:00:00 +0000http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.webpAvatar for @the_mighty_zork@localhost:8080
http://localhost:8080/@the_mighty_zork
+
+ edited status
+ http://localhost:8080/@the_mighty_zork/statuses/01JDPZC707CKDN8N4QVWM4Z1NR
+ @the_mighty_zork@localhost:8080 made a new post: "this is the latest revision of the status, with a content-warning"
+ this is the latest revision of the status, with a content-warning
]]>
+ @the_mighty_zork@localhost:8080
+ http://localhost:8080/@the_mighty_zork/statuses/01JDPZC707CKDN8N4QVWM4Z1NR
+ Fri, 01 Nov 2024 09:00:00 +0000
+
+
HTML in post
http://localhost:8080/@the_mighty_zork/statuses/01HH9KYNQPA416TNJ53NSATP40
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 @@
"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 @@
"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 a9be6db1d..2f4c40416 100644
--- a/internal/processing/instance.go
+++ b/internal/processing/instance.go
@@ -29,6 +29,7 @@
"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) {
@@ -227,6 +228,17 @@ func (p *Processor) InstancePatch(ctx context.Context, form *apimodel.InstanceSe
columns = append(columns, []string{"description", "description_text"}...)
}
+ // validate & update site custom css if it's set on the form
+ if form.CustomCSS != nil {
+ customCSS := *form.CustomCSS
+ if err := validate.InstanceCustomCSS(customCSS); err != nil {
+ return nil, gtserror.NewErrorBadRequest(err, err.Error())
+ }
+
+ instance.CustomCSS = text.SanitizeToPlaintext(customCSS)
+ columns = append(columns, []string{"custom_css"}...)
+ }
+
// Validate & update site
// terms if set on the form.
if form.Terms != nil {
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 @@
"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/getfile.go b/internal/processing/media/getfile.go
index 6962601f2..11d8f7eb5 100644
--- a/internal/processing/media/getfile.go
+++ b/internal/processing/media/getfile.go
@@ -177,9 +177,7 @@ func (p *Processor) getAttachmentContent(
}
// Start preparing API content model.
- apiContent := &apimodel.Content{
- ContentUpdated: attach.UpdatedAt,
- }
+ apiContent := &apimodel.Content{}
// Retrieve appropriate
// size file from storage.
diff --git a/internal/processing/media/unattach_test.go b/internal/processing/media/unattach_test.go
index 051caa4d3..02d2c7077 100644
--- a/internal/processing/media/unattach_test.go
+++ b/internal/processing/media/unattach_test.go
@@ -20,7 +20,6 @@
import (
"context"
"testing"
- "time"
"github.com/stretchr/testify/suite"
)
@@ -42,8 +41,6 @@ func (suite *UnattachTestSuite) TestUnattachMedia() {
dbAttachment, errWithCode := suite.db.GetAttachmentByID(ctx, a.ID)
suite.NoError(errWithCode)
-
- suite.WithinDuration(dbAttachment.UpdatedAt, time.Now(), 1*time.Minute)
suite.Empty(dbAttachment.StatusID)
}
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 @@
"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 .
-
-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 .
+
+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 ef8f8aa56..af9831b9c 100644
--- a/internal/processing/status/create.go
+++ b/internal/processing/status/create.go
@@ -19,29 +19,22 @@
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,33 +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),
}
- if form.Poll != nil {
- // Update the status AS type to "Question".
- status.ActivityStreamsType = ap.ActivityQuestion
-
- // Create new poll for status from form.
- secs := time.Duration(form.Poll.ExpiresIn)
- status.Poll = >smodel.Poll{
- ID: id.NewULID(),
- Multiple: &form.Poll.Multiple,
- HideCounts: &form.Poll.HideTotals,
- Options: form.Poll.Options,
- StatusID: statusID,
- Status: status,
- ExpiresAt: now.Add(secs * time.Second),
- }
-
- // Set poll ID on the status.
- status.PollID = status.Poll.ID
- }
-
// Check + attach in-reply-to status.
if errWithCode := p.processInReplyTo(ctx,
requester,
@@ -118,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)
}
@@ -132,28 +158,49 @@ func (p *Processor) Create(
return nil, errWithCode
}
- if err := processLanguage(form, requester.Settings.Language, status); err != nil {
+ 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 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 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 err := p.processContent(ctx, p.parseMention, form, status); err != nil {
- return nil, gtserror.NewErrorInternalError(err)
- }
-
- 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 status.Poll != nil && !status.Poll.ExpiresAt.IsZero() {
+ // 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)
}
}
- // Insert this new status in the database.
- if err := p.state.DB.PutStatus(ctx, status); err != nil {
- return nil, gtserror.NewErrorInternalError(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,
@@ -161,14 +208,6 @@ func (p *Processor) Create(
Origin: requester,
})
- if status.Poll != nil {
- // Now that the status is inserted, and side effects queued,
- // 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)
- }
- }
-
// If the new status replies to a status that
// replies to us, use our reply as an implicit
// accept of any pending interaction.
@@ -312,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,
@@ -454,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 .
+
+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 .
+
+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: "
this is some edited status text!
",
+ 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: "
this is some edited status text!
",
+ 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: "
this is some edited status text!
",
+ 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: "
this is some edited status text!
",
+ 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: "
this is some edited status text!
",
+ 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: "
this is some edited status text!
",
+ 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 75a687db2..812f01683 100644
--- a/internal/processing/status/get.go
+++ b/internal/processing/status/get.go
@@ -19,47 +19,16 @@
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/stream/notification_test.go b/internal/processing/stream/notification_test.go
index 169e4f5ce..5c89e1f40 100644
--- a/internal/processing/stream/notification_test.go
+++ b/internal/processing/stream/notification_test.go
@@ -79,8 +79,8 @@ func (suite *NotificationTestSuite) TestStreamNotification() {
"header_description": "Flat gray background (default header).",
"followers_count": 0,
"following_count": 0,
- "statuses_count": 3,
- "last_status_at": "2021-09-11",
+ "statuses_count": 4,
+ "last_status_at": "2024-11-01",
"emojis": [],
"fields": []
}
diff --git a/internal/processing/stream/statusupdate_test.go b/internal/processing/stream/statusupdate_test.go
index b61a9c623..6bf5e436c 100644
--- a/internal/processing/stream/statusupdate_test.go
+++ b/internal/processing/stream/statusupdate_test.go
@@ -54,6 +54,7 @@ func (suite *StatusUpdateTestSuite) TestStreamNotification() {
suite.Equal(`{
"id": "01FVW7JHQFSFK166WWKR8CBA6M",
"created_at": "2021-09-20T10:40:37.000Z",
+ "edited_at": null,
"in_reply_to_id": null,
"in_reply_to_account_id": null,
"sensitive": false,
@@ -90,8 +91,8 @@ func (suite *StatusUpdateTestSuite) TestStreamNotification() {
"header_description": "Flat gray background (default header).",
"followers_count": 0,
"following_count": 0,
- "statuses_count": 3,
- "last_status_at": "2021-09-11",
+ "statuses_count": 4,
+ "last_status_at": "2024-11-01",
"emojis": [],
"fields": []
},
diff --git a/internal/processing/timeline/public_test.go b/internal/processing/timeline/public_test.go
index 6b01c9849..ab8e33429 100644
--- a/internal/processing/timeline/public_test.go
+++ b/internal/processing/timeline/public_test.go
@@ -102,8 +102,8 @@ func (suite *PublicTestSuite) TestPublicTimelineGetHideFiltered() {
requester = suite.testAccounts["local_account_1"]
maxID = ""
sinceID = ""
- minID = "01F8MHAAY43M6RJ473VQFCVH36" // 1 before filteredStatus
- limit = 10
+ minID = ""
+ limit = 100
local = false
filteredStatus = suite.testStatuses["admin_account_status_2"]
filteredStatusFound = false
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/processing/workers/util.go b/internal/processing/workers/util.go
index 62ea6c95c..b358dc951 100644
--- a/internal/processing/workers/util.go
+++ b/internal/processing/workers/util.go
@@ -75,6 +75,21 @@ func (u *utils) wipeStatus(
}
}
+ // Before handling media, ensure
+ // historic edits are populated.
+ if !status.EditsPopulated() {
+ var err error
+
+ // Fetch all historical edits of status from database.
+ status.Edits, err = u.state.DB.GetStatusEditsByIDs(
+ gtscontext.SetBarebones(ctx),
+ status.EditIDs,
+ )
+ if err != nil {
+ errs.Appendf("error getting status edits from database: %w", err)
+ }
+ }
+
// Either delete all attachments for this status,
// or simply detach + clean them separately later.
//
@@ -83,20 +98,27 @@ func (u *utils) wipeStatus(
// status immediately (in case of delete + redraft).
if deleteAttachments {
// todo:u.state.DB.DeleteAttachmentsForStatus
- for _, id := range status.AttachmentIDs {
+ for _, id := range status.AllAttachmentIDs() {
if err := u.media.Delete(ctx, id); err != nil {
errs.Appendf("error deleting media: %w", err)
}
}
} else {
// todo:u.state.DB.UnattachAttachmentsForStatus
- for _, id := range status.AttachmentIDs {
+ for _, id := range status.AllAttachmentIDs() {
if _, err := u.media.Unattach(ctx, status.Account, id); err != nil {
errs.Appendf("error unattaching media: %w", err)
}
}
}
+ // Delete all historical edits of status.
+ if ids := status.EditIDs; len(ids) > 0 {
+ if err := u.state.DB.DeleteStatusEdits(ctx, ids); err != nil {
+ errs.Appendf("error deleting status edits: %w", err)
+ }
+ }
+
// Delete all mentions generated by this status.
// todo:u.state.DB.DeleteMentionsForStatus
for _, id := range status.MentionIDs {
@@ -120,19 +142,20 @@ func (u *utils) wipeStatus(
errs.Appendf("error deleting status faves: %w", err)
}
- if pollID := status.PollID; pollID != "" {
+ if id := status.PollID; id != "" {
// Delete this poll by ID from the database.
- if err := u.state.DB.DeletePollByID(ctx, pollID); err != nil {
+ if err := u.state.DB.DeletePollByID(ctx, id); err != nil {
errs.Appendf("error deleting status poll: %w", err)
}
// Cancel any scheduled expiry task for poll.
- _ = u.state.Workers.Scheduler.Cancel(pollID)
+ _ = u.state.Workers.Scheduler.Cancel(id)
}
// Get all boost of this status so that we can
// delete those boosts + remove them from timelines.
boosts, err := u.state.DB.GetStatusBoosts(
+
// We MUST set a barebones context here,
// as depending on where it came from the
// original BoostOf may already be gone.
@@ -537,11 +560,7 @@ func (u *utils) requestFave(
}
// Create + store new interaction request.
- req, err = typeutils.StatusFaveToInteractionRequest(ctx, fave)
- if err != nil {
- return gtserror.Newf("error creating interaction request: %w", err)
- }
-
+ req = typeutils.StatusFaveToInteractionRequest(fave)
if err := u.state.DB.PutInteractionRequest(ctx, req); err != nil {
return gtserror.Newf("db error storing interaction request: %w", err)
}
@@ -584,11 +603,7 @@ func (u *utils) requestReply(
}
// Create + store interaction request.
- req, err = typeutils.StatusToInteractionRequest(ctx, reply)
- if err != nil {
- return gtserror.Newf("error creating interaction request: %w", err)
- }
-
+ req = typeutils.StatusToInteractionRequest(reply)
if err := u.state.DB.PutInteractionRequest(ctx, req); err != nil {
return gtserror.Newf("db error storing interaction request: %w", err)
}
@@ -631,11 +646,7 @@ func (u *utils) requestAnnounce(
}
// Create + store interaction request.
- req, err = typeutils.StatusToInteractionRequest(ctx, boost)
- if err != nil {
- return gtserror.Newf("error creating interaction request: %w", err)
- }
-
+ req = typeutils.StatusToInteractionRequest(boost)
if err := u.state.DB.PutInteractionRequest(ctx, req); err != nil {
return gtserror.Newf("db error storing interaction request: %w", err)
}
diff --git a/internal/text/plain_test.go b/internal/text/plain_test.go
index 48280bb44..fac54a38e 100644
--- a/internal/text/plain_test.go
+++ b/internal/text/plain_test.go
@@ -36,6 +36,8 @@
moreComplexExpected = "
-{{- end }}
\ No newline at end of file
+{{- end }}
diff --git a/web/template/page_stylesheets.tmpl b/web/template/page_stylesheets.tmpl
index 9234607f8..9ccc65c13 100644
--- a/web/template/page_stylesheets.tmpl
+++ b/web/template/page_stylesheets.tmpl
@@ -32,10 +32,16 @@
{{- range .stylesheets }}
{{- end }}
+{{- if .instance.CustomCSS }}
+
+{{- end }}
{{- range .stylesheets }}
{{- end }}
+{{- if .instance.CustomCSS }}
+
+{{- end }}
{{- end }}
\ No newline at end of file