summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/api/client.go4
-rw-r--r--internal/api/client/scheduledstatuses/scheduledstatus.go52
-rw-r--r--internal/api/client/scheduledstatuses/scheduledstatusdelete.go97
-rw-r--r--internal/api/client/scheduledstatuses/scheduledstatusesget.go136
-rw-r--r--internal/api/client/scheduledstatuses/scheduledstatusget.go98
-rw-r--r--internal/api/client/scheduledstatuses/scheduledstatusput.go131
-rw-r--r--internal/api/client/statuses/statuscreate.go5
-rw-r--r--internal/api/client/statuses/statuscreate_test.go19
-rw-r--r--internal/api/model/scheduledstatus.go50
-rw-r--r--internal/api/model/status.go1
-rw-r--r--internal/cache/cache.go2
-rw-r--r--internal/cache/db.go37
-rw-r--r--internal/cache/invalidate.go5
-rw-r--r--internal/cache/size.go19
-rw-r--r--internal/cleaner/media.go42
-rw-r--r--internal/config/config.go4
-rw-r--r--internal/config/defaults.go4
-rw-r--r--internal/config/helpers.gen.go113
-rw-r--r--internal/config/testdata/test.json2
-rw-r--r--internal/config/testdata/test.yaml10
-rw-r--r--internal/db/bundb/bundb.go5
-rw-r--r--internal/db/bundb/migrations/20250531213700_add_scheduled_statuses.go67
-rw-r--r--internal/db/bundb/scheduledstatus.go402
-rw-r--r--internal/db/bundb/status.go3
-rw-r--r--internal/db/db.go1
-rw-r--r--internal/db/scheduledstatus.go59
-rw-r--r--internal/gtsmodel/scheduledstatus.go65
-rw-r--r--internal/processing/account/delete.go5
-rw-r--r--internal/processing/application/delete.go6
-rw-r--r--internal/processing/status/common.go3
-rw-r--r--internal/processing/status/create.go158
-rw-r--r--internal/processing/status/create_test.go22
-rw-r--r--internal/processing/status/edit.go1
-rw-r--r--internal/processing/status/scheduledstatus.go357
-rw-r--r--internal/processing/status/scheduledstatus_test.go69
-rw-r--r--internal/processing/status/status_test.go18
-rw-r--r--internal/typeutils/internaltofrontend.go54
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(&gtsmodel.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(&gtsmodel.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(&gtsmodel.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 := &gtsmodel.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 := &gtsmodel.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
+}