diff --git a/cmd/gotosocial/action/server/server.go b/cmd/gotosocial/action/server/server.go
index 9bbb4bf09..afd908304 100644
--- a/cmd/gotosocial/action/server/server.go
+++ b/cmd/gotosocial/action/server/server.go
@@ -382,6 +382,11 @@ var Start action.GTSAction = func(ctx context.Context) error {
return fmt.Errorf("error scheduling poll expiries: %w", err)
}
+ // schedule publication tasks for all scheduled statuses.
+ if err := process.Status().ScheduledStatusesScheduleAll(ctx); err != nil {
+ return fmt.Errorf("error scheduling status publications: %w", err)
+ }
+
// Initialize metrics.
if err := observability.InitializeMetrics(ctx, state.DB); err != nil {
return fmt.Errorf("error initializing metrics: %w", err)
diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml
index 955d332a3..c889c92e8 100644
--- a/docs/api/swagger.yaml
+++ b/docs/api/swagger.yaml
@@ -130,6 +130,69 @@ definitions:
title: NodeInfoUsers represents aggregate information about the users on the server.
type: object
x-go-package: code.superseriousbusiness.org/gotosocial/internal/api/model
+ ScheduledStatusParams:
+ properties:
+ application_id:
+ type: string
+ x-go-name: ApplicationID
+ content_type:
+ type: string
+ x-go-name: ContentType
+ in_reply_to_id:
+ type: string
+ x-go-name: InReplyToID
+ interaction_policy:
+ $ref: '#/definitions/interactionPolicy'
+ language:
+ type: string
+ x-go-name: Language
+ local_only:
+ type: boolean
+ x-go-name: LocalOnly
+ media_ids:
+ items:
+ type: string
+ type: array
+ x-go-name: MediaIDs
+ poll:
+ $ref: '#/definitions/ScheduledStatusParamsPoll'
+ scheduled_at:
+ type: string
+ x-go-name: ScheduledAt
+ sensitive:
+ type: boolean
+ x-go-name: Sensitive
+ spoiler_text:
+ type: string
+ x-go-name: SpoilerText
+ text:
+ type: string
+ x-go-name: Text
+ visibility:
+ type: string
+ x-go-name: Visibility
+ title: StatusParams represents parameters for a scheduled status.
+ type: object
+ x-go-package: code.superseriousbusiness.org/gotosocial/internal/api/model
+ ScheduledStatusParamsPoll:
+ properties:
+ expires_in:
+ format: int64
+ type: integer
+ x-go-name: ExpiresIn
+ hide_totals:
+ type: boolean
+ x-go-name: HideTotals
+ multiple:
+ type: boolean
+ x-go-name: Multiple
+ options:
+ items:
+ type: string
+ type: array
+ x-go-name: Options
+ type: object
+ x-go-package: code.superseriousbusiness.org/gotosocial/internal/api/model
Source:
description: Returned as an additional entity when verifying and updated credentials, as an attribute of Account.
properties:
@@ -2909,6 +2972,25 @@ definitions:
type: object
x-go-name: Report
x-go-package: code.superseriousbusiness.org/gotosocial/internal/api/model
+ scheduledStatus:
+ properties:
+ id:
+ type: string
+ x-go-name: ID
+ media_attachments:
+ items:
+ $ref: '#/definitions/attachment'
+ type: array
+ x-go-name: MediaAttachments
+ params:
+ $ref: '#/definitions/ScheduledStatusParams'
+ scheduled_at:
+ type: string
+ x-go-name: ScheduledAt
+ title: ScheduledStatus represents a status that will be published at a future scheduled date.
+ type: object
+ x-go-name: ScheduledStatus
+ x-go-package: code.superseriousbusiness.org/gotosocial/internal/api/model
searchResult:
properties:
accounts:
@@ -10870,6 +10952,159 @@ paths:
summary: Get one report with the given id.
tags:
- reports
+ /api/v1/scheduled_statuses:
+ get:
+ operationId: getScheduledStatuses
+ parameters:
+ - 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
+ name: max_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: since_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
+ name: min_id
+ type: string
+ - default: 20
+ description: Number of scheduled statuses to return.
+ in: query
+ name: limit
+ type: integer
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: ""
+ headers:
+ Link:
+ description: Links to the next and previous queries.
+ type: string
+ schema:
+ items:
+ $ref: '#/definitions/scheduledStatus'
+ type: array
+ "400":
+ description: bad request
+ "401":
+ description: unauthorized
+ "404":
+ description: not found
+ "406":
+ description: not acceptable
+ "500":
+ description: internal server error
+ security:
+ - OAuth2 Bearer:
+ - read:statuses
+ summary: Get an array of statuses scheduled by authorized user.
+ tags:
+ - scheduled_statuses
+ /api/v1/scheduled_statuses/{id}:
+ delete:
+ operationId: deleteScheduledStatus
+ parameters:
+ - description: ID of the status
+ in: path
+ name: id
+ required: true
+ type: string
+ produces:
+ - application/json
+ 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
+ security:
+ - OAuth2 Bearer:
+ - write:statuses
+ summary: Cancel a scheduled status with the given id.
+ tags:
+ - scheduled_statuses
+ get:
+ operationId: getScheduledStatus
+ parameters:
+ - description: ID of the status
+ in: path
+ name: id
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: ""
+ schema:
+ $ref: '#/definitions/scheduledStatus'
+ "400":
+ description: bad request
+ "401":
+ description: unauthorized
+ "404":
+ description: not found
+ "406":
+ description: not acceptable
+ "500":
+ description: internal server error
+ security:
+ - OAuth2 Bearer:
+ - read:statuses
+ summary: Get a scheduled status with the given id.
+ tags:
+ - scheduled_statuses
+ put:
+ description: Update a scheduled status's publishing date
+ operationId: updateScheduledStatus
+ parameters:
+ - description: ID of the status
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: |-
+ ISO 8601 Datetime at which to schedule a status.
+
+ Must be at least 5 minutes in the future.
+ format: date-time
+ in: formData
+ name: scheduled_at
+ type: string
+ x-go-name: ScheduledAt
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: ""
+ 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
+ security:
+ - OAuth2 Bearer:
+ - write:statuses
+ tags:
+ - scheduled_statuses
/api/v1/statuses:
post:
consumes:
@@ -10994,7 +11229,6 @@ paths:
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.
@@ -11057,6 +11291,8 @@ paths:
description: not found
"406":
description: not acceptable
+ "422":
+ description: unprocessable content
"500":
description: internal server error
"501":
diff --git a/docs/configuration/instance.md b/docs/configuration/instance.md
index 1ad3dc71d..433363927 100644
--- a/docs/configuration/instance.md
+++ b/docs/configuration/instance.md
@@ -221,9 +221,7 @@ instance-stats-mode: ""
# Bool. This flag controls whether local accounts may backdate statuses
# using past dates with the scheduled_at param to /api/v1/statuses.
-# This flag does not affect scheduling posts in the future
-# (which is currently not implemented anyway),
-# nor can it prevent remote accounts from backdating their own statuses.
+# This flag can't prevent remote accounts from backdating their own statuses.
#
# If true, all local accounts may backdate statuses.
# If false, status backdating will be disabled and an error will be returned if it's used.
diff --git a/docs/configuration/statuses.md b/docs/configuration/statuses.md
index acf82293f..d456723ce 100644
--- a/docs/configuration/statuses.md
+++ b/docs/configuration/statuses.md
@@ -35,4 +35,14 @@ statuses-poll-option-max-chars: 50
# Examples: [4, 6, 10]
# 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
```
diff --git a/example/config.yaml b/example/config.yaml
index 3c573e01a..a3b9ab5cd 100644
--- a/example/config.yaml
+++ b/example/config.yaml
@@ -520,9 +520,7 @@ instance-stats-mode: ""
# Bool. This flag controls whether local accounts may backdate statuses
# using past dates with the scheduled_at param to /api/v1/statuses.
-# This flag does not affect scheduling posts in the future
-# (which is currently not implemented anyway),
-# nor can it prevent remote accounts from backdating their own statuses.
+# This flag can't prevent remote accounts from backdating their own statuses.
#
# If true, all local accounts may backdate statuses.
# If false, status backdating will be disabled and an error will be returned if it's used.
@@ -870,6 +868,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/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 .
+
+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 .
+
+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 .
+
+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 .
+
+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 .
+
+package scheduledstatuses
+
+import (
+ "net/http"
+ "time"
+
+ apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model"
+ apiutil "code.superseriousbusiness.org/gotosocial/internal/api/util"
+ "code.superseriousbusiness.org/gotosocial/internal/gtserror"
+
+ "github.com/gin-gonic/gin"
+)
+
+// ScheduledStatusPUTHandler swagger:operation PUT /api/v1/scheduled_statuses/{id} updateScheduledStatus
+//
+// Update a scheduled status's publishing date
+//
+// ---
+// tags:
+// - scheduled_statuses
+//
+// produces:
+// - application/json
+//
+// parameters:
+// -
+// name: id
+// type: string
+// description: ID of the status
+// in: path
+// required: true
+// -
+// name: scheduled_at
+// x-go-name: ScheduledAt
+// description: |-
+// ISO 8601 Datetime at which to schedule a status.
+//
+// Must be at least 5 minutes in the future.
+// type: string
+// format: date-time
+// in: formData
+//
+// security:
+// - OAuth2 Bearer:
+// - write:statuses
+//
+// responses:
+// '200':
+// schema:
+// "$ref": "#/definitions/scheduledStatus"
+// '400':
+// description: bad request
+// '401':
+// description: unauthorized
+// '404':
+// description: not found
+// '406':
+// description: not acceptable
+// '422':
+// description: unprocessable content
+// '500':
+// description: internal server error
+func (m *Module) ScheduledStatusPUTHandler(c *gin.Context) {
+ authed, errWithCode := apiutil.TokenAuth(c,
+ true, true, true, true,
+ apiutil.ScopeWriteStatuses,
+ )
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ if authed.Account.IsMoving() {
+ apiutil.ForbiddenAfterMove(c)
+ return
+ }
+
+ if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ targetScheduledStatusID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ form := &apimodel.ScheduledStatusUpdateRequest{}
+ if err := c.ShouldBind(form); err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ now := time.Now()
+ if !now.Add(5 * time.Minute).Before(*form.ScheduledAt) {
+ const errText = "scheduled_at must be at least 5 minutes in the future"
+ apiutil.ErrorHandler(c, gtserror.NewErrorUnprocessableEntity(gtserror.New(errText), errText), m.processor.InstanceGetV1)
+ return
+ }
+
+ scheduledStatus, errWithCode := m.processor.Status().ScheduledStatusesUpdate(
+ c.Request.Context(),
+ authed.Account,
+ targetScheduledStatusID,
+ form.ScheduledAt,
+ )
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ apiutil.JSON(c, http.StatusOK, scheduledStatus)
+}
diff --git a/internal/api/client/statuses/statuscreate.go b/internal/api/client/statuses/statuscreate.go
index 048acd421..167b59b23 100644
--- a/internal/api/client/statuses/statuscreate.go
+++ b/internal/api/client/statuses/statuscreate.go
@@ -181,7 +181,6 @@ import (
//
// Providing this parameter with a *future* time will cause ScheduledStatus to be returned instead of Status.
// Must be at least 5 minutes in the future.
-// This feature isn't implemented yet.
//
// Providing this parameter with a *past* time will cause the status to be backdated,
// and will not push it to the user's followers. This is intended for importing old statuses.
@@ -256,6 +255,8 @@ import (
// description: not found
// '406':
// description: not acceptable
+// '422':
+// description: unprocessable content
// '500':
// description: internal server error
// '501':
@@ -300,7 +301,9 @@ func (m *Module) StatusCreatePOSTHandler(c *gin.Context) {
authed.Account,
authed.Application,
form,
+ nil,
)
+
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
diff --git a/internal/api/client/statuses/statuscreate_test.go b/internal/api/client/statuses/statuscreate_test.go
index 090dae593..84a6622a2 100644
--- a/internal/api/client/statuses/statuscreate_test.go
+++ b/internal/api/client/statuses/statuscreate_test.go
@@ -476,13 +476,24 @@ func (suite *StatusCreateTestSuite) TestPostNewScheduledStatus() {
"scheduled_at": {"2080-10-04T15:32:02.018Z"},
}, "")
- // We should have 501 from
+ // We should have OK from
// our call to the function.
- suite.Equal(http.StatusNotImplemented, recorder.Code)
+ suite.Equal(http.StatusOK, recorder.Code)
- // We should have a helpful error message.
+ // A scheduled status with scheduled_at and status params should be returned.
suite.Equal(`{
- "error": "Not Implemented: scheduled statuses are not yet supported"
+ "id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
+ "media_attachments": [],
+ "params": {
+ "application_id": "01F8MGY43H3N2C8EWPR2FPYEXG",
+ "language": "",
+ "scheduled_at": null,
+ "sensitive": true,
+ "spoiler_text": "hello hello",
+ "text": "this is a brand new status! #helloworld",
+ "visibility": "private"
+ },
+ "scheduled_at": "2080-10-04T15:32:02.018Z"
}`, out)
}
diff --git a/internal/api/model/scheduledstatus.go b/internal/api/model/scheduledstatus.go
index 1b074ab55..4acfde697 100644
--- a/internal/api/model/scheduledstatus.go
+++ b/internal/api/model/scheduledstatus.go
@@ -17,22 +17,46 @@
package model
+import "time"
+
// ScheduledStatus represents a status that will be published at a future scheduled date.
+//
+// swagger:model scheduledStatus
type ScheduledStatus struct {
- ID string `json:"id"`
- ScheduledAt string `json:"scheduled_at"`
- Params *StatusParams `json:"params"`
- MediaAttachments []Attachment `json:"media_attachments"`
+ ID string `json:"id"`
+ ScheduledAt string `json:"scheduled_at"`
+ Params *ScheduledStatusParams `json:"params"`
+ MediaAttachments []*Attachment `json:"media_attachments"`
}
// StatusParams represents parameters for a scheduled status.
-type StatusParams struct {
- Text string `json:"text"`
- InReplyToID string `json:"in_reply_to_id,omitempty"`
- MediaIDs []string `json:"media_ids,omitempty"`
- Sensitive bool `json:"sensitive,omitempty"`
- SpoilerText string `json:"spoiler_text,omitempty"`
- Visibility string `json:"visibility"`
- ScheduledAt string `json:"scheduled_at,omitempty"`
- ApplicationID string `json:"application_id"`
+type ScheduledStatusParams struct {
+ Text string `json:"text"`
+ MediaIDs []string `json:"media_ids,omitempty"`
+ Sensitive bool `json:"sensitive,omitempty"`
+ Poll *ScheduledStatusParamsPoll `json:"poll,omitempty"`
+ SpoilerText string `json:"spoiler_text,omitempty"`
+ Visibility Visibility `json:"visibility"`
+ InReplyToID string `json:"in_reply_to_id,omitempty"`
+ Language string `json:"language"`
+ ApplicationID string `json:"application_id"`
+ LocalOnly bool `json:"local_only,omitempty"`
+ ContentType StatusContentType `json:"content_type,omitempty"`
+ InteractionPolicy *InteractionPolicy `json:"interaction_policy,omitempty"`
+ ScheduledAt *string `json:"scheduled_at"`
+}
+
+type ScheduledStatusParamsPoll struct {
+ Options []string `json:"options"`
+ ExpiresIn int `json:"expires_in"`
+ Multiple bool `json:"multiple"`
+ HideTotals bool `json:"hide_totals"`
+}
+
+// ScheduledStatusUpdateRequest models a request to update the scheduled status publication date.
+//
+// swagger:ignore
+type ScheduledStatusUpdateRequest struct {
+ // ISO 8601 Datetime at which to schedule a status.
+ ScheduledAt *time.Time `form:"scheduled_at" json:"scheduled_at"`
}
diff --git a/internal/api/model/status.go b/internal/api/model/status.go
index 4561996ad..3edb4be5f 100644
--- a/internal/api/model/status.go
+++ b/internal/api/model/status.go
@@ -252,7 +252,6 @@ type StatusCreateRequest struct {
//
// Providing this parameter with a *future* time will cause ScheduledStatus to be returned instead of Status.
// Must be at least 5 minutes in the future.
- // This feature isn't implemented yet.
//
// Providing this parameter with a *past* time will cause the status to be backdated,
// and will not push it to the user's followers. This is intended for importing old statuses.
diff --git a/internal/cache/cache.go b/internal/cache/cache.go
index 5611ddec0..2cc07de96 100644
--- a/internal/cache/cache.go
+++ b/internal/cache/cache.go
@@ -113,6 +113,7 @@ func (c *Caches) Init() {
c.initPollVote()
c.initPollVoteIDs()
c.initReport()
+ c.initScheduledStatus()
c.initSinBinStatus()
c.initStatus()
c.initStatusBookmark()
@@ -200,6 +201,7 @@ func (c *Caches) Sweep(threshold float64) {
c.DB.PollVote.Trim(threshold)
c.DB.PollVoteIDs.Trim(threshold)
c.DB.Report.Trim(threshold)
+ c.DB.ScheduledStatus.Trim(threshold)
c.DB.SinBinStatus.Trim(threshold)
c.DB.Status.Trim(threshold)
c.DB.StatusBookmark.Trim(threshold)
diff --git a/internal/cache/db.go b/internal/cache/db.go
index d31017ccd..385c5bcbb 100644
--- a/internal/cache/db.go
+++ b/internal/cache/db.go
@@ -219,6 +219,9 @@ type DBCaches struct {
// Report provides access to the gtsmodel Report database cache.
Report StructCache[*gtsmodel.Report]
+ // ScheduledStatus provides access to the gtsmodel ScheduledStatus database cache.
+ ScheduledStatus StructCache[*gtsmodel.ScheduledStatus]
+
// SinBinStatus provides access to the gtsmodel SinBinStatus database cache.
SinBinStatus StructCache[*gtsmodel.SinBinStatus]
@@ -1287,6 +1290,40 @@ func (c *Caches) initReport() {
})
}
+func (c *Caches) initScheduledStatus() {
+ // Calculate maximum cache size.
+ cap := calculateResultCacheMax(
+ sizeofScheduledStatus(), // model in-mem size.
+ config.GetCacheScheduledStatusMemRatio(),
+ )
+
+ log.Infof(nil, "cache size = %d", cap)
+
+ copyF := func(s1 *gtsmodel.ScheduledStatus) *gtsmodel.ScheduledStatus {
+ s2 := new(gtsmodel.ScheduledStatus)
+ *s2 = *s1
+
+ // Don't include ptr fields that
+ // will be populated separately.
+ s2.Account = nil
+ s2.Application = nil
+ s2.MediaAttachments = nil
+
+ return s2
+ }
+
+ c.DB.ScheduledStatus.Init(structr.CacheConfig[*gtsmodel.ScheduledStatus]{
+ Indices: []structr.IndexConfig{
+ {Fields: "ID"},
+ {Fields: "AccountID", Multiple: true},
+ },
+ MaxSize: cap,
+ IgnoreErr: ignoreErrors,
+ Copy: copyF,
+ Invalidate: c.OnInvalidateScheduledStatus,
+ })
+}
+
func (c *Caches) initSinBinStatus() {
// Calculate maximum cache size.
cap := calculateResultCacheMax(
diff --git a/internal/cache/invalidate.go b/internal/cache/invalidate.go
index 863719b77..c6c25d4eb 100644
--- a/internal/cache/invalidate.go
+++ b/internal/cache/invalidate.go
@@ -292,6 +292,11 @@ func (c *Caches) OnInvalidatePollVote(vote *gtsmodel.PollVote) {
c.DB.PollVoteIDs.Invalidate(vote.PollID)
}
+func (c *Caches) OnInvalidateScheduledStatus(status *gtsmodel.ScheduledStatus) {
+ // Invalidate cache of related media attachments.
+ c.DB.Media.InvalidateIDs("ID", status.MediaIDs)
+}
+
func (c *Caches) OnInvalidateStatus(status *gtsmodel.Status) {
// Invalidate cached stats objects for this account.
c.DB.AccountStats.Invalidate("AccountID", status.AccountID)
diff --git a/internal/cache/size.go b/internal/cache/size.go
index ab54ada87..aa22b03d7 100644
--- a/internal/cache/size.go
+++ b/internal/cache/size.go
@@ -554,6 +554,25 @@ func sizeofReport() uintptr {
}))
}
+func sizeofScheduledStatus() uintptr {
+ return uintptr(size.Of(>smodel.ScheduledStatus{
+ ID: exampleID,
+ AccountID: exampleID,
+ ScheduledAt: exampleTime,
+ Text: exampleText,
+ Poll: gtsmodel.ScheduledStatusPoll{
+ Options: []string{exampleTextSmall, exampleTextSmall, exampleTextSmall, exampleTextSmall},
+ Multiple: util.Ptr(false),
+ HideTotals: util.Ptr(false),
+ },
+ MediaIDs: []string{exampleID, exampleID, exampleID},
+ Sensitive: util.Ptr(false),
+ SpoilerText: exampleText,
+ Visibility: gtsmodel.VisibilityPublic,
+ Language: "en",
+ }))
+}
+
func sizeofSinBinStatus() uintptr {
return uintptr(size.Of(>smodel.SinBinStatus{
ID: exampleID,
diff --git a/internal/cleaner/media.go b/internal/cleaner/media.go
index 84473bc22..99fd5779f 100644
--- a/internal/cleaner/media.go
+++ b/internal/cleaner/media.go
@@ -375,6 +375,25 @@ func (m *Media) pruneUnused(ctx context.Context, media *gtsmodel.MediaAttachment
}
}
+ // Check whether we have the required scheduled status for media.
+ scheduledStatus, missing, err := m.getRelatedScheduledStatus(ctx, media)
+ if err != nil {
+ return false, err
+ } else if missing {
+ l.Debug("deleting due to missing scheduled status")
+ return true, m.delete(ctx, media)
+ }
+
+ if scheduledStatus != nil {
+ // Check whether still attached to status.
+ for _, id := range scheduledStatus.MediaIDs {
+ if id == media.ID {
+ l.Debug("skippping as attached to scheduled status")
+ return false, nil
+ }
+ }
+ }
+
// Media totally unused, delete it.
l.Debug("deleting unused media")
return true, m.delete(ctx, media)
@@ -543,6 +562,29 @@ func (m *Media) getRelatedStatus(ctx context.Context, media *gtsmodel.MediaAttac
return status, false, nil
}
+func (m *Media) getRelatedScheduledStatus(ctx context.Context, media *gtsmodel.MediaAttachment) (*gtsmodel.ScheduledStatus, bool, error) {
+ if media.ScheduledStatusID == "" {
+ // no related status.
+ return nil, false, nil
+ }
+
+ // Load the status related to this media.
+ status, err := m.state.DB.GetScheduledStatusByID(
+ gtscontext.SetBarebones(ctx),
+ media.ScheduledStatusID,
+ )
+ if err != nil && !errors.Is(err, db.ErrNoEntries) {
+ return nil, false, gtserror.Newf("error fetching scheduled status by id %s: %w", media.ScheduledStatusID, err)
+ }
+
+ if status == nil {
+ // status is missing.
+ return nil, true, nil
+ }
+
+ return status, false, nil
+}
+
func (m *Media) uncache(ctx context.Context, media *gtsmodel.MediaAttachment) error {
if gtscontext.DryRun(ctx) {
// Dry run, do nothing.
diff --git a/internal/config/config.go b/internal/config/config.go
index f7a99c35f..3cab53732 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -131,6 +131,9 @@ type Configuration struct {
StatusesPollOptionMaxChars int `name:"statuses-poll-option-max-chars" usage:"Max amount of characters for a poll option"`
StatusesMediaMaxFiles int `name:"statuses-media-max-files" usage:"Maximum number of media files/attachments per status"`
+ ScheduledStatusesMaxTotal int `name:"scheduled-statuses-max-total" usage:"Maximum number of scheduled statuses per user"`
+ ScheduledStatusesMaxDaily int `name:"scheduled-statuses-max-daily" usage:"Maximum number of scheduled statuses per user for a single day"`
+
LetsEncryptEnabled bool `name:"letsencrypt-enabled" usage:"Enable letsencrypt TLS certs for this server. If set to true, then cert dir also needs to be set (or take the default)."`
LetsEncryptPort int `name:"letsencrypt-port" usage:"Port to listen on for letsencrypt certificate challenges. Must not be the same as the GtS webserver/API port."`
LetsEncryptCertDir string `name:"letsencrypt-cert-dir" usage:"Directory to store acquired letsencrypt certificates."`
@@ -252,6 +255,7 @@ type CacheConfiguration struct {
PollVoteMemRatio float64 `name:"poll-vote-mem-ratio"`
PollVoteIDsMemRatio float64 `name:"poll-vote-ids-mem-ratio"`
ReportMemRatio float64 `name:"report-mem-ratio"`
+ ScheduledStatusMemRatio float64 `name:"scheduled-status-mem-ratio"`
SinBinStatusMemRatio float64 `name:"sin-bin-status-mem-ratio"`
StatusMemRatio float64 `name:"status-mem-ratio"`
StatusBookmarkMemRatio float64 `name:"status-bookmark-mem-ratio"`
diff --git a/internal/config/defaults.go b/internal/config/defaults.go
index df3b64b40..f140d7877 100644
--- a/internal/config/defaults.go
+++ b/internal/config/defaults.go
@@ -105,6 +105,9 @@ var Defaults = Configuration{
StatusesPollOptionMaxChars: 50,
StatusesMediaMaxFiles: 6,
+ ScheduledStatusesMaxTotal: 300,
+ ScheduledStatusesMaxDaily: 25,
+
LetsEncryptEnabled: false,
LetsEncryptPort: 80,
LetsEncryptCertDir: "/gotosocial/storage/certs",
@@ -217,6 +220,7 @@ var Defaults = Configuration{
PollVoteMemRatio: 2,
PollVoteIDsMemRatio: 2,
ReportMemRatio: 1,
+ ScheduledStatusMemRatio: 4,
SinBinStatusMemRatio: 0.5,
StatusMemRatio: 5,
StatusBookmarkMemRatio: 0.5,
diff --git a/internal/config/helpers.gen.go b/internal/config/helpers.gen.go
index a5cbf4c46..9f5d6f39c 100644
--- a/internal/config/helpers.gen.go
+++ b/internal/config/helpers.gen.go
@@ -99,6 +99,8 @@ const (
StatusesPollMaxOptionsFlag = "statuses-poll-max-options"
StatusesPollOptionMaxCharsFlag = "statuses-poll-option-max-chars"
StatusesMediaMaxFilesFlag = "statuses-media-max-files"
+ ScheduledStatusesMaxTotalFlag = "scheduled-statuses-max-total"
+ ScheduledStatusesMaxDailyFlag = "scheduled-statuses-max-daily"
LetsEncryptEnabledFlag = "letsencrypt-enabled"
LetsEncryptPortFlag = "letsencrypt-port"
LetsEncryptCertDirFlag = "letsencrypt-cert-dir"
@@ -194,6 +196,7 @@ const (
CachePollVoteMemRatioFlag = "cache-poll-vote-mem-ratio"
CachePollVoteIDsMemRatioFlag = "cache-poll-vote-ids-mem-ratio"
CacheReportMemRatioFlag = "cache-report-mem-ratio"
+ CacheScheduledStatusMemRatioFlag = "cache-scheduled-status-mem-ratio"
CacheSinBinStatusMemRatioFlag = "cache-sin-bin-status-mem-ratio"
CacheStatusMemRatioFlag = "cache-status-mem-ratio"
CacheStatusBookmarkMemRatioFlag = "cache-status-bookmark-mem-ratio"
@@ -296,6 +299,8 @@ func (cfg *Configuration) RegisterFlags(flags *pflag.FlagSet) {
flags.Int("statuses-poll-max-options", cfg.StatusesPollMaxOptions, "Max amount of options permitted on a poll")
flags.Int("statuses-poll-option-max-chars", cfg.StatusesPollOptionMaxChars, "Max amount of characters for a poll option")
flags.Int("statuses-media-max-files", cfg.StatusesMediaMaxFiles, "Maximum number of media files/attachments per status")
+ flags.Int("scheduled-statuses-max-total", cfg.ScheduledStatusesMaxTotal, "Maximum number of scheduled statuses per user")
+ flags.Int("scheduled-statuses-max-daily", cfg.ScheduledStatusesMaxDaily, "Maximum number of scheduled statuses per user for a single day")
flags.Bool("letsencrypt-enabled", cfg.LetsEncryptEnabled, "Enable letsencrypt TLS certs for this server. If set to true, then cert dir also needs to be set (or take the default).")
flags.Int("letsencrypt-port", cfg.LetsEncryptPort, "Port to listen on for letsencrypt certificate challenges. Must not be the same as the GtS webserver/API port.")
flags.String("letsencrypt-cert-dir", cfg.LetsEncryptCertDir, "Directory to store acquired letsencrypt certificates.")
@@ -391,6 +396,7 @@ func (cfg *Configuration) RegisterFlags(flags *pflag.FlagSet) {
flags.Float64("cache-poll-vote-mem-ratio", cfg.Cache.PollVoteMemRatio, "")
flags.Float64("cache-poll-vote-ids-mem-ratio", cfg.Cache.PollVoteIDsMemRatio, "")
flags.Float64("cache-report-mem-ratio", cfg.Cache.ReportMemRatio, "")
+ flags.Float64("cache-scheduled-status-mem-ratio", cfg.Cache.ScheduledStatusMemRatio, "")
flags.Float64("cache-sin-bin-status-mem-ratio", cfg.Cache.SinBinStatusMemRatio, "")
flags.Float64("cache-status-mem-ratio", cfg.Cache.StatusMemRatio, "")
flags.Float64("cache-status-bookmark-mem-ratio", cfg.Cache.StatusBookmarkMemRatio, "")
@@ -414,7 +420,7 @@ func (cfg *Configuration) RegisterFlags(flags *pflag.FlagSet) {
}
func (cfg *Configuration) MarshalMap() map[string]any {
- cfgmap := make(map[string]any, 194)
+ cfgmap := make(map[string]any, 197)
cfgmap["log-level"] = cfg.LogLevel
cfgmap["log-format"] = cfg.LogFormat
cfgmap["log-timestamp-format"] = cfg.LogTimestampFormat
@@ -485,6 +491,8 @@ func (cfg *Configuration) MarshalMap() map[string]any {
cfgmap["statuses-poll-max-options"] = cfg.StatusesPollMaxOptions
cfgmap["statuses-poll-option-max-chars"] = cfg.StatusesPollOptionMaxChars
cfgmap["statuses-media-max-files"] = cfg.StatusesMediaMaxFiles
+ cfgmap["scheduled-statuses-max-total"] = cfg.ScheduledStatusesMaxTotal
+ cfgmap["scheduled-statuses-max-daily"] = cfg.ScheduledStatusesMaxDaily
cfgmap["letsencrypt-enabled"] = cfg.LetsEncryptEnabled
cfgmap["letsencrypt-port"] = cfg.LetsEncryptPort
cfgmap["letsencrypt-cert-dir"] = cfg.LetsEncryptCertDir
@@ -580,6 +588,7 @@ func (cfg *Configuration) MarshalMap() map[string]any {
cfgmap["cache-poll-vote-mem-ratio"] = cfg.Cache.PollVoteMemRatio
cfgmap["cache-poll-vote-ids-mem-ratio"] = cfg.Cache.PollVoteIDsMemRatio
cfgmap["cache-report-mem-ratio"] = cfg.Cache.ReportMemRatio
+ cfgmap["cache-scheduled-status-mem-ratio"] = cfg.Cache.ScheduledStatusMemRatio
cfgmap["cache-sin-bin-status-mem-ratio"] = cfg.Cache.SinBinStatusMemRatio
cfgmap["cache-status-mem-ratio"] = cfg.Cache.StatusMemRatio
cfgmap["cache-status-bookmark-mem-ratio"] = cfg.Cache.StatusBookmarkMemRatio
@@ -1186,6 +1195,22 @@ func (cfg *Configuration) UnmarshalMap(cfgmap map[string]any) error {
}
}
+ if ival, ok := cfgmap["scheduled-statuses-max-total"]; ok {
+ var err error
+ cfg.ScheduledStatusesMaxTotal, err = cast.ToIntE(ival)
+ if err != nil {
+ return fmt.Errorf("error casting %#v -> int for 'scheduled-statuses-max-total': %w", ival, err)
+ }
+ }
+
+ if ival, ok := cfgmap["scheduled-statuses-max-daily"]; ok {
+ var err error
+ cfg.ScheduledStatusesMaxDaily, err = cast.ToIntE(ival)
+ if err != nil {
+ return fmt.Errorf("error casting %#v -> int for 'scheduled-statuses-max-daily': %w", ival, err)
+ }
+ }
+
if ival, ok := cfgmap["letsencrypt-enabled"]; ok {
var err error
cfg.LetsEncryptEnabled, err = cast.ToBoolE(ival)
@@ -1972,6 +1997,14 @@ func (cfg *Configuration) UnmarshalMap(cfgmap map[string]any) error {
}
}
+ if ival, ok := cfgmap["cache-scheduled-status-mem-ratio"]; ok {
+ var err error
+ cfg.Cache.ScheduledStatusMemRatio, err = cast.ToFloat64E(ival)
+ if err != nil {
+ return fmt.Errorf("error casting %#v -> float64 for 'cache-scheduled-status-mem-ratio': %w", ival, err)
+ }
+ }
+
if ival, ok := cfgmap["cache-sin-bin-status-mem-ratio"]; ok {
var err error
cfg.Cache.SinBinStatusMemRatio, err = cast.ToFloat64E(ival)
@@ -3753,6 +3786,50 @@ func GetStatusesMediaMaxFiles() int { return global.GetStatusesMediaMaxFiles() }
// SetStatusesMediaMaxFiles safely sets the value for global configuration 'StatusesMediaMaxFiles' field
func SetStatusesMediaMaxFiles(v int) { global.SetStatusesMediaMaxFiles(v) }
+// GetScheduledStatusesMaxTotal safely fetches the Configuration value for state's 'ScheduledStatusesMaxTotal' field
+func (st *ConfigState) GetScheduledStatusesMaxTotal() (v int) {
+ st.mutex.RLock()
+ v = st.config.ScheduledStatusesMaxTotal
+ st.mutex.RUnlock()
+ return
+}
+
+// SetScheduledStatusesMaxTotal safely sets the Configuration value for state's 'ScheduledStatusesMaxTotal' field
+func (st *ConfigState) SetScheduledStatusesMaxTotal(v int) {
+ st.mutex.Lock()
+ defer st.mutex.Unlock()
+ st.config.ScheduledStatusesMaxTotal = v
+ st.reloadToViper()
+}
+
+// GetScheduledStatusesMaxTotal safely fetches the value for global configuration 'ScheduledStatusesMaxTotal' field
+func GetScheduledStatusesMaxTotal() int { return global.GetScheduledStatusesMaxTotal() }
+
+// SetScheduledStatusesMaxTotal safely sets the value for global configuration 'ScheduledStatusesMaxTotal' field
+func SetScheduledStatusesMaxTotal(v int) { global.SetScheduledStatusesMaxTotal(v) }
+
+// GetScheduledStatusesMaxDaily safely fetches the Configuration value for state's 'ScheduledStatusesMaxDaily' field
+func (st *ConfigState) GetScheduledStatusesMaxDaily() (v int) {
+ st.mutex.RLock()
+ v = st.config.ScheduledStatusesMaxDaily
+ st.mutex.RUnlock()
+ return
+}
+
+// SetScheduledStatusesMaxDaily safely sets the Configuration value for state's 'ScheduledStatusesMaxDaily' field
+func (st *ConfigState) SetScheduledStatusesMaxDaily(v int) {
+ st.mutex.Lock()
+ defer st.mutex.Unlock()
+ st.config.ScheduledStatusesMaxDaily = v
+ st.reloadToViper()
+}
+
+// GetScheduledStatusesMaxDaily safely fetches the value for global configuration 'ScheduledStatusesMaxDaily' field
+func GetScheduledStatusesMaxDaily() int { return global.GetScheduledStatusesMaxDaily() }
+
+// SetScheduledStatusesMaxDaily safely sets the value for global configuration 'ScheduledStatusesMaxDaily' field
+func SetScheduledStatusesMaxDaily(v int) { global.SetScheduledStatusesMaxDaily(v) }
+
// GetLetsEncryptEnabled safely fetches the Configuration value for state's 'LetsEncryptEnabled' field
func (st *ConfigState) GetLetsEncryptEnabled() (v bool) {
st.mutex.RLock()
@@ -5859,6 +5936,28 @@ func GetCacheReportMemRatio() float64 { return global.GetCacheReportMemRatio() }
// SetCacheReportMemRatio safely sets the value for global configuration 'Cache.ReportMemRatio' field
func SetCacheReportMemRatio(v float64) { global.SetCacheReportMemRatio(v) }
+// GetCacheScheduledStatusMemRatio safely fetches the Configuration value for state's 'Cache.ScheduledStatusMemRatio' field
+func (st *ConfigState) GetCacheScheduledStatusMemRatio() (v float64) {
+ st.mutex.RLock()
+ v = st.config.Cache.ScheduledStatusMemRatio
+ st.mutex.RUnlock()
+ return
+}
+
+// SetCacheScheduledStatusMemRatio safely sets the Configuration value for state's 'Cache.ScheduledStatusMemRatio' field
+func (st *ConfigState) SetCacheScheduledStatusMemRatio(v float64) {
+ st.mutex.Lock()
+ defer st.mutex.Unlock()
+ st.config.Cache.ScheduledStatusMemRatio = v
+ st.reloadToViper()
+}
+
+// GetCacheScheduledStatusMemRatio safely fetches the value for global configuration 'Cache.ScheduledStatusMemRatio' field
+func GetCacheScheduledStatusMemRatio() float64 { return global.GetCacheScheduledStatusMemRatio() }
+
+// SetCacheScheduledStatusMemRatio safely sets the value for global configuration 'Cache.ScheduledStatusMemRatio' field
+func SetCacheScheduledStatusMemRatio(v float64) { global.SetCacheScheduledStatusMemRatio(v) }
+
// GetCacheSinBinStatusMemRatio safely fetches the Configuration value for state's 'Cache.SinBinStatusMemRatio' field
func (st *ConfigState) GetCacheSinBinStatusMemRatio() (v float64) {
st.mutex.RLock()
@@ -6545,6 +6644,7 @@ func (st *ConfigState) GetTotalOfMemRatios() (total float64) {
total += st.config.Cache.PollVoteMemRatio
total += st.config.Cache.PollVoteIDsMemRatio
total += st.config.Cache.ReportMemRatio
+ total += st.config.Cache.ScheduledStatusMemRatio
total += st.config.Cache.SinBinStatusMemRatio
total += st.config.Cache.StatusMemRatio
total += st.config.Cache.StatusBookmarkMemRatio
@@ -7328,6 +7428,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"},
} {
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 .
+
+package migrations
+
+import (
+ "context"
+
+ gtsmodel "code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
+ "github.com/uptrace/bun"
+)
+
+func init() {
+ up := func(ctx context.Context, db *bun.DB) error {
+ return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
+ if _, err := tx.
+ NewCreateTable().
+ Model(>smodel.ScheduledStatus{}).
+ IfNotExists().
+ Exec(ctx); err != nil {
+ return err
+ }
+
+ // Add indexes to the scheduled statuses tables.
+ for index, columns := range map[string][]string{
+ "scheduled_statuses_account_id_idx": {"account_id"},
+ "scheduled_statuses_scheduled_at_idx": {"scheduled_at"},
+ } {
+ if _, err := tx.
+ NewCreateIndex().
+ Table("scheduled_statuses").
+ Index(index).
+ Column(columns...).
+ IfNotExists().
+ Exec(ctx); err != nil {
+ return err
+ }
+ }
+
+ return nil
+ })
+ }
+
+ down := func(ctx context.Context, db *bun.DB) error {
+ return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
+ return nil
+ })
+ }
+
+ if err := Migrations.Register(up, down); err != nil {
+ panic(err)
+ }
+}
diff --git a/internal/db/bundb/scheduledstatus.go b/internal/db/bundb/scheduledstatus.go
new file mode 100644
index 000000000..44cbd0f59
--- /dev/null
+++ b/internal/db/bundb/scheduledstatus.go
@@ -0,0 +1,402 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+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 .
+
+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 .
+
+package gtsmodel
+
+import "time"
+
+// ScheduledStatus represents a status that is scheduled to be published at given time by a local user.
+type ScheduledStatus struct {
+ ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
+ AccountID string `bun:"type:CHAR(26),nullzero,notnull"` // which account scheduled this status
+ Account *Account `bun:"-"` // Account corresponding to AccountID
+ ScheduledAt time.Time `bun:"type:timestamptz,nullzero,notnull"` // time at which the status is scheduled
+ Text string `bun:""` // Text content of the status
+ Poll ScheduledStatusPoll `bun:",embed:poll_,notnull,nullzero"` //
+ MediaIDs []string `bun:"attachments,array"` // Database IDs of any media attachments associated with this status
+ MediaAttachments []*MediaAttachment `bun:"-"` // Attachments corresponding to media IDs
+ Sensitive *bool `bun:",nullzero,notnull,default:false"` // mark the status as sensitive?
+ SpoilerText string `bun:""` // Original text of the content warning without formatting
+ Visibility Visibility `bun:",nullzero,notnull"` // visibility entry for this status
+ InReplyToID string `bun:"type:CHAR(26),nullzero"` // id of the status this status replies to
+ Language string `bun:",nullzero"` // what language is this status written in?
+ ApplicationID string `bun:"type:CHAR(26),nullzero"` // Which application was used to create this status?
+ Application *Application `bun:"-"` //
+ LocalOnly *bool `bun:",nullzero,notnull,default:false"` // Whether the status is not federated
+ ContentType string `bun:",nullzero"` // Content type used to process the original text of the status
+ InteractionPolicy *InteractionPolicy `bun:""` // InteractionPolicy for this status. If null then the default InteractionPolicy should be assumed for this status's Visibility. Always null for boost wrappers.
+ Idempotency string `bun:",nullzero"` // Currently unused
+}
+
+type ScheduledStatusPoll struct {
+ Options []string `bun:",nullzero,array"` // The available options for this poll.
+ ExpiresIn int `bun:",nullzero"` // Duration the poll should be open, in seconds
+ Multiple *bool `bun:",nullzero,notnull,default:false"` // Is this a multiple choice poll? i.e. can you vote on multiple options.
+ HideTotals *bool `bun:",nullzero,notnull,default:false"` // Hides vote counts until poll ends.
+}
+
+// AttachmentsPopulated returns whether media attachments
+// are populated according to current AttachmentIDs.
+func (s *ScheduledStatus) AttachmentsPopulated() bool {
+ if len(s.MediaIDs) != len(s.MediaAttachments) {
+ // this is the quickest indicator.
+ return false
+ }
+ for i, id := range s.MediaIDs {
+ if s.MediaAttachments[i].ID != id {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/processing/account/delete.go b/internal/processing/account/delete.go
index 717c03fcc..a45afe754 100644
--- a/internal/processing/account/delete.go
+++ b/internal/processing/account/delete.go
@@ -459,6 +459,11 @@ func (p *Processor) deleteAccountPeripheral(
if err := p.state.DB.DeleteAccountStats(ctx, account.ID); err != nil {
log.Errorf("error deleting stats for account: %v", err)
}
+
+ // Delete statuses scheduled by given account, only for local.
+ if err := p.state.DB.DeleteScheduledStatusesByAccountID(ctx, account.ID); err != nil {
+ log.Errorf("error deleting scheduled statuses for account: %v", err)
+ }
}
// Delete all bookmarks targeting given account, local and remote.
diff --git a/internal/processing/application/delete.go b/internal/processing/application/delete.go
index 7d1a3b495..6b3856bf0 100644
--- a/internal/processing/application/delete.go
+++ b/internal/processing/application/delete.go
@@ -66,5 +66,11 @@ func (p *Processor) Delete(
return nil, gtserror.NewErrorInternalError(err)
}
+ // Delete all scheduled statuses posted from the app.
+ if err := p.state.DB.DeleteScheduledStatusesByApplicationID(ctx, appID); err != nil {
+ err := gtserror.Newf("db error deleting scheduled statuses for app %s: %w", appID, err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
return apiApp, nil
}
diff --git a/internal/processing/status/common.go b/internal/processing/status/common.go
index c764a64b4..ca17ab80e 100644
--- a/internal/processing/status/common.go
+++ b/internal/processing/status/common.go
@@ -282,6 +282,7 @@ func (p *Processor) processMedia(
authorID string,
statusID string,
mediaIDs []string,
+ scheduledStatusID *string,
) (
[]*gtsmodel.MediaAttachment,
gtserror.WithCode,
@@ -315,7 +316,7 @@ func (p *Processor) processMedia(
// Check media isn't already attached to another status.
if (media.StatusID != "" && media.StatusID != statusID) ||
- (media.ScheduledStatusID != "" && media.ScheduledStatusID != statusID) {
+ (media.ScheduledStatusID != "" && (media.ScheduledStatusID != statusID && (scheduledStatusID == nil || media.ScheduledStatusID != *scheduledStatusID))) {
text := fmt.Sprintf("media already attached to status: %s", id)
return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
}
diff --git a/internal/processing/status/create.go b/internal/processing/status/create.go
index 1a00d8ab7..57338708c 100644
--- a/internal/processing/status/create.go
+++ b/internal/processing/status/create.go
@@ -44,10 +44,8 @@ func (p *Processor) Create(
requester *gtsmodel.Account,
application *gtsmodel.Application,
form *apimodel.StatusCreateRequest,
-) (
- *apimodel.Status,
- gtserror.WithCode,
-) {
+ scheduledStatusID *string,
+) (any, gtserror.WithCode) {
// Validate incoming form status content.
if errWithCode := validateStatusContent(
form.Status,
@@ -83,16 +81,6 @@ func (p *Processor) Create(
return nil, errWithCode
}
- // Process incoming status attachments.
- media, errWithCode := p.processMedia(ctx,
- requester.ID,
- statusID,
- form.MediaIDs,
- )
- if errWithCode != nil {
- return nil, errWithCode
- }
-
// Generate necessary URIs for username, to build status URIs.
accountURIs := uris.GenerateURIsForAccount(requester.Username)
@@ -105,16 +93,27 @@ func (p *Processor) Create(
// Handle backfilled/scheduled statuses.
backfill := false
- if form.ScheduledAt != nil {
- scheduledAt := *form.ScheduledAt
- // Statuses may only be scheduled
- // a minimum time into the future.
- if now.Before(scheduledAt) {
- const errText = "scheduled statuses are not yet supported"
- return nil, gtserror.NewErrorNotImplemented(gtserror.New(errText), errText)
+ switch {
+ case form.ScheduledAt == nil:
+ // No scheduling/backfilling
+ break
+ case form.ScheduledAt.Sub(now) >= 5*time.Minute:
+ // Statuses may only be scheduled a minimum time into the future.
+ scheduledStatus, errWithCode := p.processScheduledStatus(ctx, statusID, form, requester, application)
+
+ if errWithCode != nil {
+ return nil, errWithCode
}
+ return scheduledStatus, nil
+
+ case now.Before(*form.ScheduledAt):
+ // Invalid future scheduled status
+ const errText = "scheduled_at must be at least 5 minutes in the future"
+ return nil, gtserror.NewErrorUnprocessableEntity(gtserror.New(errText), errText)
+
+ default:
// If not scheduled into the future, this status is being backfilled.
if !config.GetInstanceAllowBackdatingStatuses() {
const errText = "backdating statuses has been disabled on this instance"
@@ -127,7 +126,7 @@ func (p *Processor) Create(
// this would also cause issues with time.Time.IsZero() checks
// that normally signify an absent optional time,
// but this check covers both cases.
- if scheduledAt.Compare(time.UnixMilli(0)) <= 0 {
+ if form.ScheduledAt.Compare(time.UnixMilli(0)) <= 0 {
const errText = "statuses can't be backdated to or before the UNIX epoch"
return nil, gtserror.NewErrorNotAcceptable(gtserror.New(errText), errText)
}
@@ -138,7 +137,7 @@ func (p *Processor) Create(
backfill = true
// Update to backfill date.
- createdAt = scheduledAt
+ createdAt = *form.ScheduledAt
// Generate an appropriate, (and unique!), ID for the creation time.
if statusID, err = p.backfilledStatusID(ctx, createdAt); err != nil {
@@ -146,6 +145,17 @@ func (p *Processor) Create(
}
}
+ // Process incoming status attachments.
+ media, errWithCode := p.processMedia(ctx,
+ requester.ID,
+ statusID,
+ form.MediaIDs,
+ scheduledStatusID,
+ )
+ if errWithCode != nil {
+ return nil, errWithCode
+ }
+
status := >smodel.Status{
ID: statusID,
URI: accountURIs.StatusesURI + "/" + statusID,
@@ -546,3 +556,103 @@ func processInteractionPolicy(
// setting it explicitly to save space.
return nil
}
+
+func (p *Processor) processScheduledStatus(
+ ctx context.Context,
+ statusID string,
+ form *apimodel.StatusCreateRequest,
+ requester *gtsmodel.Account,
+ application *gtsmodel.Application,
+) (*apimodel.ScheduledStatus, gtserror.WithCode) {
+ // Validate scheduled status against server configuration
+ // (max scheduled statuses limit).
+ if errWithCode := p.validateScheduledStatusLimits(ctx, requester.ID, form.ScheduledAt, nil); errWithCode != nil {
+ return nil, errWithCode
+ }
+
+ media, errWithCode := p.processMedia(ctx,
+ requester.ID,
+ statusID,
+ form.MediaIDs,
+ nil,
+ )
+ if errWithCode != nil {
+ return nil, errWithCode
+ }
+ status := >smodel.ScheduledStatus{
+ ID: statusID,
+ Account: requester,
+ AccountID: requester.ID,
+ Application: application,
+ ApplicationID: application.ID,
+ ScheduledAt: *form.ScheduledAt,
+ Text: form.Status,
+ MediaIDs: form.MediaIDs,
+ MediaAttachments: media,
+ Sensitive: &form.Sensitive,
+ SpoilerText: form.SpoilerText,
+ InReplyToID: form.InReplyToID,
+ Language: form.Language,
+ LocalOnly: form.LocalOnly,
+ ContentType: string(form.ContentType),
+ }
+
+ if form.Poll != nil {
+ status.Poll = gtsmodel.ScheduledStatusPoll{
+ Options: form.Poll.Options,
+ ExpiresIn: form.Poll.ExpiresIn,
+ Multiple: &form.Poll.Multiple,
+ HideTotals: &form.Poll.HideTotals,
+ }
+ }
+
+ accountDefaultVisibility := requester.Settings.Privacy
+
+ switch {
+ case form.Visibility != "":
+ status.Visibility = typeutils.APIVisToVis(form.Visibility)
+
+ case accountDefaultVisibility != 0:
+ status.Visibility = accountDefaultVisibility
+ form.Visibility = typeutils.VisToAPIVis(accountDefaultVisibility)
+
+ default:
+ status.Visibility = gtsmodel.VisibilityDefault
+ form.Visibility = typeutils.VisToAPIVis(gtsmodel.VisibilityDefault)
+ }
+
+ if form.InteractionPolicy != nil {
+ interactionPolicy, err := typeutils.APIInteractionPolicyToInteractionPolicy(form.InteractionPolicy, form.Visibility)
+
+ if err != nil {
+ err := gtserror.Newf("error converting interaction policy: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ status.InteractionPolicy = interactionPolicy
+ }
+
+ // Insert this newly prepared status into the database.
+ if err := p.state.DB.PutScheduledStatus(ctx, status); err != nil {
+ err := gtserror.Newf("error inserting status in db: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ // Schedule the newly inserted status for publishing.
+ if err := p.ScheduledStatusesSchedulePublication(ctx, status.ID); err != nil {
+ err := gtserror.Newf("error scheduling status publish: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ apiScheduledStatus, err := p.converter.ScheduledStatusToAPIScheduledStatus(
+ ctx,
+ status,
+ )
+
+ if err != nil {
+ err := gtserror.Newf("error converting: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ return apiScheduledStatus, nil
+}
diff --git a/internal/processing/status/create_test.go b/internal/processing/status/create_test.go
index 82bc801c4..646a26978 100644
--- a/internal/processing/status/create_test.go
+++ b/internal/processing/status/create_test.go
@@ -53,7 +53,8 @@ func (suite *StatusCreateTestSuite) TestProcessContentWarningWithQuotationMarks(
ContentType: apimodel.StatusContentTypePlain,
}
- apiStatus, err := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm)
+ apiStatusAny, err := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm, nil)
+ apiStatus := apiStatusAny.(*apimodel.Status)
suite.NoError(err)
suite.NotNil(apiStatus)
@@ -84,7 +85,8 @@ func (suite *StatusCreateTestSuite) TestProcessStatusMarkdownWithUnderscoreEmoji
ContentType: apimodel.StatusContentTypeMarkdown,
}
- apiStatus, err := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm)
+ apiStatusAny, err := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm, nil)
+ apiStatus := apiStatusAny.(*apimodel.Status)
suite.NoError(err)
suite.NotNil(apiStatus)
@@ -111,7 +113,8 @@ func (suite *StatusCreateTestSuite) TestProcessStatusMarkdownWithSpoilerTextEmoj
ContentType: apimodel.StatusContentTypeMarkdown,
}
- apiStatus, err := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm)
+ apiStatusAny, err := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm, nil)
+ apiStatus := apiStatusAny.(*apimodel.Status)
suite.NoError(err)
suite.NotNil(apiStatus)
@@ -142,7 +145,7 @@ func (suite *StatusCreateTestSuite) TestProcessMediaDescriptionTooShort() {
ContentType: apimodel.StatusContentTypePlain,
}
- apiStatus, err := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm)
+ apiStatus, err := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm, nil)
suite.EqualError(err, "media description less than min chars (100)")
suite.Nil(apiStatus)
}
@@ -167,7 +170,8 @@ func (suite *StatusCreateTestSuite) TestProcessLanguageWithScriptPart() {
ContentType: apimodel.StatusContentTypePlain,
}
- apiStatus, err := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm)
+ apiStatusAny, err := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm, nil)
+ apiStatus := apiStatusAny.(*apimodel.Status)
suite.NoError(err)
suite.NotNil(apiStatus)
@@ -197,7 +201,8 @@ func (suite *StatusCreateTestSuite) TestProcessReplyToUnthreadedRemoteStatus() {
ContentType: apimodel.StatusContentTypePlain,
}
- apiStatus, err := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm)
+ apiStatusAny, err := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm, nil)
+ apiStatus := apiStatusAny.(*apimodel.Status)
suite.NoError(err)
suite.NotNil(apiStatus)
@@ -230,7 +235,8 @@ func (suite *StatusCreateTestSuite) TestProcessNoContentTypeUsesDefault() {
ContentType: "",
}
- apiStatus, errWithCode := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm)
+ apiStatusAny, errWithCode := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm, nil)
+ apiStatus := apiStatusAny.(*apimodel.Status)
suite.NoError(errWithCode)
suite.NotNil(apiStatus)
@@ -260,7 +266,7 @@ func (suite *StatusCreateTestSuite) TestProcessInvalidVisibility() {
ContentType: apimodel.StatusContentTypePlain,
}
- apiStatus, errWithCode := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm)
+ apiStatus, errWithCode := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm, nil)
suite.Nil(apiStatus)
suite.Equal(http.StatusUnprocessableEntity, errWithCode.Code())
suite.Equal("Unprocessable Entity: processVisibility: invalid visibility", errWithCode.Safe())
diff --git a/internal/processing/status/edit.go b/internal/processing/status/edit.go
index b64a0583b..3ca21f5cf 100644
--- a/internal/processing/status/edit.go
+++ b/internal/processing/status/edit.go
@@ -110,6 +110,7 @@ func (p *Processor) Edit(
requester.ID,
statusID,
form.MediaIDs,
+ nil,
)
if errWithCode != nil {
return nil, errWithCode
diff --git a/internal/processing/status/scheduledstatus.go b/internal/processing/status/scheduledstatus.go
new file mode 100644
index 000000000..d0ec6898c
--- /dev/null
+++ b/internal/processing/status/scheduledstatus.go
@@ -0,0 +1,357 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+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 .
+
+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
+}
diff --git a/test/envparsing.sh b/test/envparsing.sh
index 66baee482..d61a1a728 100755
--- a/test/envparsing.sh
+++ b/test/envparsing.sh
@@ -68,6 +68,7 @@ EXPECT=$(cat << "EOF"
"cache-poll-vote-ids-mem-ratio": 2,
"cache-poll-vote-mem-ratio": 2,
"cache-report-mem-ratio": 1,
+ "cache-scheduled-status-mem-ratio": 4,
"cache-sin-bin-status-mem-ratio": 0.5,
"cache-status-bookmark-ids-mem-ratio": 2,
"cache-status-bookmark-mem-ratio": 0.5,
@@ -177,6 +178,8 @@ EXPECT=$(cat << "EOF"
"protocol": "http",
"remote-only": false,
"request-id-header": "X-Trace-Id",
+ "scheduled-statuses-max-daily": 25,
+ "scheduled-statuses-max-total": 300,
"skip-db-setup": false,
"skip-db-teardown": false,
"smtp-disclose-recipients": true,
diff --git a/testrig/config.go b/testrig/config.go
index 93d7fb554..991382bd2 100644
--- a/testrig/config.go
+++ b/testrig/config.go
@@ -140,6 +140,9 @@ func testDefaults() config.Configuration {
StatusesPollOptionMaxChars: 50,
StatusesMediaMaxFiles: 6,
+ ScheduledStatusesMaxTotal: 300,
+ ScheduledStatusesMaxDaily: 25,
+
LetsEncryptEnabled: false,
LetsEncryptPort: 0,
LetsEncryptCertDir: "",
diff --git a/testrig/db.go b/testrig/db.go
index dd65e0804..1b1f6549a 100644
--- a/testrig/db.go
+++ b/testrig/db.go
@@ -358,6 +358,12 @@ func StandardDBSetup(db db.DB, accounts map[string]*gtsmodel.Account) {
}
}
+ for _, v := range NewTestScheduledStatuses() {
+ if err := db.Put(ctx, v); err != nil {
+ log.Panic(ctx, err)
+ }
+ }
+
if err := db.CreateInstanceAccount(ctx); err != nil {
log.Panic(ctx, err)
}
diff --git a/testrig/testmodels.go b/testrig/testmodels.go
index 9bbebcc4e..b7d527010 100644
--- a/testrig/testmodels.go
+++ b/testrig/testmodels.go
@@ -4331,6 +4331,21 @@ func NewTestStatusEdits() map[string]*gtsmodel.StatusEdit {
}
}
+func NewTestScheduledStatuses() map[string]*gtsmodel.ScheduledStatus {
+ return map[string]*gtsmodel.ScheduledStatus{
+ "scheduled_status_1": {
+ ID: "01JZ399E8JF23TS0NEVY6J91KP",
+ AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF", // local account 1,
+ ScheduledAt: TimeMustParse("2080-07-01T21:37:00+02:00"),
+ Text: ":neopapaj_woozy:",
+ Visibility: gtsmodel.VisibilityPublic,
+ Sensitive: util.Ptr(false),
+ Language: "pl",
+ ApplicationID: "01F8MGY43H3N2C8EWPR2FPYEXG",
+ },
+ }
+}
+
// GetSignatureForActivity prepares a mock HTTP request as if it were going to deliver activity to destination signed for privkey and pubKeyID, signs the request and returns the header values.
func GetSignatureForActivity(activity pub.Activity, pubKeyID string, privkey *rsa.PrivateKey, destination *url.URL) (signatureHeader string, digestHeader string, dateHeader string) {
// convert the activity into json bytes