diff options
| author | 2025-08-12 14:05:15 +0200 | |
|---|---|---|
| committer | 2025-08-12 14:05:15 +0200 | |
| commit | 660cf2c94ce6a87ac33d704ab1f68b2d4a258d92 (patch) | |
| tree | 03eeab55b2f3bc2f3fc7667d245ed3b561a4f1a4 /internal | |
| parent | [docs] Revamp trusted proxies warning a bit (#4365) (diff) | |
| download | gotosocial-660cf2c94ce6a87ac33d704ab1f68b2d4a258d92.tar.xz | |
[feature] scheduled statuses (#4274)
An implementation of [`scheduled_statuses`](https://docs.joinmastodon.org/methods/scheduled_statuses/). Will fix #1006.
this is heavily WIP and I need to reorganize some of the code, working on this made me somehow familiar with the codebase and led to my other recent contributions
i told some fops on fedi i'd work on this so i have no choice but to complete it 🤷‍♀️
btw iirc my avatar presents me working on this branch
Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
Reviewed-on: https://codeberg.org/superseriousbusiness/gotosocial/pulls/4274
Co-authored-by: nicole mikołajczyk <git@mkljczk.pl>
Co-committed-by: nicole mikołajczyk <git@mkljczk.pl>
Diffstat (limited to 'internal')
37 files changed, 2064 insertions, 62 deletions
diff --git a/internal/api/client.go b/internal/api/client.go index 0b977e59f..829c9326d 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -48,6 +48,7 @@ import ( "code.superseriousbusiness.org/gotosocial/internal/api/client/preferences" "code.superseriousbusiness.org/gotosocial/internal/api/client/push" "code.superseriousbusiness.org/gotosocial/internal/api/client/reports" + "code.superseriousbusiness.org/gotosocial/internal/api/client/scheduledstatuses" "code.superseriousbusiness.org/gotosocial/internal/api/client/search" "code.superseriousbusiness.org/gotosocial/internal/api/client/statuses" "code.superseriousbusiness.org/gotosocial/internal/api/client/streaming" @@ -95,6 +96,7 @@ type Client struct { preferences *preferences.Module // api/v1/preferences push *push.Module // api/v1/push reports *reports.Module // api/v1/reports + scheduledStatuses *scheduledstatuses.Module // api/v1/scheduled_statuses search *search.Module // api/v1/search, api/v2/search statuses *statuses.Module // api/v1/statuses streaming *streaming.Module // api/v1/streaming @@ -149,6 +151,7 @@ func (c *Client) Route(r *router.Router, m ...gin.HandlerFunc) { c.preferences.Route(h) c.push.Route(h) c.reports.Route(h) + c.scheduledStatuses.Route(h) c.search.Route(h) c.statuses.Route(h) c.streaming.Route(h) @@ -191,6 +194,7 @@ func NewClient(state *state.State, p *processing.Processor) *Client { preferences: preferences.New(p), push: push.New(p), reports: reports.New(p), + scheduledStatuses: scheduledstatuses.New(p), search: search.New(p), statuses: statuses.New(p), streaming: streaming.New(p, time.Second*30, 4096), diff --git a/internal/api/client/scheduledstatuses/scheduledstatus.go b/internal/api/client/scheduledstatuses/scheduledstatus.go new file mode 100644 index 000000000..4710640ff --- /dev/null +++ b/internal/api/client/scheduledstatuses/scheduledstatus.go @@ -0,0 +1,52 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package scheduledstatuses + +import ( + "net/http" + + "code.superseriousbusiness.org/gotosocial/internal/processing" + "github.com/gin-gonic/gin" +) + +const ( + // IDKey is for status UUIDs + IDKey = "id" + // BasePath is the base path for serving the scheduled statuses API, minus the 'api' prefix + BasePath = "/v1/scheduled_statuses" + // BasePathWithID is just the base path with the ID key in it. + // Use this anywhere you need to know the ID of the scheduled status being queried. + BasePathWithID = BasePath + "/:" + IDKey +) + +type Module struct { + processor *processing.Processor +} + +func New(processor *processing.Processor) *Module { + return &Module{ + processor: processor, + } +} + +func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) { + attachHandler(http.MethodGet, BasePath, m.ScheduledStatusesGETHandler) + attachHandler(http.MethodGet, BasePathWithID, m.ScheduledStatusGETHandler) + attachHandler(http.MethodPut, BasePathWithID, m.ScheduledStatusPUTHandler) + attachHandler(http.MethodDelete, BasePathWithID, m.ScheduledStatusDELETEHandler) +} diff --git a/internal/api/client/scheduledstatuses/scheduledstatusdelete.go b/internal/api/client/scheduledstatuses/scheduledstatusdelete.go new file mode 100644 index 000000000..efd645e2a --- /dev/null +++ b/internal/api/client/scheduledstatuses/scheduledstatusdelete.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 <http://www.gnu.org/licenses/>. + +package scheduledstatuses + +import ( + "net/http" + + apiutil "code.superseriousbusiness.org/gotosocial/internal/api/util" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + + "github.com/gin-gonic/gin" +) + +// ScheduledStatusDELETEHandler swagger:operation DELETE /api/v1/scheduled_statuses/{id} deleteScheduledStatus +// +// Cancel a scheduled status with the given id. +// +// --- +// tags: +// - scheduled_statuses +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: string +// description: ID of the status +// in: path +// required: true +// +// security: +// - OAuth2 Bearer: +// - write:statuses +// +// responses: +// '200': +// description: status canceled +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) ScheduledStatusDELETEHandler(c *gin.Context) { + authed, errWithCode := apiutil.TokenAuth(c, + true, true, true, true, + apiutil.ScopeWriteStatuses, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + targetScheduledStatusID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + errWithCode = m.processor.Status().ScheduledStatusesDelete( + c.Request.Context(), + authed.Account, + targetScheduledStatusID, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + c.JSON(http.StatusOK, apiutil.EmptyJSONObject) +} diff --git a/internal/api/client/scheduledstatuses/scheduledstatusesget.go b/internal/api/client/scheduledstatuses/scheduledstatusesget.go new file mode 100644 index 000000000..6d08ff18b --- /dev/null +++ b/internal/api/client/scheduledstatuses/scheduledstatusesget.go @@ -0,0 +1,136 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package scheduledstatuses + +import ( + "net/http" + + apiutil "code.superseriousbusiness.org/gotosocial/internal/api/util" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/paging" + "github.com/gin-gonic/gin" +) + +// ScheduledStatusesGETHandler swagger:operation GET /api/v1/scheduled_statuses getScheduledStatuses +// +// Get an array of statuses scheduled by authorized user. +// +// --- +// tags: +// - scheduled_statuses +// +// produces: +// - application/json +// +// parameters: +// - +// name: max_id +// type: string +// description: >- +// Return only statuses *OLDER* than the given max status ID. +// The status with the specified ID will not be included in the response. +// in: query +// required: false +// - +// name: since_id +// type: string +// description: >- +// Return only statuses *newer* than the given since status ID. +// The status with the specified ID will not be included in the response. +// in: query +// - +// name: min_id +// type: string +// description: >- +// Return only statuses *immediately newer* than the given min ID. +// The status with the specified ID will not be included in the response. +// in: query +// required: false +// - +// name: limit +// type: integer +// description: Number of scheduled statuses to return. +// default: 20 +// in: query +// required: false +// +// security: +// - OAuth2 Bearer: +// - read:statuses +// +// responses: +// '200': +// headers: +// Link: +// type: string +// description: Links to the next and previous queries. +// schema: +// type: array +// items: +// "$ref": "#/definitions/scheduledStatus" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) ScheduledStatusesGETHandler(c *gin.Context) { + authed, errWithCode := apiutil.TokenAuth(c, + true, true, true, true, + apiutil.ScopeReadStatuses, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + page, errWithCode := paging.ParseIDPage(c, + 1, // min limit + 80, // max limit + 20, // default limit + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + resp, errWithCode := m.processor.Status().ScheduledStatusesGetPage( + c.Request.Context(), + authed.Account, + page, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + if resp.LinkHeader != "" { + c.Header("Link", resp.LinkHeader) + } + + apiutil.JSON(c, http.StatusOK, resp.Items) +} diff --git a/internal/api/client/scheduledstatuses/scheduledstatusget.go b/internal/api/client/scheduledstatuses/scheduledstatusget.go new file mode 100644 index 000000000..893c44938 --- /dev/null +++ b/internal/api/client/scheduledstatuses/scheduledstatusget.go @@ -0,0 +1,98 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package scheduledstatuses + +import ( + "net/http" + + apiutil "code.superseriousbusiness.org/gotosocial/internal/api/util" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + + "github.com/gin-gonic/gin" +) + +// ScheduledStatusGETHandler swagger:operation GET /api/v1/scheduled_statuses/{id} getScheduledStatus +// +// Get a scheduled status with the given id. +// +// --- +// tags: +// - scheduled_statuses +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: string +// description: ID of the status +// in: path +// required: true +// +// security: +// - OAuth2 Bearer: +// - read:statuses +// +// responses: +// '200': +// schema: +// "$ref": "#/definitions/scheduledStatus" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) ScheduledStatusGETHandler(c *gin.Context) { + authed, errWithCode := apiutil.TokenAuth(c, + true, true, true, true, + apiutil.ScopeReadStatuses, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + targetScheduledStatusID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + scheduledStatus, errWithCode := m.processor.Status().ScheduledStatusesGetOne( + c.Request.Context(), + authed.Account, + targetScheduledStatusID, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusOK, scheduledStatus) +} diff --git a/internal/api/client/scheduledstatuses/scheduledstatusput.go b/internal/api/client/scheduledstatuses/scheduledstatusput.go new file mode 100644 index 000000000..f037716e7 --- /dev/null +++ b/internal/api/client/scheduledstatuses/scheduledstatusput.go @@ -0,0 +1,131 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package scheduledstatuses + +import ( + "net/http" + "time" + + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + apiutil "code.superseriousbusiness.org/gotosocial/internal/api/util" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + + "github.com/gin-gonic/gin" +) + +// ScheduledStatusPUTHandler swagger:operation PUT /api/v1/scheduled_statuses/{id} updateScheduledStatus +// +// Update a scheduled status's publishing date +// +// --- +// tags: +// - scheduled_statuses +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: string +// description: ID of the status +// in: path +// required: true +// - +// name: scheduled_at +// x-go-name: ScheduledAt +// description: |- +// ISO 8601 Datetime at which to schedule a status. +// +// Must be at least 5 minutes in the future. +// type: string +// format: date-time +// in: formData +// +// security: +// - OAuth2 Bearer: +// - write:statuses +// +// responses: +// '200': +// schema: +// "$ref": "#/definitions/scheduledStatus" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '422': +// description: unprocessable content +// '500': +// description: internal server error +func (m *Module) ScheduledStatusPUTHandler(c *gin.Context) { + authed, errWithCode := apiutil.TokenAuth(c, + true, true, true, true, + apiutil.ScopeWriteStatuses, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, 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 + } + + targetScheduledStatusID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + form := &apimodel.ScheduledStatusUpdateRequest{} + if err := c.ShouldBind(form); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) + return + } + + now := time.Now() + if !now.Add(5 * time.Minute).Before(*form.ScheduledAt) { + const errText = "scheduled_at must be at least 5 minutes in the future" + apiutil.ErrorHandler(c, gtserror.NewErrorUnprocessableEntity(gtserror.New(errText), errText), m.processor.InstanceGetV1) + return + } + + scheduledStatus, errWithCode := m.processor.Status().ScheduledStatusesUpdate( + c.Request.Context(), + authed.Account, + targetScheduledStatusID, + form.ScheduledAt, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusOK, scheduledStatus) +} diff --git a/internal/api/client/statuses/statuscreate.go b/internal/api/client/statuses/statuscreate.go index 048acd421..167b59b23 100644 --- a/internal/api/client/statuses/statuscreate.go +++ b/internal/api/client/statuses/statuscreate.go @@ -181,7 +181,6 @@ import ( // // Providing this parameter with a *future* time will cause ScheduledStatus to be returned instead of Status. // Must be at least 5 minutes in the future. -// This feature isn't implemented yet. // // Providing this parameter with a *past* time will cause the status to be backdated, // and will not push it to the user's followers. This is intended for importing old statuses. @@ -256,6 +255,8 @@ import ( // description: not found // '406': // description: not acceptable +// '422': +// description: unprocessable content // '500': // description: internal server error // '501': @@ -300,7 +301,9 @@ func (m *Module) StatusCreatePOSTHandler(c *gin.Context) { authed.Account, authed.Application, form, + nil, ) + if errWithCode != nil { apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) return diff --git a/internal/api/client/statuses/statuscreate_test.go b/internal/api/client/statuses/statuscreate_test.go index 090dae593..84a6622a2 100644 --- a/internal/api/client/statuses/statuscreate_test.go +++ b/internal/api/client/statuses/statuscreate_test.go @@ -476,13 +476,24 @@ func (suite *StatusCreateTestSuite) TestPostNewScheduledStatus() { "scheduled_at": {"2080-10-04T15:32:02.018Z"}, }, "") - // We should have 501 from + // We should have OK from // our call to the function. - suite.Equal(http.StatusNotImplemented, recorder.Code) + suite.Equal(http.StatusOK, recorder.Code) - // We should have a helpful error message. + // A scheduled status with scheduled_at and status params should be returned. suite.Equal(`{ - "error": "Not Implemented: scheduled statuses are not yet supported" + "id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ", + "media_attachments": [], + "params": { + "application_id": "01F8MGY43H3N2C8EWPR2FPYEXG", + "language": "", + "scheduled_at": null, + "sensitive": true, + "spoiler_text": "hello hello", + "text": "this is a brand new status! #helloworld", + "visibility": "private" + }, + "scheduled_at": "2080-10-04T15:32:02.018Z" }`, out) } diff --git a/internal/api/model/scheduledstatus.go b/internal/api/model/scheduledstatus.go index 1b074ab55..4acfde697 100644 --- a/internal/api/model/scheduledstatus.go +++ b/internal/api/model/scheduledstatus.go @@ -17,22 +17,46 @@ package model +import "time" + // ScheduledStatus represents a status that will be published at a future scheduled date. +// +// swagger:model scheduledStatus type ScheduledStatus struct { - ID string `json:"id"` - ScheduledAt string `json:"scheduled_at"` - Params *StatusParams `json:"params"` - MediaAttachments []Attachment `json:"media_attachments"` + ID string `json:"id"` + ScheduledAt string `json:"scheduled_at"` + Params *ScheduledStatusParams `json:"params"` + MediaAttachments []*Attachment `json:"media_attachments"` } // StatusParams represents parameters for a scheduled status. -type StatusParams struct { - Text string `json:"text"` - InReplyToID string `json:"in_reply_to_id,omitempty"` - MediaIDs []string `json:"media_ids,omitempty"` - Sensitive bool `json:"sensitive,omitempty"` - SpoilerText string `json:"spoiler_text,omitempty"` - Visibility string `json:"visibility"` - ScheduledAt string `json:"scheduled_at,omitempty"` - ApplicationID string `json:"application_id"` +type ScheduledStatusParams struct { + Text string `json:"text"` + MediaIDs []string `json:"media_ids,omitempty"` + Sensitive bool `json:"sensitive,omitempty"` + Poll *ScheduledStatusParamsPoll `json:"poll,omitempty"` + SpoilerText string `json:"spoiler_text,omitempty"` + Visibility Visibility `json:"visibility"` + InReplyToID string `json:"in_reply_to_id,omitempty"` + Language string `json:"language"` + ApplicationID string `json:"application_id"` + LocalOnly bool `json:"local_only,omitempty"` + ContentType StatusContentType `json:"content_type,omitempty"` + InteractionPolicy *InteractionPolicy `json:"interaction_policy,omitempty"` + ScheduledAt *string `json:"scheduled_at"` +} + +type ScheduledStatusParamsPoll struct { + Options []string `json:"options"` + ExpiresIn int `json:"expires_in"` + Multiple bool `json:"multiple"` + HideTotals bool `json:"hide_totals"` +} + +// ScheduledStatusUpdateRequest models a request to update the scheduled status publication date. +// +// swagger:ignore +type ScheduledStatusUpdateRequest struct { + // ISO 8601 Datetime at which to schedule a status. + ScheduledAt *time.Time `form:"scheduled_at" json:"scheduled_at"` } diff --git a/internal/api/model/status.go b/internal/api/model/status.go index 4561996ad..3edb4be5f 100644 --- a/internal/api/model/status.go +++ b/internal/api/model/status.go @@ -252,7 +252,6 @@ type StatusCreateRequest struct { // // Providing this parameter with a *future* time will cause ScheduledStatus to be returned instead of Status. // Must be at least 5 minutes in the future. - // This feature isn't implemented yet. // // Providing this parameter with a *past* time will cause the status to be backdated, // and will not push it to the user's followers. This is intended for importing old statuses. diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 5611ddec0..2cc07de96 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -113,6 +113,7 @@ func (c *Caches) Init() { c.initPollVote() c.initPollVoteIDs() c.initReport() + c.initScheduledStatus() c.initSinBinStatus() c.initStatus() c.initStatusBookmark() @@ -200,6 +201,7 @@ func (c *Caches) Sweep(threshold float64) { c.DB.PollVote.Trim(threshold) c.DB.PollVoteIDs.Trim(threshold) c.DB.Report.Trim(threshold) + c.DB.ScheduledStatus.Trim(threshold) c.DB.SinBinStatus.Trim(threshold) c.DB.Status.Trim(threshold) c.DB.StatusBookmark.Trim(threshold) diff --git a/internal/cache/db.go b/internal/cache/db.go index d31017ccd..385c5bcbb 100644 --- a/internal/cache/db.go +++ b/internal/cache/db.go @@ -219,6 +219,9 @@ type DBCaches struct { // Report provides access to the gtsmodel Report database cache. Report StructCache[*gtsmodel.Report] + // ScheduledStatus provides access to the gtsmodel ScheduledStatus database cache. + ScheduledStatus StructCache[*gtsmodel.ScheduledStatus] + // SinBinStatus provides access to the gtsmodel SinBinStatus database cache. SinBinStatus StructCache[*gtsmodel.SinBinStatus] @@ -1287,6 +1290,40 @@ func (c *Caches) initReport() { }) } +func (c *Caches) initScheduledStatus() { + // Calculate maximum cache size. + cap := calculateResultCacheMax( + sizeofScheduledStatus(), // model in-mem size. + config.GetCacheScheduledStatusMemRatio(), + ) + + log.Infof(nil, "cache size = %d", cap) + + copyF := func(s1 *gtsmodel.ScheduledStatus) *gtsmodel.ScheduledStatus { + s2 := new(gtsmodel.ScheduledStatus) + *s2 = *s1 + + // Don't include ptr fields that + // will be populated separately. + s2.Account = nil + s2.Application = nil + s2.MediaAttachments = nil + + return s2 + } + + c.DB.ScheduledStatus.Init(structr.CacheConfig[*gtsmodel.ScheduledStatus]{ + Indices: []structr.IndexConfig{ + {Fields: "ID"}, + {Fields: "AccountID", Multiple: true}, + }, + MaxSize: cap, + IgnoreErr: ignoreErrors, + Copy: copyF, + Invalidate: c.OnInvalidateScheduledStatus, + }) +} + func (c *Caches) initSinBinStatus() { // Calculate maximum cache size. cap := calculateResultCacheMax( diff --git a/internal/cache/invalidate.go b/internal/cache/invalidate.go index 863719b77..c6c25d4eb 100644 --- a/internal/cache/invalidate.go +++ b/internal/cache/invalidate.go @@ -292,6 +292,11 @@ func (c *Caches) OnInvalidatePollVote(vote *gtsmodel.PollVote) { c.DB.PollVoteIDs.Invalidate(vote.PollID) } +func (c *Caches) OnInvalidateScheduledStatus(status *gtsmodel.ScheduledStatus) { + // Invalidate cache of related media attachments. + c.DB.Media.InvalidateIDs("ID", status.MediaIDs) +} + func (c *Caches) OnInvalidateStatus(status *gtsmodel.Status) { // Invalidate cached stats objects for this account. c.DB.AccountStats.Invalidate("AccountID", status.AccountID) diff --git a/internal/cache/size.go b/internal/cache/size.go index ab54ada87..aa22b03d7 100644 --- a/internal/cache/size.go +++ b/internal/cache/size.go @@ -554,6 +554,25 @@ func sizeofReport() uintptr { })) } +func sizeofScheduledStatus() uintptr { + return uintptr(size.Of(>smodel.ScheduledStatus{ + ID: exampleID, + AccountID: exampleID, + ScheduledAt: exampleTime, + Text: exampleText, + Poll: gtsmodel.ScheduledStatusPoll{ + Options: []string{exampleTextSmall, exampleTextSmall, exampleTextSmall, exampleTextSmall}, + Multiple: util.Ptr(false), + HideTotals: util.Ptr(false), + }, + MediaIDs: []string{exampleID, exampleID, exampleID}, + Sensitive: util.Ptr(false), + SpoilerText: exampleText, + Visibility: gtsmodel.VisibilityPublic, + Language: "en", + })) +} + func sizeofSinBinStatus() uintptr { return uintptr(size.Of(>smodel.SinBinStatus{ ID: exampleID, diff --git a/internal/cleaner/media.go b/internal/cleaner/media.go index 84473bc22..99fd5779f 100644 --- a/internal/cleaner/media.go +++ b/internal/cleaner/media.go @@ -375,6 +375,25 @@ func (m *Media) pruneUnused(ctx context.Context, media *gtsmodel.MediaAttachment } } + // Check whether we have the required scheduled status for media. + scheduledStatus, missing, err := m.getRelatedScheduledStatus(ctx, media) + if err != nil { + return false, err + } else if missing { + l.Debug("deleting due to missing scheduled status") + return true, m.delete(ctx, media) + } + + if scheduledStatus != nil { + // Check whether still attached to status. + for _, id := range scheduledStatus.MediaIDs { + if id == media.ID { + l.Debug("skippping as attached to scheduled status") + return false, nil + } + } + } + // Media totally unused, delete it. l.Debug("deleting unused media") return true, m.delete(ctx, media) @@ -543,6 +562,29 @@ func (m *Media) getRelatedStatus(ctx context.Context, media *gtsmodel.MediaAttac return status, false, nil } +func (m *Media) getRelatedScheduledStatus(ctx context.Context, media *gtsmodel.MediaAttachment) (*gtsmodel.ScheduledStatus, bool, error) { + if media.ScheduledStatusID == "" { + // no related status. + return nil, false, nil + } + + // Load the status related to this media. + status, err := m.state.DB.GetScheduledStatusByID( + gtscontext.SetBarebones(ctx), + media.ScheduledStatusID, + ) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return nil, false, gtserror.Newf("error fetching scheduled status by id %s: %w", media.ScheduledStatusID, err) + } + + if status == nil { + // status is missing. + return nil, true, nil + } + + return status, false, nil +} + func (m *Media) uncache(ctx context.Context, media *gtsmodel.MediaAttachment) error { if gtscontext.DryRun(ctx) { // Dry run, do nothing. diff --git a/internal/config/config.go b/internal/config/config.go index f7a99c35f..3cab53732 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -131,6 +131,9 @@ type Configuration struct { StatusesPollOptionMaxChars int `name:"statuses-poll-option-max-chars" usage:"Max amount of characters for a poll option"` StatusesMediaMaxFiles int `name:"statuses-media-max-files" usage:"Maximum number of media files/attachments per status"` + ScheduledStatusesMaxTotal int `name:"scheduled-statuses-max-total" usage:"Maximum number of scheduled statuses per user"` + ScheduledStatusesMaxDaily int `name:"scheduled-statuses-max-daily" usage:"Maximum number of scheduled statuses per user for a single day"` + LetsEncryptEnabled bool `name:"letsencrypt-enabled" usage:"Enable letsencrypt TLS certs for this server. If set to true, then cert dir also needs to be set (or take the default)."` LetsEncryptPort int `name:"letsencrypt-port" usage:"Port to listen on for letsencrypt certificate challenges. Must not be the same as the GtS webserver/API port."` LetsEncryptCertDir string `name:"letsencrypt-cert-dir" usage:"Directory to store acquired letsencrypt certificates."` @@ -252,6 +255,7 @@ type CacheConfiguration struct { PollVoteMemRatio float64 `name:"poll-vote-mem-ratio"` PollVoteIDsMemRatio float64 `name:"poll-vote-ids-mem-ratio"` ReportMemRatio float64 `name:"report-mem-ratio"` + ScheduledStatusMemRatio float64 `name:"scheduled-status-mem-ratio"` SinBinStatusMemRatio float64 `name:"sin-bin-status-mem-ratio"` StatusMemRatio float64 `name:"status-mem-ratio"` StatusBookmarkMemRatio float64 `name:"status-bookmark-mem-ratio"` diff --git a/internal/config/defaults.go b/internal/config/defaults.go index df3b64b40..f140d7877 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -105,6 +105,9 @@ var Defaults = Configuration{ StatusesPollOptionMaxChars: 50, StatusesMediaMaxFiles: 6, + ScheduledStatusesMaxTotal: 300, + ScheduledStatusesMaxDaily: 25, + LetsEncryptEnabled: false, LetsEncryptPort: 80, LetsEncryptCertDir: "/gotosocial/storage/certs", @@ -217,6 +220,7 @@ var Defaults = Configuration{ PollVoteMemRatio: 2, PollVoteIDsMemRatio: 2, ReportMemRatio: 1, + ScheduledStatusMemRatio: 4, SinBinStatusMemRatio: 0.5, StatusMemRatio: 5, StatusBookmarkMemRatio: 0.5, diff --git a/internal/config/helpers.gen.go b/internal/config/helpers.gen.go index a5cbf4c46..9f5d6f39c 100644 --- a/internal/config/helpers.gen.go +++ b/internal/config/helpers.gen.go @@ -99,6 +99,8 @@ const ( StatusesPollMaxOptionsFlag = "statuses-poll-max-options" StatusesPollOptionMaxCharsFlag = "statuses-poll-option-max-chars" StatusesMediaMaxFilesFlag = "statuses-media-max-files" + ScheduledStatusesMaxTotalFlag = "scheduled-statuses-max-total" + ScheduledStatusesMaxDailyFlag = "scheduled-statuses-max-daily" LetsEncryptEnabledFlag = "letsencrypt-enabled" LetsEncryptPortFlag = "letsencrypt-port" LetsEncryptCertDirFlag = "letsencrypt-cert-dir" @@ -194,6 +196,7 @@ const ( CachePollVoteMemRatioFlag = "cache-poll-vote-mem-ratio" CachePollVoteIDsMemRatioFlag = "cache-poll-vote-ids-mem-ratio" CacheReportMemRatioFlag = "cache-report-mem-ratio" + CacheScheduledStatusMemRatioFlag = "cache-scheduled-status-mem-ratio" CacheSinBinStatusMemRatioFlag = "cache-sin-bin-status-mem-ratio" CacheStatusMemRatioFlag = "cache-status-mem-ratio" CacheStatusBookmarkMemRatioFlag = "cache-status-bookmark-mem-ratio" @@ -296,6 +299,8 @@ func (cfg *Configuration) RegisterFlags(flags *pflag.FlagSet) { flags.Int("statuses-poll-max-options", cfg.StatusesPollMaxOptions, "Max amount of options permitted on a poll") flags.Int("statuses-poll-option-max-chars", cfg.StatusesPollOptionMaxChars, "Max amount of characters for a poll option") flags.Int("statuses-media-max-files", cfg.StatusesMediaMaxFiles, "Maximum number of media files/attachments per status") + flags.Int("scheduled-statuses-max-total", cfg.ScheduledStatusesMaxTotal, "Maximum number of scheduled statuses per user") + flags.Int("scheduled-statuses-max-daily", cfg.ScheduledStatusesMaxDaily, "Maximum number of scheduled statuses per user for a single day") flags.Bool("letsencrypt-enabled", cfg.LetsEncryptEnabled, "Enable letsencrypt TLS certs for this server. If set to true, then cert dir also needs to be set (or take the default).") flags.Int("letsencrypt-port", cfg.LetsEncryptPort, "Port to listen on for letsencrypt certificate challenges. Must not be the same as the GtS webserver/API port.") flags.String("letsencrypt-cert-dir", cfg.LetsEncryptCertDir, "Directory to store acquired letsencrypt certificates.") @@ -391,6 +396,7 @@ func (cfg *Configuration) RegisterFlags(flags *pflag.FlagSet) { flags.Float64("cache-poll-vote-mem-ratio", cfg.Cache.PollVoteMemRatio, "") flags.Float64("cache-poll-vote-ids-mem-ratio", cfg.Cache.PollVoteIDsMemRatio, "") flags.Float64("cache-report-mem-ratio", cfg.Cache.ReportMemRatio, "") + flags.Float64("cache-scheduled-status-mem-ratio", cfg.Cache.ScheduledStatusMemRatio, "") flags.Float64("cache-sin-bin-status-mem-ratio", cfg.Cache.SinBinStatusMemRatio, "") flags.Float64("cache-status-mem-ratio", cfg.Cache.StatusMemRatio, "") flags.Float64("cache-status-bookmark-mem-ratio", cfg.Cache.StatusBookmarkMemRatio, "") @@ -414,7 +420,7 @@ func (cfg *Configuration) RegisterFlags(flags *pflag.FlagSet) { } func (cfg *Configuration) MarshalMap() map[string]any { - cfgmap := make(map[string]any, 194) + cfgmap := make(map[string]any, 197) cfgmap["log-level"] = cfg.LogLevel cfgmap["log-format"] = cfg.LogFormat cfgmap["log-timestamp-format"] = cfg.LogTimestampFormat @@ -485,6 +491,8 @@ func (cfg *Configuration) MarshalMap() map[string]any { cfgmap["statuses-poll-max-options"] = cfg.StatusesPollMaxOptions cfgmap["statuses-poll-option-max-chars"] = cfg.StatusesPollOptionMaxChars cfgmap["statuses-media-max-files"] = cfg.StatusesMediaMaxFiles + cfgmap["scheduled-statuses-max-total"] = cfg.ScheduledStatusesMaxTotal + cfgmap["scheduled-statuses-max-daily"] = cfg.ScheduledStatusesMaxDaily cfgmap["letsencrypt-enabled"] = cfg.LetsEncryptEnabled cfgmap["letsencrypt-port"] = cfg.LetsEncryptPort cfgmap["letsencrypt-cert-dir"] = cfg.LetsEncryptCertDir @@ -580,6 +588,7 @@ func (cfg *Configuration) MarshalMap() map[string]any { cfgmap["cache-poll-vote-mem-ratio"] = cfg.Cache.PollVoteMemRatio cfgmap["cache-poll-vote-ids-mem-ratio"] = cfg.Cache.PollVoteIDsMemRatio cfgmap["cache-report-mem-ratio"] = cfg.Cache.ReportMemRatio + cfgmap["cache-scheduled-status-mem-ratio"] = cfg.Cache.ScheduledStatusMemRatio cfgmap["cache-sin-bin-status-mem-ratio"] = cfg.Cache.SinBinStatusMemRatio cfgmap["cache-status-mem-ratio"] = cfg.Cache.StatusMemRatio cfgmap["cache-status-bookmark-mem-ratio"] = cfg.Cache.StatusBookmarkMemRatio @@ -1186,6 +1195,22 @@ func (cfg *Configuration) UnmarshalMap(cfgmap map[string]any) error { } } + if ival, ok := cfgmap["scheduled-statuses-max-total"]; ok { + var err error + cfg.ScheduledStatusesMaxTotal, err = cast.ToIntE(ival) + if err != nil { + return fmt.Errorf("error casting %#v -> int for 'scheduled-statuses-max-total': %w", ival, err) + } + } + + if ival, ok := cfgmap["scheduled-statuses-max-daily"]; ok { + var err error + cfg.ScheduledStatusesMaxDaily, err = cast.ToIntE(ival) + if err != nil { + return fmt.Errorf("error casting %#v -> int for 'scheduled-statuses-max-daily': %w", ival, err) + } + } + if ival, ok := cfgmap["letsencrypt-enabled"]; ok { var err error cfg.LetsEncryptEnabled, err = cast.ToBoolE(ival) @@ -1972,6 +1997,14 @@ func (cfg *Configuration) UnmarshalMap(cfgmap map[string]any) error { } } + if ival, ok := cfgmap["cache-scheduled-status-mem-ratio"]; ok { + var err error + cfg.Cache.ScheduledStatusMemRatio, err = cast.ToFloat64E(ival) + if err != nil { + return fmt.Errorf("error casting %#v -> float64 for 'cache-scheduled-status-mem-ratio': %w", ival, err) + } + } + if ival, ok := cfgmap["cache-sin-bin-status-mem-ratio"]; ok { var err error cfg.Cache.SinBinStatusMemRatio, err = cast.ToFloat64E(ival) @@ -3753,6 +3786,50 @@ func GetStatusesMediaMaxFiles() int { return global.GetStatusesMediaMaxFiles() } // SetStatusesMediaMaxFiles safely sets the value for global configuration 'StatusesMediaMaxFiles' field func SetStatusesMediaMaxFiles(v int) { global.SetStatusesMediaMaxFiles(v) } +// GetScheduledStatusesMaxTotal safely fetches the Configuration value for state's 'ScheduledStatusesMaxTotal' field +func (st *ConfigState) GetScheduledStatusesMaxTotal() (v int) { + st.mutex.RLock() + v = st.config.ScheduledStatusesMaxTotal + st.mutex.RUnlock() + return +} + +// SetScheduledStatusesMaxTotal safely sets the Configuration value for state's 'ScheduledStatusesMaxTotal' field +func (st *ConfigState) SetScheduledStatusesMaxTotal(v int) { + st.mutex.Lock() + defer st.mutex.Unlock() + st.config.ScheduledStatusesMaxTotal = v + st.reloadToViper() +} + +// GetScheduledStatusesMaxTotal safely fetches the value for global configuration 'ScheduledStatusesMaxTotal' field +func GetScheduledStatusesMaxTotal() int { return global.GetScheduledStatusesMaxTotal() } + +// SetScheduledStatusesMaxTotal safely sets the value for global configuration 'ScheduledStatusesMaxTotal' field +func SetScheduledStatusesMaxTotal(v int) { global.SetScheduledStatusesMaxTotal(v) } + +// GetScheduledStatusesMaxDaily safely fetches the Configuration value for state's 'ScheduledStatusesMaxDaily' field +func (st *ConfigState) GetScheduledStatusesMaxDaily() (v int) { + st.mutex.RLock() + v = st.config.ScheduledStatusesMaxDaily + st.mutex.RUnlock() + return +} + +// SetScheduledStatusesMaxDaily safely sets the Configuration value for state's 'ScheduledStatusesMaxDaily' field +func (st *ConfigState) SetScheduledStatusesMaxDaily(v int) { + st.mutex.Lock() + defer st.mutex.Unlock() + st.config.ScheduledStatusesMaxDaily = v + st.reloadToViper() +} + +// GetScheduledStatusesMaxDaily safely fetches the value for global configuration 'ScheduledStatusesMaxDaily' field +func GetScheduledStatusesMaxDaily() int { return global.GetScheduledStatusesMaxDaily() } + +// SetScheduledStatusesMaxDaily safely sets the value for global configuration 'ScheduledStatusesMaxDaily' field +func SetScheduledStatusesMaxDaily(v int) { global.SetScheduledStatusesMaxDaily(v) } + // GetLetsEncryptEnabled safely fetches the Configuration value for state's 'LetsEncryptEnabled' field func (st *ConfigState) GetLetsEncryptEnabled() (v bool) { st.mutex.RLock() @@ -5859,6 +5936,28 @@ func GetCacheReportMemRatio() float64 { return global.GetCacheReportMemRatio() } // SetCacheReportMemRatio safely sets the value for global configuration 'Cache.ReportMemRatio' field func SetCacheReportMemRatio(v float64) { global.SetCacheReportMemRatio(v) } +// GetCacheScheduledStatusMemRatio safely fetches the Configuration value for state's 'Cache.ScheduledStatusMemRatio' field +func (st *ConfigState) GetCacheScheduledStatusMemRatio() (v float64) { + st.mutex.RLock() + v = st.config.Cache.ScheduledStatusMemRatio + st.mutex.RUnlock() + return +} + +// SetCacheScheduledStatusMemRatio safely sets the Configuration value for state's 'Cache.ScheduledStatusMemRatio' field +func (st *ConfigState) SetCacheScheduledStatusMemRatio(v float64) { + st.mutex.Lock() + defer st.mutex.Unlock() + st.config.Cache.ScheduledStatusMemRatio = v + st.reloadToViper() +} + +// GetCacheScheduledStatusMemRatio safely fetches the value for global configuration 'Cache.ScheduledStatusMemRatio' field +func GetCacheScheduledStatusMemRatio() float64 { return global.GetCacheScheduledStatusMemRatio() } + +// SetCacheScheduledStatusMemRatio safely sets the value for global configuration 'Cache.ScheduledStatusMemRatio' field +func SetCacheScheduledStatusMemRatio(v float64) { global.SetCacheScheduledStatusMemRatio(v) } + // GetCacheSinBinStatusMemRatio safely fetches the Configuration value for state's 'Cache.SinBinStatusMemRatio' field func (st *ConfigState) GetCacheSinBinStatusMemRatio() (v float64) { st.mutex.RLock() @@ -6545,6 +6644,7 @@ func (st *ConfigState) GetTotalOfMemRatios() (total float64) { total += st.config.Cache.PollVoteMemRatio total += st.config.Cache.PollVoteIDsMemRatio total += st.config.Cache.ReportMemRatio + total += st.config.Cache.ScheduledStatusMemRatio total += st.config.Cache.SinBinStatusMemRatio total += st.config.Cache.StatusMemRatio total += st.config.Cache.StatusBookmarkMemRatio @@ -7329,6 +7429,17 @@ func flattenConfigMap(cfgmap map[string]any) { } for _, key := range [][]string{ + {"cache", "scheduled-status-mem-ratio"}, + } { + ival, ok := mapGet(cfgmap, key...) + if ok { + cfgmap["cache-scheduled-status-mem-ratio"] = ival + nestedKeys[key[0]] = struct{}{} + break + } + } + + for _, key := range [][]string{ {"cache", "sin-bin-status-mem-ratio"}, } { ival, ok := mapGet(cfgmap, key...) diff --git a/internal/config/testdata/test.json b/internal/config/testdata/test.json index 9bbedd36a..28b0d1867 100644 --- a/internal/config/testdata/test.json +++ b/internal/config/testdata/test.json @@ -49,6 +49,8 @@ "statuses-media-max-files": 6, "statuses-poll-max-options": 6, "statuses-poll-option-max-chars": 50, + "scheduled-statuses-max-total": 300, + "scheduled-statuses-max-daily": 25, "storage-backend": "local", "storage-local-base-path": "/gotosocial/storage", "trusted-proxies": [ diff --git a/internal/config/testdata/test.yaml b/internal/config/testdata/test.yaml index 09762215d..bad9b00d0 100644 --- a/internal/config/testdata/test.yaml +++ b/internal/config/testdata/test.yaml @@ -243,6 +243,16 @@ statuses-poll-option-max-chars: 50 # Default: 6 statuses-media-max-files: 6 +# Int. Maximum number of statuses a user can schedule at time. +# Examples: [300] +# Default: 300 +scheduled-statuses-max-total: 300 + +# Int. Maximum number of statuses a user can schedule for a single day. +# Examples: [25] +# Default: 25 +scheduled-statuses-max-daily: 25 + ############################## ##### LETSENCRYPT CONFIG ##### ############################## diff --git a/internal/db/bundb/bundb.go b/internal/db/bundb/bundb.go index 6545414a7..39547b1ae 100644 --- a/internal/db/bundb/bundb.go +++ b/internal/db/bundb/bundb.go @@ -76,6 +76,7 @@ type DBService struct { db.Relationship db.Report db.Rule + db.ScheduledStatus db.Search db.Session db.SinBinStatus @@ -261,6 +262,10 @@ func NewBunDBService(ctx context.Context, state *state.State) (db.DB, error) { db: db, state: state, }, + ScheduledStatus: &scheduledStatusDB{ + db: db, + state: state, + }, Search: &searchDB{ db: db, state: state, diff --git a/internal/db/bundb/migrations/20250531213700_add_scheduled_statuses.go b/internal/db/bundb/migrations/20250531213700_add_scheduled_statuses.go new file mode 100644 index 000000000..7b124fa77 --- /dev/null +++ b/internal/db/bundb/migrations/20250531213700_add_scheduled_statuses.go @@ -0,0 +1,67 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package migrations + +import ( + "context" + + gtsmodel "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "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 { + if _, err := tx. + NewCreateTable(). + Model(>smodel.ScheduledStatus{}). + IfNotExists(). + Exec(ctx); err != nil { + return err + } + + // Add indexes to the scheduled statuses tables. + for index, columns := range map[string][]string{ + "scheduled_statuses_account_id_idx": {"account_id"}, + "scheduled_statuses_scheduled_at_idx": {"scheduled_at"}, + } { + if _, err := tx. + NewCreateIndex(). + Table("scheduled_statuses"). + Index(index). + Column(columns...). + IfNotExists(). + Exec(ctx); err != nil { + return err + } + } + + return nil + }) + } + + 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/scheduledstatus.go b/internal/db/bundb/scheduledstatus.go new file mode 100644 index 000000000..44cbd0f59 --- /dev/null +++ b/internal/db/bundb/scheduledstatus.go @@ -0,0 +1,402 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package bundb + +import ( + "context" + "errors" + "slices" + "time" + + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/gtscontext" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/log" + "code.superseriousbusiness.org/gotosocial/internal/paging" + "code.superseriousbusiness.org/gotosocial/internal/state" + "code.superseriousbusiness.org/gotosocial/internal/util/xslices" + "github.com/uptrace/bun" +) + +type scheduledStatusDB struct { + db *bun.DB + state *state.State +} + +func (s *scheduledStatusDB) GetAllScheduledStatuses(ctx context.Context) ([]*gtsmodel.ScheduledStatus, error) { + + var statusIDs []string + + // Select ALL token IDs. + if err := s.db.NewSelect(). + Table("scheduled_statuses"). + Column("id"). + Scan(ctx, &statusIDs); err != nil { + return nil, err + } + + return s.GetScheduledStatusesByIDs(ctx, statusIDs) +} + +func (s *scheduledStatusDB) GetScheduledStatusByID(ctx context.Context, id string) (*gtsmodel.ScheduledStatus, error) { + return s.getScheduledStatus( + ctx, + "ID", + func(scheduledStatus *gtsmodel.ScheduledStatus) error { + return s.db. + NewSelect(). + Model(scheduledStatus). + Where("? = ?", bun.Ident("scheduled_status.id"), id). + Scan(ctx) + }, + id, + ) +} + +func (s *scheduledStatusDB) getScheduledStatus( + ctx context.Context, + lookup string, + dbQuery func(*gtsmodel.ScheduledStatus) error, + keyParts ...any, +) (*gtsmodel.ScheduledStatus, error) { + // Fetch scheduled status from database cache with loader callback + scheduledStatus, err := s.state.Caches.DB.ScheduledStatus.LoadOne(lookup, func() (*gtsmodel.ScheduledStatus, error) { + var scheduledStatus gtsmodel.ScheduledStatus + + // Not cached! Perform database query + if err := dbQuery(&scheduledStatus); err != nil { + return nil, err + } + + return &scheduledStatus, nil + }, keyParts...) + if err != nil { + // Error already processed. + return nil, err + } + + if gtscontext.Barebones(ctx) { + // Only a barebones model was requested. + return scheduledStatus, nil + } + + if err := s.PopulateScheduledStatus(ctx, scheduledStatus); err != nil { + return nil, err + } + + return scheduledStatus, nil +} + +func (s *scheduledStatusDB) PopulateScheduledStatus(ctx context.Context, status *gtsmodel.ScheduledStatus) error { + var ( + err error + errs = gtserror.NewMultiError(1) + ) + + if status.Account == nil { + status.Account, err = s.state.DB.GetAccountByID( + gtscontext.SetBarebones(ctx), + status.AccountID, + ) + if err != nil { + errs.Appendf("error populating scheduled status author account: %w", err) + } + } + + if status.Application == nil { + status.Application, err = s.state.DB.GetApplicationByID( + gtscontext.SetBarebones(ctx), + status.ApplicationID, + ) + if err != nil { + errs.Appendf("error populating scheduled status application: %w", err) + } + } + + if !status.AttachmentsPopulated() { + // Status attachments are out-of-date with IDs, repopulate. + status.MediaAttachments, err = s.state.DB.GetAttachmentsByIDs( + gtscontext.SetBarebones(ctx), + status.MediaIDs, + ) + if err != nil { + errs.Appendf("error populating status attachments: %w", err) + } + } + + return errs.Combine() +} + +func (s *scheduledStatusDB) GetScheduledStatusesByIDs(ctx context.Context, ids []string) ([]*gtsmodel.ScheduledStatus, error) { + // Load all scheduled status IDs via cache loader callbacks. + statuses, err := s.state.Caches.DB.ScheduledStatus.LoadIDs("ID", + ids, + func(uncached []string) ([]*gtsmodel.ScheduledStatus, error) { + // Preallocate expected length of uncached scheduled statuses. + statuses := make([]*gtsmodel.ScheduledStatus, 0, len(uncached)) + + // Perform database query scanning + // the remaining (uncached) IDs. + if err := s.db.NewSelect(). + Model(&statuses). + Where("? IN (?)", bun.Ident("id"), bun.In(uncached)). + Scan(ctx); err != nil { + return nil, err + } + + return statuses, nil + }, + ) + if err != nil { + return nil, err + } + + // Reorder the statuses by their + // IDs to ensure in correct order. + getID := func(r *gtsmodel.ScheduledStatus) string { return r.ID } + xslices.OrderBy(statuses, ids, getID) + + if gtscontext.Barebones(ctx) { + // no need to fully populate. + return statuses, nil + } + + // Populate all loaded scheduled statuses, removing those we + // fail to populate (removes needing so many nil checks everywhere). + statuses = slices.DeleteFunc(statuses, func(scheduledStatus *gtsmodel.ScheduledStatus) bool { + if err := s.PopulateScheduledStatus(ctx, scheduledStatus); err != nil { + log.Errorf(ctx, "error populating %s: %v", scheduledStatus.ID, err) + return true + } + return false + }) + + return statuses, nil +} + +func (s *scheduledStatusDB) GetScheduledStatusesForAcct( + ctx context.Context, + acctID string, + page *paging.Page, +) ([]*gtsmodel.ScheduledStatus, error) { + var ( + // Get paging params. + minID = page.GetMin() + maxID = page.GetMax() + limit = page.GetLimit() + order = page.GetOrder() + + // Make educated guess for slice size + statusIDs = make([]string, 0, limit) + ) + + // Create the basic select query. + q := s.db. + NewSelect(). + Column("id"). + TableExpr( + "? AS ?", + bun.Ident("scheduled_statuses"), + bun.Ident("scheduled_status"), + ) + + // Select scheduled statuses by the account. + if acctID != "" { + q = q.Where("? = ?", bun.Ident("account_id"), acctID) + } + + // Add paging param max ID. + if maxID != "" { + q = q.Where("? < ?", bun.Ident("id"), maxID) + } + + // Add paging param min ID. + if minID != "" { + q = q.Where("? > ?", bun.Ident("id"), minID) + } + + // Add paging param order. + if order == paging.OrderAscending { + // Page up. + q = q.OrderExpr("? ASC", bun.Ident("id")) + } else { + // Page down. + q = q.OrderExpr("? DESC", bun.Ident("id")) + } + + // Add paging param limit. + if limit > 0 { + q = q.Limit(limit) + } + + // Execute the query and scan into IDs. + err := q.Scan(ctx, &statusIDs) + if err != nil { + return nil, err + } + + // Catch case of no items early + if len(statusIDs) == 0 { + return nil, db.ErrNoEntries + } + + // If we're paging up, we still want statuses + // to be sorted by ID desc, so reverse ids slice. + if order == paging.OrderAscending { + slices.Reverse(statusIDs) + } + + // Load all scheduled statuses by their IDs. + return s.GetScheduledStatusesByIDs(ctx, statusIDs) +} + +func (s *scheduledStatusDB) PutScheduledStatus(ctx context.Context, status *gtsmodel.ScheduledStatus) error { + return s.state.Caches.DB.ScheduledStatus.Store(status, func() error { + return s.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + if _, err := tx.NewInsert(). + Model(status). + Exec(ctx); err != nil { + return gtserror.Newf("error selecting boosted status: %w", err) + } + + // change the scheduled status ID of the + // media attachments to the current status + for _, a := range status.MediaAttachments { + a.ScheduledStatusID = status.ID + if _, err := tx. + NewUpdate(). + Model(a). + Column("scheduled_status_id"). + Where("? = ?", bun.Ident("media_attachment.id"), a.ID). + Exec(ctx); err != nil { + return gtserror.Newf("error updating media: %w", err) + } + } + + return nil + }) + }) +} + +func (s *scheduledStatusDB) DeleteScheduledStatusByID(ctx context.Context, id string) error { + var deleted gtsmodel.ScheduledStatus + + // Delete scheduled status + // from database by its ID. + if _, err := s.db.NewDelete(). + Model(&deleted). + Returning("?, ?", bun.Ident("id"), bun.Ident("attachments")). + Where("? = ?", bun.Ident("scheduled_status.id"), id). + Exec(ctx); err != nil { + return err + } + + // Invalidate cached scheduled status by its ID, + // manually call invalidate hook in case not cached. + s.state.Caches.DB.ScheduledStatus.Invalidate("ID", id) + s.state.Caches.OnInvalidateScheduledStatus(&deleted) + + return nil +} + +func (s *scheduledStatusDB) DeleteScheduledStatusesByAccountID(ctx context.Context, accountID string) error { + // Gather necessary fields from + // deleted for cache invaliation. + var deleted []*gtsmodel.ScheduledStatus + + if _, err := s.db.NewDelete(). + Model(&deleted). + Returning("?, ?", bun.Ident("id"), bun.Ident("attachments")). + Where("? = ?", bun.Ident("account_id"), accountID). + Exec(ctx); err != nil && + !errors.Is(err, db.ErrNoEntries) { + return err + } + + for _, deleted := range deleted { + // Invalidate cached scheduled statuses by ID + // and related media attachments. + s.state.Caches.DB.ScheduledStatus.Invalidate("ID", deleted.ID) + s.state.Caches.OnInvalidateScheduledStatus(deleted) + } + + return nil +} + +func (s *scheduledStatusDB) DeleteScheduledStatusesByApplicationID(ctx context.Context, applicationID string) error { + // Gather necessary fields from + // deleted for cache invaliation. + var deleted []*gtsmodel.ScheduledStatus + + if _, err := s.db.NewDelete(). + Model(&deleted). + Returning("?, ?", bun.Ident("id"), bun.Ident("attachments")). + Where("? = ?", bun.Ident("application_id"), applicationID). + Exec(ctx); err != nil && + !errors.Is(err, db.ErrNoEntries) { + return err + } + + for _, deleted := range deleted { + // Invalidate cached scheduled statuses by ID + // and related media attachments. + s.state.Caches.DB.ScheduledStatus.Invalidate("ID", deleted.ID) + s.state.Caches.OnInvalidateScheduledStatus(deleted) + } + + return nil +} + +func (s *scheduledStatusDB) UpdateScheduledStatusScheduledDate(ctx context.Context, scheduledStatus *gtsmodel.ScheduledStatus, scheduledAt *time.Time) error { + return s.state.Caches.DB.ScheduledStatus.Store(scheduledStatus, func() error { + _, err := s.db.NewUpdate(). + Model(scheduledStatus). + Where("? = ?", bun.Ident("scheduled_status.id"), scheduledStatus.ID). + Column("scheduled_at"). + Exec(ctx) + return err + }) +} + +func (s *scheduledStatusDB) GetScheduledStatusesCountForAcct(ctx context.Context, acctID string, scheduledAt *time.Time) (int, error) { + q := s.db. + NewSelect(). + Column("id"). + TableExpr( + "? AS ?", + bun.Ident("scheduled_statuses"), + bun.Ident("scheduled_status"), + ). + Where("? = ?", bun.Ident("account_id"), acctID) + + if scheduledAt != nil { + startOfDay := time.Date(scheduledAt.Year(), scheduledAt.Month(), scheduledAt.Day(), 0, 0, 0, 0, scheduledAt.Location()) + endOfDay := startOfDay.Add(24 * time.Hour) + q = q. + Where("? >= ? AND ? < ?", bun.Ident("scheduled_at"), startOfDay, bun.Ident("scheduled_at"), endOfDay) + } + + count, err := q.Count(ctx) + + if err != nil { + return 0, err + } + + return count, nil +} diff --git a/internal/db/bundb/status.go b/internal/db/bundb/status.go index 81aba8726..5b72f5fbe 100644 --- a/internal/db/bundb/status.go +++ b/internal/db/bundb/status.go @@ -561,10 +561,11 @@ func insertStatus(ctx context.Context, tx bun.Tx, status *gtsmodel.Status) error // attachments to the current status for _, a := range status.Attachments { a.StatusID = status.ID + a.ScheduledStatusID = "" if _, err := tx. NewUpdate(). Model(a). - Column("status_id"). + Column("status_id", "scheduled_status_id"). Where("? = ?", bun.Ident("media_attachment.id"), a.ID). Exec(ctx); err != nil { return gtserror.Newf("error updating media: %w", err) diff --git a/internal/db/db.go b/internal/db/db.go index 16796ae49..a7b96d5ca 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -46,6 +46,7 @@ type DB interface { Relationship Report Rule + ScheduledStatus Search Session SinBinStatus diff --git a/internal/db/scheduledstatus.go b/internal/db/scheduledstatus.go new file mode 100644 index 000000000..b266462c2 --- /dev/null +++ b/internal/db/scheduledstatus.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 <http://www.gnu.org/licenses/>. + +package db + +import ( + "context" + "time" + + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/paging" +) + +type ScheduledStatus interface { + // GetAllScheduledStatuses returns all pending scheduled statuses. + GetAllScheduledStatuses(ctx context.Context) ([]*gtsmodel.ScheduledStatus, error) + + // GetScheduledStatusByID gets one scheduled status with the given id. + GetScheduledStatusByID(ctx context.Context, id string) (*gtsmodel.ScheduledStatus, error) + + // GetScheduledStatusesForAcct returns statuses scheduled by the given account. + GetScheduledStatusesForAcct( + ctx context.Context, + acctID string, + page *paging.Page, + ) ([]*gtsmodel.ScheduledStatus, error) + + // PutScheduledStatus puts the given scheduled status in the database. + PutScheduledStatus(ctx context.Context, status *gtsmodel.ScheduledStatus) error + + // DeleteScheduledStatusByID deletes one scheduled status from the database. + DeleteScheduledStatusByID(ctx context.Context, id string) error + + // DeleteScheduledStatusByID deletes all scheduled statuses from an account from the database. + DeleteScheduledStatusesByAccountID(ctx context.Context, accountID string) error + + // DeleteScheduledStatusesByApplicationID deletes all scheduled statuses posted from the given application from the database. + DeleteScheduledStatusesByApplicationID(ctx context.Context, applicationID string) error + + // UpdateScheduledStatusScheduledDate updates `scheduled_at` param for the given scheduled status in the database. + UpdateScheduledStatusScheduledDate(ctx context.Context, scheduledStatus *gtsmodel.ScheduledStatus, scheduledAt *time.Time) error + + // GetScheduledStatusesCountForAcct returns the number of pending statuses scheduled by the given account, optionally for a specific day. + GetScheduledStatusesCountForAcct(ctx context.Context, acctID string, scheduledAt *time.Time) (int, error) +} diff --git a/internal/gtsmodel/scheduledstatus.go b/internal/gtsmodel/scheduledstatus.go new file mode 100644 index 000000000..d177d53f1 --- /dev/null +++ b/internal/gtsmodel/scheduledstatus.go @@ -0,0 +1,65 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package gtsmodel + +import "time" + +// ScheduledStatus represents a status that is scheduled to be published at given time by a local user. +type ScheduledStatus struct { + ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database + AccountID string `bun:"type:CHAR(26),nullzero,notnull"` // which account scheduled this status + Account *Account `bun:"-"` // Account corresponding to AccountID + ScheduledAt time.Time `bun:"type:timestamptz,nullzero,notnull"` // time at which the status is scheduled + Text string `bun:""` // Text content of the status + Poll ScheduledStatusPoll `bun:",embed:poll_,notnull,nullzero"` // + MediaIDs []string `bun:"attachments,array"` // Database IDs of any media attachments associated with this status + MediaAttachments []*MediaAttachment `bun:"-"` // Attachments corresponding to media IDs + Sensitive *bool `bun:",nullzero,notnull,default:false"` // mark the status as sensitive? + SpoilerText string `bun:""` // Original text of the content warning without formatting + Visibility Visibility `bun:",nullzero,notnull"` // visibility entry for this status + InReplyToID string `bun:"type:CHAR(26),nullzero"` // id of the status this status replies to + Language string `bun:",nullzero"` // what language is this status written in? + ApplicationID string `bun:"type:CHAR(26),nullzero"` // Which application was used to create this status? + Application *Application `bun:"-"` // + LocalOnly *bool `bun:",nullzero,notnull,default:false"` // Whether the status is not federated + ContentType string `bun:",nullzero"` // Content type used to process the original text of the status + InteractionPolicy *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. + Idempotency string `bun:",nullzero"` // Currently unused +} + +type ScheduledStatusPoll struct { + Options []string `bun:",nullzero,array"` // The available options for this poll. + ExpiresIn int `bun:",nullzero"` // Duration the poll should be open, in seconds + Multiple *bool `bun:",nullzero,notnull,default:false"` // Is this a multiple choice poll? i.e. can you vote on multiple options. + HideTotals *bool `bun:",nullzero,notnull,default:false"` // Hides vote counts until poll ends. +} + +// AttachmentsPopulated returns whether media attachments +// are populated according to current AttachmentIDs. +func (s *ScheduledStatus) AttachmentsPopulated() bool { + if len(s.MediaIDs) != len(s.MediaAttachments) { + // this is the quickest indicator. + return false + } + for i, id := range s.MediaIDs { + if s.MediaAttachments[i].ID != id { + return false + } + } + return true +} diff --git a/internal/processing/account/delete.go b/internal/processing/account/delete.go index 717c03fcc..a45afe754 100644 --- a/internal/processing/account/delete.go +++ b/internal/processing/account/delete.go @@ -459,6 +459,11 @@ func (p *Processor) deleteAccountPeripheral( if err := p.state.DB.DeleteAccountStats(ctx, account.ID); err != nil { log.Errorf("error deleting stats for account: %v", err) } + + // Delete statuses scheduled by given account, only for local. + if err := p.state.DB.DeleteScheduledStatusesByAccountID(ctx, account.ID); err != nil { + log.Errorf("error deleting scheduled statuses for account: %v", err) + } } // Delete all bookmarks targeting given account, local and remote. diff --git a/internal/processing/application/delete.go b/internal/processing/application/delete.go index 7d1a3b495..6b3856bf0 100644 --- a/internal/processing/application/delete.go +++ b/internal/processing/application/delete.go @@ -66,5 +66,11 @@ func (p *Processor) Delete( return nil, gtserror.NewErrorInternalError(err) } + // Delete all scheduled statuses posted from the app. + if err := p.state.DB.DeleteScheduledStatusesByApplicationID(ctx, appID); err != nil { + err := gtserror.Newf("db error deleting scheduled statuses for app %s: %w", appID, err) + return nil, gtserror.NewErrorInternalError(err) + } + return apiApp, nil } diff --git a/internal/processing/status/common.go b/internal/processing/status/common.go index c764a64b4..ca17ab80e 100644 --- a/internal/processing/status/common.go +++ b/internal/processing/status/common.go @@ -282,6 +282,7 @@ func (p *Processor) processMedia( authorID string, statusID string, mediaIDs []string, + scheduledStatusID *string, ) ( []*gtsmodel.MediaAttachment, gtserror.WithCode, @@ -315,7 +316,7 @@ func (p *Processor) processMedia( // Check media isn't already attached to another status. if (media.StatusID != "" && media.StatusID != statusID) || - (media.ScheduledStatusID != "" && media.ScheduledStatusID != statusID) { + (media.ScheduledStatusID != "" && (media.ScheduledStatusID != statusID && (scheduledStatusID == nil || media.ScheduledStatusID != *scheduledStatusID))) { text := fmt.Sprintf("media already attached to status: %s", id) return nil, gtserror.NewErrorBadRequest(errors.New(text), text) } diff --git a/internal/processing/status/create.go b/internal/processing/status/create.go index 1a00d8ab7..57338708c 100644 --- a/internal/processing/status/create.go +++ b/internal/processing/status/create.go @@ -44,10 +44,8 @@ func (p *Processor) Create( requester *gtsmodel.Account, application *gtsmodel.Application, form *apimodel.StatusCreateRequest, -) ( - *apimodel.Status, - gtserror.WithCode, -) { + scheduledStatusID *string, +) (any, gtserror.WithCode) { // Validate incoming form status content. if errWithCode := validateStatusContent( form.Status, @@ -83,16 +81,6 @@ func (p *Processor) Create( 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) @@ -105,16 +93,27 @@ func (p *Processor) Create( // Handle backfilled/scheduled statuses. backfill := false - if form.ScheduledAt != nil { - scheduledAt := *form.ScheduledAt - - // Statuses may only be scheduled - // a minimum time into the future. - if now.Before(scheduledAt) { - const errText = "scheduled statuses are not yet supported" - return nil, gtserror.NewErrorNotImplemented(gtserror.New(errText), errText) + + switch { + case form.ScheduledAt == nil: + // No scheduling/backfilling + break + case form.ScheduledAt.Sub(now) >= 5*time.Minute: + // Statuses may only be scheduled a minimum time into the future. + scheduledStatus, errWithCode := p.processScheduledStatus(ctx, statusID, form, requester, application) + + if errWithCode != nil { + return nil, errWithCode } + return scheduledStatus, nil + + case now.Before(*form.ScheduledAt): + // Invalid future scheduled status + const errText = "scheduled_at must be at least 5 minutes in the future" + return nil, gtserror.NewErrorUnprocessableEntity(gtserror.New(errText), errText) + + default: // If not scheduled into the future, this status is being backfilled. if !config.GetInstanceAllowBackdatingStatuses() { const errText = "backdating statuses has been disabled on this instance" @@ -127,7 +126,7 @@ func (p *Processor) Create( // this would also cause issues with time.Time.IsZero() checks // that normally signify an absent optional time, // but this check covers both cases. - if scheduledAt.Compare(time.UnixMilli(0)) <= 0 { + if form.ScheduledAt.Compare(time.UnixMilli(0)) <= 0 { const errText = "statuses can't be backdated to or before the UNIX epoch" return nil, gtserror.NewErrorNotAcceptable(gtserror.New(errText), errText) } @@ -138,7 +137,7 @@ func (p *Processor) Create( backfill = true // Update to backfill date. - createdAt = scheduledAt + createdAt = *form.ScheduledAt // Generate an appropriate, (and unique!), ID for the creation time. if statusID, err = p.backfilledStatusID(ctx, createdAt); err != nil { @@ -146,6 +145,17 @@ func (p *Processor) Create( } } + // Process incoming status attachments. + media, errWithCode := p.processMedia(ctx, + requester.ID, + statusID, + form.MediaIDs, + scheduledStatusID, + ) + if errWithCode != nil { + return nil, errWithCode + } + status := >smodel.Status{ ID: statusID, URI: accountURIs.StatusesURI + "/" + statusID, @@ -546,3 +556,103 @@ func processInteractionPolicy( // setting it explicitly to save space. return nil } + +func (p *Processor) processScheduledStatus( + ctx context.Context, + statusID string, + form *apimodel.StatusCreateRequest, + requester *gtsmodel.Account, + application *gtsmodel.Application, +) (*apimodel.ScheduledStatus, gtserror.WithCode) { + // Validate scheduled status against server configuration + // (max scheduled statuses limit). + if errWithCode := p.validateScheduledStatusLimits(ctx, requester.ID, form.ScheduledAt, nil); errWithCode != nil { + return nil, errWithCode + } + + media, errWithCode := p.processMedia(ctx, + requester.ID, + statusID, + form.MediaIDs, + nil, + ) + if errWithCode != nil { + return nil, errWithCode + } + status := >smodel.ScheduledStatus{ + ID: statusID, + Account: requester, + AccountID: requester.ID, + Application: application, + ApplicationID: application.ID, + ScheduledAt: *form.ScheduledAt, + Text: form.Status, + MediaIDs: form.MediaIDs, + MediaAttachments: media, + Sensitive: &form.Sensitive, + SpoilerText: form.SpoilerText, + InReplyToID: form.InReplyToID, + Language: form.Language, + LocalOnly: form.LocalOnly, + ContentType: string(form.ContentType), + } + + if form.Poll != nil { + status.Poll = gtsmodel.ScheduledStatusPoll{ + Options: form.Poll.Options, + ExpiresIn: form.Poll.ExpiresIn, + Multiple: &form.Poll.Multiple, + HideTotals: &form.Poll.HideTotals, + } + } + + accountDefaultVisibility := requester.Settings.Privacy + + switch { + case form.Visibility != "": + status.Visibility = typeutils.APIVisToVis(form.Visibility) + + case accountDefaultVisibility != 0: + status.Visibility = accountDefaultVisibility + form.Visibility = typeutils.VisToAPIVis(accountDefaultVisibility) + + default: + status.Visibility = gtsmodel.VisibilityDefault + form.Visibility = typeutils.VisToAPIVis(gtsmodel.VisibilityDefault) + } + + if form.InteractionPolicy != nil { + interactionPolicy, err := typeutils.APIInteractionPolicyToInteractionPolicy(form.InteractionPolicy, form.Visibility) + + if err != nil { + err := gtserror.Newf("error converting interaction policy: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + status.InteractionPolicy = interactionPolicy + } + + // Insert this newly prepared status into the database. + if err := p.state.DB.PutScheduledStatus(ctx, status); err != nil { + err := gtserror.Newf("error inserting status in db: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + // Schedule the newly inserted status for publishing. + if err := p.ScheduledStatusesSchedulePublication(ctx, status.ID); err != nil { + err := gtserror.Newf("error scheduling status publish: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + apiScheduledStatus, err := p.converter.ScheduledStatusToAPIScheduledStatus( + ctx, + status, + ) + + if err != nil { + err := gtserror.Newf("error converting: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return apiScheduledStatus, nil +} diff --git a/internal/processing/status/create_test.go b/internal/processing/status/create_test.go index 82bc801c4..646a26978 100644 --- a/internal/processing/status/create_test.go +++ b/internal/processing/status/create_test.go @@ -53,7 +53,8 @@ func (suite *StatusCreateTestSuite) TestProcessContentWarningWithQuotationMarks( ContentType: apimodel.StatusContentTypePlain, } - apiStatus, err := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm) + apiStatusAny, err := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm, nil) + apiStatus := apiStatusAny.(*apimodel.Status) suite.NoError(err) suite.NotNil(apiStatus) @@ -84,7 +85,8 @@ func (suite *StatusCreateTestSuite) TestProcessStatusMarkdownWithUnderscoreEmoji ContentType: apimodel.StatusContentTypeMarkdown, } - apiStatus, err := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm) + apiStatusAny, err := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm, nil) + apiStatus := apiStatusAny.(*apimodel.Status) suite.NoError(err) suite.NotNil(apiStatus) @@ -111,7 +113,8 @@ func (suite *StatusCreateTestSuite) TestProcessStatusMarkdownWithSpoilerTextEmoj ContentType: apimodel.StatusContentTypeMarkdown, } - apiStatus, err := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm) + apiStatusAny, err := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm, nil) + apiStatus := apiStatusAny.(*apimodel.Status) suite.NoError(err) suite.NotNil(apiStatus) @@ -142,7 +145,7 @@ func (suite *StatusCreateTestSuite) TestProcessMediaDescriptionTooShort() { ContentType: apimodel.StatusContentTypePlain, } - apiStatus, err := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm) + apiStatus, err := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm, nil) suite.EqualError(err, "media description less than min chars (100)") suite.Nil(apiStatus) } @@ -167,7 +170,8 @@ func (suite *StatusCreateTestSuite) TestProcessLanguageWithScriptPart() { ContentType: apimodel.StatusContentTypePlain, } - apiStatus, err := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm) + apiStatusAny, err := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm, nil) + apiStatus := apiStatusAny.(*apimodel.Status) suite.NoError(err) suite.NotNil(apiStatus) @@ -197,7 +201,8 @@ func (suite *StatusCreateTestSuite) TestProcessReplyToUnthreadedRemoteStatus() { ContentType: apimodel.StatusContentTypePlain, } - apiStatus, err := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm) + apiStatusAny, err := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm, nil) + apiStatus := apiStatusAny.(*apimodel.Status) suite.NoError(err) suite.NotNil(apiStatus) @@ -230,7 +235,8 @@ func (suite *StatusCreateTestSuite) TestProcessNoContentTypeUsesDefault() { ContentType: "", } - apiStatus, errWithCode := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm) + apiStatusAny, errWithCode := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm, nil) + apiStatus := apiStatusAny.(*apimodel.Status) suite.NoError(errWithCode) suite.NotNil(apiStatus) @@ -260,7 +266,7 @@ func (suite *StatusCreateTestSuite) TestProcessInvalidVisibility() { ContentType: apimodel.StatusContentTypePlain, } - apiStatus, errWithCode := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm) + apiStatus, errWithCode := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm, nil) suite.Nil(apiStatus) suite.Equal(http.StatusUnprocessableEntity, errWithCode.Code()) suite.Equal("Unprocessable Entity: processVisibility: invalid visibility", errWithCode.Safe()) diff --git a/internal/processing/status/edit.go b/internal/processing/status/edit.go index b64a0583b..3ca21f5cf 100644 --- a/internal/processing/status/edit.go +++ b/internal/processing/status/edit.go @@ -110,6 +110,7 @@ func (p *Processor) Edit( requester.ID, statusID, form.MediaIDs, + nil, ) if errWithCode != nil { return nil, errWithCode diff --git a/internal/processing/status/scheduledstatus.go b/internal/processing/status/scheduledstatus.go new file mode 100644 index 000000000..d0ec6898c --- /dev/null +++ b/internal/processing/status/scheduledstatus.go @@ -0,0 +1,357 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package status + +import ( + "context" + "errors" + "time" + + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "code.superseriousbusiness.org/gotosocial/internal/config" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/gtscontext" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/log" + "code.superseriousbusiness.org/gotosocial/internal/paging" + "code.superseriousbusiness.org/gotosocial/internal/typeutils" +) + +// ScheduledStatusesGetPage returns a page of scheduled statuses authored +// by the requester. +func (p *Processor) ScheduledStatusesGetPage( + ctx context.Context, + requester *gtsmodel.Account, + page *paging.Page, +) (*apimodel.PageableResponse, gtserror.WithCode) { + scheduledStatuses, err := p.state.DB.GetScheduledStatusesForAcct( + ctx, + requester.ID, + page, + ) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting scheduled statuses: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + count := len(scheduledStatuses) + if count == 0 { + return paging.EmptyResponse(), nil + } + + var ( + // Get the lowest and highest + // ID values, used for paging. + lo = scheduledStatuses[count-1].ID + hi = scheduledStatuses[0].ID + + // Best-guess items length. + items = make([]interface{}, 0, count) + ) + + for _, scheduledStatus := range scheduledStatuses { + apiScheduledStatus, err := p.converter.ScheduledStatusToAPIScheduledStatus( + ctx, scheduledStatus, + ) + if err != nil { + log.Errorf(ctx, "error converting scheduled status to api scheduled status: %v", err) + continue + } + + // Append scheduledStatus to return items. + items = append(items, apiScheduledStatus) + } + + return paging.PackageResponse(paging.ResponseParams{ + Items: items, + Path: "/api/v1/scheduled_statuses", + Next: page.Next(lo, hi), + Prev: page.Prev(lo, hi), + }), nil +} + +// ScheduledStatusesGetOne returns one scheduled +// status with the given ID. +func (p *Processor) ScheduledStatusesGetOne( + ctx context.Context, + requester *gtsmodel.Account, + id string, +) (*apimodel.ScheduledStatus, gtserror.WithCode) { + scheduledStatus, err := p.state.DB.GetScheduledStatusByID(ctx, id) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting scheduled status: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + if scheduledStatus == nil { + err := gtserror.New("scheduled status not found") + return nil, gtserror.NewErrorNotFound(err) + } + + if scheduledStatus.AccountID != requester.ID { + err := gtserror.Newf( + "scheduled status %s is not authored by account %s", + scheduledStatus.ID, requester.ID, + ) + return nil, gtserror.NewErrorNotFound(err) + } + + apiScheduledStatus, err := p.converter.ScheduledStatusToAPIScheduledStatus( + ctx, scheduledStatus, + ) + if err != nil { + err := gtserror.Newf("error converting scheduled status to api scheduled status: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return apiScheduledStatus, nil +} + +func (p *Processor) ScheduledStatusesScheduleAll(ctx context.Context) error { + // Fetch all pending statuses from the database (barebones models are enough). + statuses, err := p.state.DB.GetAllScheduledStatuses(gtscontext.SetBarebones(ctx)) + if err != nil { + return gtserror.Newf("error getting scheduled statuses from db: %w", err) + } + + var errs gtserror.MultiError + + for _, status := range statuses { + // Schedule publication of each of the statuses and catch any errors. + if err := p.ScheduledStatusesSchedulePublication(ctx, status.ID); err != nil { + errs.Append(err) + } + } + + return errs.Combine() +} + +func (p *Processor) ScheduledStatusesSchedulePublication(ctx context.Context, statusID string) gtserror.WithCode { + status, err := p.state.DB.GetScheduledStatusByID(ctx, statusID) + + if err != nil { + return gtserror.NewErrorNotFound(gtserror.Newf("failed to get scheduled status %s", statusID)) + } + + // Add the given status to the scheduler. + ok := p.state.Workers.Scheduler.AddOnce( + status.ID, + status.ScheduledAt, + p.onPublish(status.ID), + ) + + if !ok { + // Failed to add the status to the scheduler, either it was + // starting / stopping or there already exists a task for status. + return gtserror.NewErrorInternalError(gtserror.Newf("failed adding status %s to scheduler", status.ID)) + } + + atStr := status.ScheduledAt.Local().Format("Jan _2 2006 15:04:05") + log.Infof(ctx, "scheduled status publication for %s at '%s'", status.ID, atStr) + return nil +} + +// onPublish returns a callback function to be used by the scheduler on the scheduled date. +func (p *Processor) onPublish(statusID string) func(context.Context, time.Time) { + return func(ctx context.Context, now time.Time) { + // Get the latest version of status from database. + status, err := p.state.DB.GetScheduledStatusByID(ctx, statusID) + if err != nil { + log.Errorf(ctx, "error getting status %s from db: %v", statusID, err) + return + } + + request := &apimodel.StatusCreateRequest{ + Status: status.Text, + MediaIDs: status.MediaIDs, + Poll: nil, + InReplyToID: status.InReplyToID, + Sensitive: *status.Sensitive, + SpoilerText: status.SpoilerText, + Visibility: typeutils.VisToAPIVis(status.Visibility), + Language: status.Language, + } + + if status.Poll.Options != nil && len(status.Poll.Options) > 1 { + request.Poll = &apimodel.PollRequest{ + Options: status.Poll.Options, + ExpiresIn: status.Poll.ExpiresIn, + Multiple: *status.Poll.Multiple, + HideTotals: *status.Poll.HideTotals, + } + } + + _, errWithCode := p.Create(ctx, status.Account, status.Application, request, &statusID) + + if errWithCode != nil { + log.Errorf(ctx, "could not publish scheduled status: %v", errWithCode.Unwrap()) + return + } + + err = p.state.DB.DeleteScheduledStatusByID(ctx, statusID) + + if err != nil { + log.Error(ctx, err) + } + } +} + +// Update scheduled status schedule data +func (p *Processor) ScheduledStatusesUpdate( + ctx context.Context, + requester *gtsmodel.Account, + id string, + scheduledAt *time.Time, +) (*apimodel.ScheduledStatus, gtserror.WithCode) { + scheduledStatus, err := p.state.DB.GetScheduledStatusByID(ctx, id) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting scheduled status: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + if scheduledStatus == nil { + err := gtserror.New("scheduled status not found") + return nil, gtserror.NewErrorNotFound(err) + } + + if scheduledStatus.AccountID != requester.ID { + err := gtserror.Newf( + "scheduled status %s is not authored by account %s", + scheduledStatus.ID, requester.ID, + ) + return nil, gtserror.NewErrorNotFound(err) + } + + if errWithCode := p.validateScheduledStatusLimits(ctx, requester.ID, scheduledAt, &scheduledStatus.ScheduledAt); errWithCode != nil { + return nil, errWithCode + } + + scheduledStatus.ScheduledAt = *scheduledAt + err = p.state.DB.UpdateScheduledStatusScheduledDate(ctx, scheduledStatus, scheduledAt) + + if err != nil { + err := gtserror.Newf("db error getting scheduled status: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + ok := p.state.Workers.Scheduler.Cancel(id) + + if !ok { + err := gtserror.Newf("failed to cancel scheduled status") + return nil, gtserror.NewErrorInternalError(err) + } + + err = p.ScheduledStatusesSchedulePublication(ctx, id) + + if err != nil { + err := gtserror.Newf("error scheduling status: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + apiScheduledStatus, err := p.converter.ScheduledStatusToAPIScheduledStatus( + ctx, scheduledStatus, + ) + if err != nil { + err := gtserror.Newf("error converting scheduled status to api req: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return apiScheduledStatus, nil +} + +// Cancel a scheduled status +func (p *Processor) ScheduledStatusesDelete(ctx context.Context, requester *gtsmodel.Account, id string) gtserror.WithCode { + scheduledStatus, err := p.state.DB.GetScheduledStatusByID(ctx, id) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting scheduled status: %w", err) + return gtserror.NewErrorInternalError(err) + } + + if scheduledStatus == nil { + err := gtserror.New("scheduled status not found") + return gtserror.NewErrorNotFound(err) + } + + if scheduledStatus.AccountID != requester.ID { + err := gtserror.Newf( + "scheduled status %s is not authored by account %s", + scheduledStatus.ID, requester.ID, + ) + return gtserror.NewErrorNotFound(err) + } + + ok := p.state.Workers.Scheduler.Cancel(id) + + if !ok { + err := gtserror.Newf("failed to cancel scheduled status") + return gtserror.NewErrorInternalError(err) + } + + err = p.state.DB.DeleteScheduledStatusByID(ctx, id) + + if err != nil { + err := gtserror.Newf("db error deleting scheduled status: %w", err) + return gtserror.NewErrorInternalError(err) + } + + return nil +} + +func (p *Processor) validateScheduledStatusLimits(ctx context.Context, acctID string, scheduledAt *time.Time, prevScheduledAt *time.Time) gtserror.WithCode { + // Skip check when the scheduled status already exists and the day stays the same + if prevScheduledAt != nil { + y1, m1, d1 := scheduledAt.Date() + y2, m2, d2 := prevScheduledAt.Date() + + if y1 == y2 && m1 == m2 && d1 == d2 { + return nil + } + } + + scheduledDaily, err := p.state.DB.GetScheduledStatusesCountForAcct(ctx, acctID, scheduledAt) + + if err != nil { + err := gtserror.Newf("error getting scheduled statuses count for day: %w", err) + return gtserror.NewErrorInternalError(err) + } + + if max := config.GetScheduledStatusesMaxDaily(); scheduledDaily >= max { + err := gtserror.Newf("scheduled statuses count for day is at the limit (%d)", max) + return gtserror.NewErrorUnprocessableEntity(err) + } + + // Skip total check when editing an existing scheduled status + if prevScheduledAt != nil { + return nil + } + + scheduledTotal, err := p.state.DB.GetScheduledStatusesCountForAcct(ctx, acctID, nil) + + if err != nil { + err := gtserror.Newf("error getting total scheduled statuses count: %w", err) + return gtserror.NewErrorInternalError(err) + } + + if max := config.GetScheduledStatusesMaxTotal(); scheduledTotal >= max { + err := gtserror.Newf("total scheduled statuses count is at the limit (%d)", max) + return gtserror.NewErrorUnprocessableEntity(err) + } + + return nil +} diff --git a/internal/processing/status/scheduledstatus_test.go b/internal/processing/status/scheduledstatus_test.go new file mode 100644 index 000000000..d53b1ec70 --- /dev/null +++ b/internal/processing/status/scheduledstatus_test.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 <http://www.gnu.org/licenses/>. + +package status_test + +import ( + "context" + "testing" + "time" + + "code.superseriousbusiness.org/gotosocial/internal/util" + "code.superseriousbusiness.org/gotosocial/testrig" + "github.com/stretchr/testify/suite" +) + +type ScheduledStatusTestSuite struct { + StatusStandardTestSuite +} + +func (suite *ScheduledStatusTestSuite) TestUpdate() { + ctx := suite.T().Context() + + account1 := suite.testAccounts["local_account_1"] + scheduledStatus1 := suite.testScheduledStatuses["scheduled_status_1"] + newScheduledAt := testrig.TimeMustParse("2080-07-02T21:37:00+02:00") + + suite.state.Workers.Scheduler.AddOnce(scheduledStatus1.ID, scheduledStatus1.ScheduledAt, func(ctx context.Context, t time.Time) {}) + + // update scheduled status publication date + scheduledStatus2, err := suite.status.ScheduledStatusesUpdate(ctx, account1, scheduledStatus1.ID, util.Ptr(newScheduledAt)) + suite.NoError(err) + suite.NotNil(scheduledStatus2) + suite.Equal(scheduledStatus2.ScheduledAt, util.FormatISO8601(newScheduledAt)) + // should be rescheduled + suite.Equal(suite.state.Workers.Scheduler.Cancel(scheduledStatus1.ID), true) +} + +func (suite *ScheduledStatusTestSuite) TestDelete() { + ctx := suite.T().Context() + + account1 := suite.testAccounts["local_account_1"] + scheduledStatus1 := suite.testScheduledStatuses["scheduled_status_1"] + + suite.state.Workers.Scheduler.AddOnce(scheduledStatus1.ID, scheduledStatus1.ScheduledAt, func(ctx context.Context, t time.Time) {}) + + // delete scheduled status + err := suite.status.ScheduledStatusesDelete(ctx, account1, scheduledStatus1.ID) + suite.NoError(err) + // should be already cancelled + suite.Equal(suite.state.Workers.Scheduler.Cancel(scheduledStatus1.ID), false) +} + +func TestScheduledStatusTestSuite(t *testing.T) { + suite.Run(t, new(ScheduledStatusTestSuite)) +} diff --git a/internal/processing/status/status_test.go b/internal/processing/status/status_test.go index d709d435f..18d20d67c 100644 --- a/internal/processing/status/status_test.go +++ b/internal/processing/status/status_test.go @@ -51,14 +51,15 @@ type StatusStandardTestSuite struct { federator *federation.Federator // standard suite models - testTokens map[string]*gtsmodel.Token - testApplications map[string]*gtsmodel.Application - testUsers map[string]*gtsmodel.User - testAccounts map[string]*gtsmodel.Account - testAttachments map[string]*gtsmodel.MediaAttachment - testStatuses map[string]*gtsmodel.Status - testTags map[string]*gtsmodel.Tag - testMentions map[string]*gtsmodel.Mention + testTokens map[string]*gtsmodel.Token + testApplications map[string]*gtsmodel.Application + testUsers map[string]*gtsmodel.User + testAccounts map[string]*gtsmodel.Account + testAttachments map[string]*gtsmodel.MediaAttachment + testStatuses map[string]*gtsmodel.Status + testTags map[string]*gtsmodel.Tag + testMentions map[string]*gtsmodel.Mention + testScheduledStatuses map[string]*gtsmodel.ScheduledStatus // module being tested status status.Processor @@ -73,6 +74,7 @@ func (suite *StatusStandardTestSuite) SetupSuite() { suite.testStatuses = testrig.NewTestStatuses() suite.testTags = testrig.NewTestTags() suite.testMentions = testrig.NewTestMentions() + suite.testScheduledStatuses = testrig.NewTestScheduledStatuses() } func (suite *StatusStandardTestSuite) SetupTest() { diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index aef38ad6e..a4cb1c0e1 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -3014,3 +3014,57 @@ func (c *Converter) TokenToAPITokenInfo( Application: apiApplication, }, nil } + +func (c *Converter) ScheduledStatusToAPIScheduledStatus( + ctx context.Context, + scheduledStatus *gtsmodel.ScheduledStatus, +) (*apimodel.ScheduledStatus, error) { + apiAttachments, err := c.convertAttachmentsToAPIAttachments( + ctx, + scheduledStatus.MediaAttachments, + scheduledStatus.MediaIDs, + ) + if err != nil { + log.Errorf(ctx, "error converting status attachments: %v", err) + } + + scheduledAt := util.FormatISO8601(scheduledStatus.ScheduledAt) + + apiScheduledStatus := &apimodel.ScheduledStatus{ + ID: scheduledStatus.ID, + ScheduledAt: scheduledAt, + Params: &apimodel.ScheduledStatusParams{ + Text: scheduledStatus.Text, + MediaIDs: scheduledStatus.MediaIDs, + Sensitive: *scheduledStatus.Sensitive, + SpoilerText: scheduledStatus.SpoilerText, + Visibility: VisToAPIVis(scheduledStatus.Visibility), + InReplyToID: scheduledStatus.InReplyToID, + Language: scheduledStatus.Language, + ApplicationID: scheduledStatus.ApplicationID, + LocalOnly: *scheduledStatus.LocalOnly, + ContentType: apimodel.StatusContentType(scheduledStatus.ContentType), + ScheduledAt: nil, + }, + MediaAttachments: apiAttachments, + } + + if len(scheduledStatus.Poll.Options) > 1 { + apiScheduledStatus.Params.Poll = &apimodel.ScheduledStatusParamsPoll{ + Options: scheduledStatus.Poll.Options, + ExpiresIn: scheduledStatus.Poll.ExpiresIn, + Multiple: *scheduledStatus.Poll.Multiple, + HideTotals: *scheduledStatus.Poll.HideTotals, + } + } + + if scheduledStatus.InteractionPolicy != nil { + apiInteractionPolicy, err := c.InteractionPolicyToAPIInteractionPolicy(ctx, scheduledStatus.InteractionPolicy, nil, nil) + if err != nil { + return nil, gtserror.Newf("error converting interaction policy: %w", err) + } + apiScheduledStatus.Params.InteractionPolicy = apiInteractionPolicy + } + + return apiScheduledStatus, nil +} |
