[feature] scheduled statuses (#4274)

An implementation of [`scheduled_statuses`](https://docs.joinmastodon.org/methods/scheduled_statuses/). Will fix #1006.

this is heavily WIP and I need to reorganize some of the code, working on this made me somehow familiar with the codebase and led to my other recent contributions
i told some fops on fedi i'd work on this so i have no choice but to complete it 🤷‍♀️
btw iirc my avatar presents me working on this branch

Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
Reviewed-on: https://codeberg.org/superseriousbusiness/gotosocial/pulls/4274
Co-authored-by: nicole mikołajczyk <git@mkljczk.pl>
Co-committed-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
nicole mikołajczyk 2025-08-12 14:05:15 +02:00 committed by kim
commit 660cf2c94c
46 changed files with 2354 additions and 68 deletions

View file

@ -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)

View file

@ -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":

View file

@ -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.

View file

@ -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
```

View file

@ -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 #####
##############################

View file

@ -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),

View file

@ -0,0 +1,52 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package scheduledstatuses
import (
"net/http"
"code.superseriousbusiness.org/gotosocial/internal/processing"
"github.com/gin-gonic/gin"
)
const (
// IDKey is for status UUIDs
IDKey = "id"
// BasePath is the base path for serving the scheduled statuses API, minus the 'api' prefix
BasePath = "/v1/scheduled_statuses"
// BasePathWithID is just the base path with the ID key in it.
// Use this anywhere you need to know the ID of the scheduled status being queried.
BasePathWithID = BasePath + "/:" + IDKey
)
type Module struct {
processor *processing.Processor
}
func New(processor *processing.Processor) *Module {
return &Module{
processor: processor,
}
}
func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) {
attachHandler(http.MethodGet, BasePath, m.ScheduledStatusesGETHandler)
attachHandler(http.MethodGet, BasePathWithID, m.ScheduledStatusGETHandler)
attachHandler(http.MethodPut, BasePathWithID, m.ScheduledStatusPUTHandler)
attachHandler(http.MethodDelete, BasePathWithID, m.ScheduledStatusDELETEHandler)
}

View file

@ -0,0 +1,97 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package scheduledstatuses
import (
"net/http"
apiutil "code.superseriousbusiness.org/gotosocial/internal/api/util"
"code.superseriousbusiness.org/gotosocial/internal/gtserror"
"github.com/gin-gonic/gin"
)
// ScheduledStatusDELETEHandler swagger:operation DELETE /api/v1/scheduled_statuses/{id} deleteScheduledStatus
//
// Cancel a scheduled status with the given id.
//
// ---
// tags:
// - scheduled_statuses
//
// produces:
// - application/json
//
// parameters:
// -
// name: id
// type: string
// description: ID of the status
// in: path
// required: true
//
// security:
// - OAuth2 Bearer:
// - write:statuses
//
// responses:
// '200':
// description: status canceled
// '400':
// description: bad request
// '401':
// description: unauthorized
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) ScheduledStatusDELETEHandler(c *gin.Context) {
authed, errWithCode := apiutil.TokenAuth(c,
true, true, true, true,
apiutil.ScopeWriteStatuses,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
targetScheduledStatusID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
errWithCode = m.processor.Status().ScheduledStatusesDelete(
c.Request.Context(),
authed.Account,
targetScheduledStatusID,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
c.JSON(http.StatusOK, apiutil.EmptyJSONObject)
}

View file

@ -0,0 +1,136 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package scheduledstatuses
import (
"net/http"
apiutil "code.superseriousbusiness.org/gotosocial/internal/api/util"
"code.superseriousbusiness.org/gotosocial/internal/gtserror"
"code.superseriousbusiness.org/gotosocial/internal/paging"
"github.com/gin-gonic/gin"
)
// ScheduledStatusesGETHandler swagger:operation GET /api/v1/scheduled_statuses getScheduledStatuses
//
// Get an array of statuses scheduled by authorized user.
//
// ---
// tags:
// - scheduled_statuses
//
// produces:
// - application/json
//
// parameters:
// -
// name: max_id
// type: string
// description: >-
// Return only statuses *OLDER* than the given max status ID.
// The status with the specified ID will not be included in the response.
// in: query
// required: false
// -
// name: since_id
// type: string
// description: >-
// Return only statuses *newer* than the given since status ID.
// The status with the specified ID will not be included in the response.
// in: query
// -
// name: min_id
// type: string
// description: >-
// Return only statuses *immediately newer* than the given min ID.
// The status with the specified ID will not be included in the response.
// in: query
// required: false
// -
// name: limit
// type: integer
// description: Number of scheduled statuses to return.
// default: 20
// in: query
// required: false
//
// security:
// - OAuth2 Bearer:
// - read:statuses
//
// responses:
// '200':
// headers:
// Link:
// type: string
// description: Links to the next and previous queries.
// schema:
// type: array
// items:
// "$ref": "#/definitions/scheduledStatus"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) ScheduledStatusesGETHandler(c *gin.Context) {
authed, errWithCode := apiutil.TokenAuth(c,
true, true, true, true,
apiutil.ScopeReadStatuses,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
page, errWithCode := paging.ParseIDPage(c,
1, // min limit
80, // max limit
20, // default limit
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
resp, errWithCode := m.processor.Status().ScheduledStatusesGetPage(
c.Request.Context(),
authed.Account,
page,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
if resp.LinkHeader != "" {
c.Header("Link", resp.LinkHeader)
}
apiutil.JSON(c, http.StatusOK, resp.Items)
}

View file

@ -0,0 +1,98 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package scheduledstatuses
import (
"net/http"
apiutil "code.superseriousbusiness.org/gotosocial/internal/api/util"
"code.superseriousbusiness.org/gotosocial/internal/gtserror"
"github.com/gin-gonic/gin"
)
// ScheduledStatusGETHandler swagger:operation GET /api/v1/scheduled_statuses/{id} getScheduledStatus
//
// Get a scheduled status with the given id.
//
// ---
// tags:
// - scheduled_statuses
//
// produces:
// - application/json
//
// parameters:
// -
// name: id
// type: string
// description: ID of the status
// in: path
// required: true
//
// security:
// - OAuth2 Bearer:
// - read:statuses
//
// responses:
// '200':
// schema:
// "$ref": "#/definitions/scheduledStatus"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) ScheduledStatusGETHandler(c *gin.Context) {
authed, errWithCode := apiutil.TokenAuth(c,
true, true, true, true,
apiutil.ScopeReadStatuses,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
targetScheduledStatusID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
scheduledStatus, errWithCode := m.processor.Status().ScheduledStatusesGetOne(
c.Request.Context(),
authed.Account,
targetScheduledStatusID,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiutil.JSON(c, http.StatusOK, scheduledStatus)
}

View file

@ -0,0 +1,131 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package scheduledstatuses
import (
"net/http"
"time"
apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model"
apiutil "code.superseriousbusiness.org/gotosocial/internal/api/util"
"code.superseriousbusiness.org/gotosocial/internal/gtserror"
"github.com/gin-gonic/gin"
)
// ScheduledStatusPUTHandler swagger:operation PUT /api/v1/scheduled_statuses/{id} updateScheduledStatus
//
// Update a scheduled status's publishing date
//
// ---
// tags:
// - scheduled_statuses
//
// produces:
// - application/json
//
// parameters:
// -
// name: id
// type: string
// description: ID of the status
// in: path
// required: true
// -
// name: scheduled_at
// x-go-name: ScheduledAt
// description: |-
// ISO 8601 Datetime at which to schedule a status.
//
// Must be at least 5 minutes in the future.
// type: string
// format: date-time
// in: formData
//
// security:
// - OAuth2 Bearer:
// - write:statuses
//
// responses:
// '200':
// schema:
// "$ref": "#/definitions/scheduledStatus"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '404':
// description: not found
// '406':
// description: not acceptable
// '422':
// description: unprocessable content
// '500':
// description: internal server error
func (m *Module) ScheduledStatusPUTHandler(c *gin.Context) {
authed, errWithCode := apiutil.TokenAuth(c,
true, true, true, true,
apiutil.ScopeWriteStatuses,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
targetScheduledStatusID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
form := &apimodel.ScheduledStatusUpdateRequest{}
if err := c.ShouldBind(form); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
now := time.Now()
if !now.Add(5 * time.Minute).Before(*form.ScheduledAt) {
const errText = "scheduled_at must be at least 5 minutes in the future"
apiutil.ErrorHandler(c, gtserror.NewErrorUnprocessableEntity(gtserror.New(errText), errText), m.processor.InstanceGetV1)
return
}
scheduledStatus, errWithCode := m.processor.Status().ScheduledStatusesUpdate(
c.Request.Context(),
authed.Account,
targetScheduledStatusID,
form.ScheduledAt,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiutil.JSON(c, http.StatusOK, scheduledStatus)
}

View file

@ -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

View file

@ -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)
}

View file

@ -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"`
}

View file

@ -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.

View file

@ -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)

37
internal/cache/db.go vendored
View file

@ -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(

View file

@ -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)

View file

@ -554,6 +554,25 @@ func sizeofReport() uintptr {
}))
}
func sizeofScheduledStatus() uintptr {
return uintptr(size.Of(&gtsmodel.ScheduledStatus{
ID: exampleID,
AccountID: exampleID,
ScheduledAt: exampleTime,
Text: exampleText,
Poll: gtsmodel.ScheduledStatusPoll{
Options: []string{exampleTextSmall, exampleTextSmall, exampleTextSmall, exampleTextSmall},
Multiple: util.Ptr(false),
HideTotals: util.Ptr(false),
},
MediaIDs: []string{exampleID, exampleID, exampleID},
Sensitive: util.Ptr(false),
SpoilerText: exampleText,
Visibility: gtsmodel.VisibilityPublic,
Language: "en",
}))
}
func sizeofSinBinStatus() uintptr {
return uintptr(size.Of(&gtsmodel.SinBinStatus{
ID: exampleID,

View file

@ -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.

View file

@ -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"`

View file

@ -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,

View file

@ -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"},
} {

View file

@ -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": [

View file

@ -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 #####
##############################

View file

@ -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,

View file

@ -0,0 +1,67 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package migrations
import (
"context"
gtsmodel "code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
"github.com/uptrace/bun"
)
func init() {
up := func(ctx context.Context, db *bun.DB) error {
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
if _, err := tx.
NewCreateTable().
Model(&gtsmodel.ScheduledStatus{}).
IfNotExists().
Exec(ctx); err != nil {
return err
}
// Add indexes to the scheduled statuses tables.
for index, columns := range map[string][]string{
"scheduled_statuses_account_id_idx": {"account_id"},
"scheduled_statuses_scheduled_at_idx": {"scheduled_at"},
} {
if _, err := tx.
NewCreateIndex().
Table("scheduled_statuses").
Index(index).
Column(columns...).
IfNotExists().
Exec(ctx); err != nil {
return err
}
}
return nil
})
}
down := func(ctx context.Context, db *bun.DB) error {
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
return nil
})
}
if err := Migrations.Register(up, down); err != nil {
panic(err)
}
}

View file

@ -0,0 +1,402 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package bundb
import (
"context"
"errors"
"slices"
"time"
"code.superseriousbusiness.org/gotosocial/internal/db"
"code.superseriousbusiness.org/gotosocial/internal/gtscontext"
"code.superseriousbusiness.org/gotosocial/internal/gtserror"
"code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
"code.superseriousbusiness.org/gotosocial/internal/log"
"code.superseriousbusiness.org/gotosocial/internal/paging"
"code.superseriousbusiness.org/gotosocial/internal/state"
"code.superseriousbusiness.org/gotosocial/internal/util/xslices"
"github.com/uptrace/bun"
)
type scheduledStatusDB struct {
db *bun.DB
state *state.State
}
func (s *scheduledStatusDB) GetAllScheduledStatuses(ctx context.Context) ([]*gtsmodel.ScheduledStatus, error) {
var statusIDs []string
// Select ALL token IDs.
if err := s.db.NewSelect().
Table("scheduled_statuses").
Column("id").
Scan(ctx, &statusIDs); err != nil {
return nil, err
}
return s.GetScheduledStatusesByIDs(ctx, statusIDs)
}
func (s *scheduledStatusDB) GetScheduledStatusByID(ctx context.Context, id string) (*gtsmodel.ScheduledStatus, error) {
return s.getScheduledStatus(
ctx,
"ID",
func(scheduledStatus *gtsmodel.ScheduledStatus) error {
return s.db.
NewSelect().
Model(scheduledStatus).
Where("? = ?", bun.Ident("scheduled_status.id"), id).
Scan(ctx)
},
id,
)
}
func (s *scheduledStatusDB) getScheduledStatus(
ctx context.Context,
lookup string,
dbQuery func(*gtsmodel.ScheduledStatus) error,
keyParts ...any,
) (*gtsmodel.ScheduledStatus, error) {
// Fetch scheduled status from database cache with loader callback
scheduledStatus, err := s.state.Caches.DB.ScheduledStatus.LoadOne(lookup, func() (*gtsmodel.ScheduledStatus, error) {
var scheduledStatus gtsmodel.ScheduledStatus
// Not cached! Perform database query
if err := dbQuery(&scheduledStatus); err != nil {
return nil, err
}
return &scheduledStatus, nil
}, keyParts...)
if err != nil {
// Error already processed.
return nil, err
}
if gtscontext.Barebones(ctx) {
// Only a barebones model was requested.
return scheduledStatus, nil
}
if err := s.PopulateScheduledStatus(ctx, scheduledStatus); err != nil {
return nil, err
}
return scheduledStatus, nil
}
func (s *scheduledStatusDB) PopulateScheduledStatus(ctx context.Context, status *gtsmodel.ScheduledStatus) error {
var (
err error
errs = gtserror.NewMultiError(1)
)
if status.Account == nil {
status.Account, err = s.state.DB.GetAccountByID(
gtscontext.SetBarebones(ctx),
status.AccountID,
)
if err != nil {
errs.Appendf("error populating scheduled status author account: %w", err)
}
}
if status.Application == nil {
status.Application, err = s.state.DB.GetApplicationByID(
gtscontext.SetBarebones(ctx),
status.ApplicationID,
)
if err != nil {
errs.Appendf("error populating scheduled status application: %w", err)
}
}
if !status.AttachmentsPopulated() {
// Status attachments are out-of-date with IDs, repopulate.
status.MediaAttachments, err = s.state.DB.GetAttachmentsByIDs(
gtscontext.SetBarebones(ctx),
status.MediaIDs,
)
if err != nil {
errs.Appendf("error populating status attachments: %w", err)
}
}
return errs.Combine()
}
func (s *scheduledStatusDB) GetScheduledStatusesByIDs(ctx context.Context, ids []string) ([]*gtsmodel.ScheduledStatus, error) {
// Load all scheduled status IDs via cache loader callbacks.
statuses, err := s.state.Caches.DB.ScheduledStatus.LoadIDs("ID",
ids,
func(uncached []string) ([]*gtsmodel.ScheduledStatus, error) {
// Preallocate expected length of uncached scheduled statuses.
statuses := make([]*gtsmodel.ScheduledStatus, 0, len(uncached))
// Perform database query scanning
// the remaining (uncached) IDs.
if err := s.db.NewSelect().
Model(&statuses).
Where("? IN (?)", bun.Ident("id"), bun.In(uncached)).
Scan(ctx); err != nil {
return nil, err
}
return statuses, nil
},
)
if err != nil {
return nil, err
}
// Reorder the statuses by their
// IDs to ensure in correct order.
getID := func(r *gtsmodel.ScheduledStatus) string { return r.ID }
xslices.OrderBy(statuses, ids, getID)
if gtscontext.Barebones(ctx) {
// no need to fully populate.
return statuses, nil
}
// Populate all loaded scheduled statuses, removing those we
// fail to populate (removes needing so many nil checks everywhere).
statuses = slices.DeleteFunc(statuses, func(scheduledStatus *gtsmodel.ScheduledStatus) bool {
if err := s.PopulateScheduledStatus(ctx, scheduledStatus); err != nil {
log.Errorf(ctx, "error populating %s: %v", scheduledStatus.ID, err)
return true
}
return false
})
return statuses, nil
}
func (s *scheduledStatusDB) GetScheduledStatusesForAcct(
ctx context.Context,
acctID string,
page *paging.Page,
) ([]*gtsmodel.ScheduledStatus, error) {
var (
// Get paging params.
minID = page.GetMin()
maxID = page.GetMax()
limit = page.GetLimit()
order = page.GetOrder()
// Make educated guess for slice size
statusIDs = make([]string, 0, limit)
)
// Create the basic select query.
q := s.db.
NewSelect().
Column("id").
TableExpr(
"? AS ?",
bun.Ident("scheduled_statuses"),
bun.Ident("scheduled_status"),
)
// Select scheduled statuses by the account.
if acctID != "" {
q = q.Where("? = ?", bun.Ident("account_id"), acctID)
}
// Add paging param max ID.
if maxID != "" {
q = q.Where("? < ?", bun.Ident("id"), maxID)
}
// Add paging param min ID.
if minID != "" {
q = q.Where("? > ?", bun.Ident("id"), minID)
}
// Add paging param order.
if order == paging.OrderAscending {
// Page up.
q = q.OrderExpr("? ASC", bun.Ident("id"))
} else {
// Page down.
q = q.OrderExpr("? DESC", bun.Ident("id"))
}
// Add paging param limit.
if limit > 0 {
q = q.Limit(limit)
}
// Execute the query and scan into IDs.
err := q.Scan(ctx, &statusIDs)
if err != nil {
return nil, err
}
// Catch case of no items early
if len(statusIDs) == 0 {
return nil, db.ErrNoEntries
}
// If we're paging up, we still want statuses
// to be sorted by ID desc, so reverse ids slice.
if order == paging.OrderAscending {
slices.Reverse(statusIDs)
}
// Load all scheduled statuses by their IDs.
return s.GetScheduledStatusesByIDs(ctx, statusIDs)
}
func (s *scheduledStatusDB) PutScheduledStatus(ctx context.Context, status *gtsmodel.ScheduledStatus) error {
return s.state.Caches.DB.ScheduledStatus.Store(status, func() error {
return s.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
if _, err := tx.NewInsert().
Model(status).
Exec(ctx); err != nil {
return gtserror.Newf("error selecting boosted status: %w", err)
}
// change the scheduled status ID of the
// media attachments to the current status
for _, a := range status.MediaAttachments {
a.ScheduledStatusID = status.ID
if _, err := tx.
NewUpdate().
Model(a).
Column("scheduled_status_id").
Where("? = ?", bun.Ident("media_attachment.id"), a.ID).
Exec(ctx); err != nil {
return gtserror.Newf("error updating media: %w", err)
}
}
return nil
})
})
}
func (s *scheduledStatusDB) DeleteScheduledStatusByID(ctx context.Context, id string) error {
var deleted gtsmodel.ScheduledStatus
// Delete scheduled status
// from database by its ID.
if _, err := s.db.NewDelete().
Model(&deleted).
Returning("?, ?", bun.Ident("id"), bun.Ident("attachments")).
Where("? = ?", bun.Ident("scheduled_status.id"), id).
Exec(ctx); err != nil {
return err
}
// Invalidate cached scheduled status by its ID,
// manually call invalidate hook in case not cached.
s.state.Caches.DB.ScheduledStatus.Invalidate("ID", id)
s.state.Caches.OnInvalidateScheduledStatus(&deleted)
return nil
}
func (s *scheduledStatusDB) DeleteScheduledStatusesByAccountID(ctx context.Context, accountID string) error {
// Gather necessary fields from
// deleted for cache invaliation.
var deleted []*gtsmodel.ScheduledStatus
if _, err := s.db.NewDelete().
Model(&deleted).
Returning("?, ?", bun.Ident("id"), bun.Ident("attachments")).
Where("? = ?", bun.Ident("account_id"), accountID).
Exec(ctx); err != nil &&
!errors.Is(err, db.ErrNoEntries) {
return err
}
for _, deleted := range deleted {
// Invalidate cached scheduled statuses by ID
// and related media attachments.
s.state.Caches.DB.ScheduledStatus.Invalidate("ID", deleted.ID)
s.state.Caches.OnInvalidateScheduledStatus(deleted)
}
return nil
}
func (s *scheduledStatusDB) DeleteScheduledStatusesByApplicationID(ctx context.Context, applicationID string) error {
// Gather necessary fields from
// deleted for cache invaliation.
var deleted []*gtsmodel.ScheduledStatus
if _, err := s.db.NewDelete().
Model(&deleted).
Returning("?, ?", bun.Ident("id"), bun.Ident("attachments")).
Where("? = ?", bun.Ident("application_id"), applicationID).
Exec(ctx); err != nil &&
!errors.Is(err, db.ErrNoEntries) {
return err
}
for _, deleted := range deleted {
// Invalidate cached scheduled statuses by ID
// and related media attachments.
s.state.Caches.DB.ScheduledStatus.Invalidate("ID", deleted.ID)
s.state.Caches.OnInvalidateScheduledStatus(deleted)
}
return nil
}
func (s *scheduledStatusDB) UpdateScheduledStatusScheduledDate(ctx context.Context, scheduledStatus *gtsmodel.ScheduledStatus, scheduledAt *time.Time) error {
return s.state.Caches.DB.ScheduledStatus.Store(scheduledStatus, func() error {
_, err := s.db.NewUpdate().
Model(scheduledStatus).
Where("? = ?", bun.Ident("scheduled_status.id"), scheduledStatus.ID).
Column("scheduled_at").
Exec(ctx)
return err
})
}
func (s *scheduledStatusDB) GetScheduledStatusesCountForAcct(ctx context.Context, acctID string, scheduledAt *time.Time) (int, error) {
q := s.db.
NewSelect().
Column("id").
TableExpr(
"? AS ?",
bun.Ident("scheduled_statuses"),
bun.Ident("scheduled_status"),
).
Where("? = ?", bun.Ident("account_id"), acctID)
if scheduledAt != nil {
startOfDay := time.Date(scheduledAt.Year(), scheduledAt.Month(), scheduledAt.Day(), 0, 0, 0, 0, scheduledAt.Location())
endOfDay := startOfDay.Add(24 * time.Hour)
q = q.
Where("? >= ? AND ? < ?", bun.Ident("scheduled_at"), startOfDay, bun.Ident("scheduled_at"), endOfDay)
}
count, err := q.Count(ctx)
if err != nil {
return 0, err
}
return count, nil
}

View file

@ -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)

View file

@ -46,6 +46,7 @@ type DB interface {
Relationship
Report
Rule
ScheduledStatus
Search
Session
SinBinStatus

View file

@ -0,0 +1,59 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package db
import (
"context"
"time"
"code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
"code.superseriousbusiness.org/gotosocial/internal/paging"
)
type ScheduledStatus interface {
// GetAllScheduledStatuses returns all pending scheduled statuses.
GetAllScheduledStatuses(ctx context.Context) ([]*gtsmodel.ScheduledStatus, error)
// GetScheduledStatusByID gets one scheduled status with the given id.
GetScheduledStatusByID(ctx context.Context, id string) (*gtsmodel.ScheduledStatus, error)
// GetScheduledStatusesForAcct returns statuses scheduled by the given account.
GetScheduledStatusesForAcct(
ctx context.Context,
acctID string,
page *paging.Page,
) ([]*gtsmodel.ScheduledStatus, error)
// PutScheduledStatus puts the given scheduled status in the database.
PutScheduledStatus(ctx context.Context, status *gtsmodel.ScheduledStatus) error
// DeleteScheduledStatusByID deletes one scheduled status from the database.
DeleteScheduledStatusByID(ctx context.Context, id string) error
// DeleteScheduledStatusByID deletes all scheduled statuses from an account from the database.
DeleteScheduledStatusesByAccountID(ctx context.Context, accountID string) error
// DeleteScheduledStatusesByApplicationID deletes all scheduled statuses posted from the given application from the database.
DeleteScheduledStatusesByApplicationID(ctx context.Context, applicationID string) error
// UpdateScheduledStatusScheduledDate updates `scheduled_at` param for the given scheduled status in the database.
UpdateScheduledStatusScheduledDate(ctx context.Context, scheduledStatus *gtsmodel.ScheduledStatus, scheduledAt *time.Time) error
// GetScheduledStatusesCountForAcct returns the number of pending statuses scheduled by the given account, optionally for a specific day.
GetScheduledStatusesCountForAcct(ctx context.Context, acctID string, scheduledAt *time.Time) (int, error)
}

View file

@ -0,0 +1,65 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package gtsmodel
import "time"
// ScheduledStatus represents a status that is scheduled to be published at given time by a local user.
type ScheduledStatus struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
AccountID string `bun:"type:CHAR(26),nullzero,notnull"` // which account scheduled this status
Account *Account `bun:"-"` // Account corresponding to AccountID
ScheduledAt time.Time `bun:"type:timestamptz,nullzero,notnull"` // time at which the status is scheduled
Text string `bun:""` // Text content of the status
Poll ScheduledStatusPoll `bun:",embed:poll_,notnull,nullzero"` //
MediaIDs []string `bun:"attachments,array"` // Database IDs of any media attachments associated with this status
MediaAttachments []*MediaAttachment `bun:"-"` // Attachments corresponding to media IDs
Sensitive *bool `bun:",nullzero,notnull,default:false"` // mark the status as sensitive?
SpoilerText string `bun:""` // Original text of the content warning without formatting
Visibility Visibility `bun:",nullzero,notnull"` // visibility entry for this status
InReplyToID string `bun:"type:CHAR(26),nullzero"` // id of the status this status replies to
Language string `bun:",nullzero"` // what language is this status written in?
ApplicationID string `bun:"type:CHAR(26),nullzero"` // Which application was used to create this status?
Application *Application `bun:"-"` //
LocalOnly *bool `bun:",nullzero,notnull,default:false"` // Whether the status is not federated
ContentType string `bun:",nullzero"` // Content type used to process the original text of the status
InteractionPolicy *InteractionPolicy `bun:""` // InteractionPolicy for this status. If null then the default InteractionPolicy should be assumed for this status's Visibility. Always null for boost wrappers.
Idempotency string `bun:",nullzero"` // Currently unused
}
type ScheduledStatusPoll struct {
Options []string `bun:",nullzero,array"` // The available options for this poll.
ExpiresIn int `bun:",nullzero"` // Duration the poll should be open, in seconds
Multiple *bool `bun:",nullzero,notnull,default:false"` // Is this a multiple choice poll? i.e. can you vote on multiple options.
HideTotals *bool `bun:",nullzero,notnull,default:false"` // Hides vote counts until poll ends.
}
// AttachmentsPopulated returns whether media attachments
// are populated according to current AttachmentIDs.
func (s *ScheduledStatus) AttachmentsPopulated() bool {
if len(s.MediaIDs) != len(s.MediaAttachments) {
// this is the quickest indicator.
return false
}
for i, id := range s.MediaIDs {
if s.MediaAttachments[i].ID != id {
return false
}
}
return true
}

View file

@ -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.

View file

@ -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
}

View file

@ -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)
}

View file

@ -44,10 +44,8 @@ func (p *Processor) Create(
requester *gtsmodel.Account,
application *gtsmodel.Application,
form *apimodel.StatusCreateRequest,
) (
*apimodel.Status,
gtserror.WithCode,
) {
scheduledStatusID *string,
) (any, gtserror.WithCode) {
// Validate incoming form status content.
if errWithCode := validateStatusContent(
form.Status,
@ -83,16 +81,6 @@ func (p *Processor) Create(
return nil, errWithCode
}
// Process incoming status attachments.
media, errWithCode := p.processMedia(ctx,
requester.ID,
statusID,
form.MediaIDs,
)
if errWithCode != nil {
return nil, errWithCode
}
// Generate necessary URIs for username, to build status URIs.
accountURIs := uris.GenerateURIsForAccount(requester.Username)
@ -105,16 +93,27 @@ func (p *Processor) Create(
// Handle backfilled/scheduled statuses.
backfill := false
if form.ScheduledAt != nil {
scheduledAt := *form.ScheduledAt
// Statuses may only be scheduled
// a minimum time into the future.
if now.Before(scheduledAt) {
const errText = "scheduled statuses are not yet supported"
return nil, gtserror.NewErrorNotImplemented(gtserror.New(errText), errText)
switch {
case form.ScheduledAt == nil:
// No scheduling/backfilling
break
case form.ScheduledAt.Sub(now) >= 5*time.Minute:
// Statuses may only be scheduled a minimum time into the future.
scheduledStatus, errWithCode := p.processScheduledStatus(ctx, statusID, form, requester, application)
if errWithCode != nil {
return nil, errWithCode
}
return scheduledStatus, nil
case now.Before(*form.ScheduledAt):
// Invalid future scheduled status
const errText = "scheduled_at must be at least 5 minutes in the future"
return nil, gtserror.NewErrorUnprocessableEntity(gtserror.New(errText), errText)
default:
// If not scheduled into the future, this status is being backfilled.
if !config.GetInstanceAllowBackdatingStatuses() {
const errText = "backdating statuses has been disabled on this instance"
@ -127,7 +126,7 @@ func (p *Processor) Create(
// this would also cause issues with time.Time.IsZero() checks
// that normally signify an absent optional time,
// but this check covers both cases.
if scheduledAt.Compare(time.UnixMilli(0)) <= 0 {
if form.ScheduledAt.Compare(time.UnixMilli(0)) <= 0 {
const errText = "statuses can't be backdated to or before the UNIX epoch"
return nil, gtserror.NewErrorNotAcceptable(gtserror.New(errText), errText)
}
@ -138,7 +137,7 @@ func (p *Processor) Create(
backfill = true
// Update to backfill date.
createdAt = scheduledAt
createdAt = *form.ScheduledAt
// Generate an appropriate, (and unique!), ID for the creation time.
if statusID, err = p.backfilledStatusID(ctx, createdAt); err != nil {
@ -146,6 +145,17 @@ func (p *Processor) Create(
}
}
// Process incoming status attachments.
media, errWithCode := p.processMedia(ctx,
requester.ID,
statusID,
form.MediaIDs,
scheduledStatusID,
)
if errWithCode != nil {
return nil, errWithCode
}
status := &gtsmodel.Status{
ID: statusID,
URI: accountURIs.StatusesURI + "/" + statusID,
@ -546,3 +556,103 @@ func processInteractionPolicy(
// setting it explicitly to save space.
return nil
}
func (p *Processor) processScheduledStatus(
ctx context.Context,
statusID string,
form *apimodel.StatusCreateRequest,
requester *gtsmodel.Account,
application *gtsmodel.Application,
) (*apimodel.ScheduledStatus, gtserror.WithCode) {
// Validate scheduled status against server configuration
// (max scheduled statuses limit).
if errWithCode := p.validateScheduledStatusLimits(ctx, requester.ID, form.ScheduledAt, nil); errWithCode != nil {
return nil, errWithCode
}
media, errWithCode := p.processMedia(ctx,
requester.ID,
statusID,
form.MediaIDs,
nil,
)
if errWithCode != nil {
return nil, errWithCode
}
status := &gtsmodel.ScheduledStatus{
ID: statusID,
Account: requester,
AccountID: requester.ID,
Application: application,
ApplicationID: application.ID,
ScheduledAt: *form.ScheduledAt,
Text: form.Status,
MediaIDs: form.MediaIDs,
MediaAttachments: media,
Sensitive: &form.Sensitive,
SpoilerText: form.SpoilerText,
InReplyToID: form.InReplyToID,
Language: form.Language,
LocalOnly: form.LocalOnly,
ContentType: string(form.ContentType),
}
if form.Poll != nil {
status.Poll = gtsmodel.ScheduledStatusPoll{
Options: form.Poll.Options,
ExpiresIn: form.Poll.ExpiresIn,
Multiple: &form.Poll.Multiple,
HideTotals: &form.Poll.HideTotals,
}
}
accountDefaultVisibility := requester.Settings.Privacy
switch {
case form.Visibility != "":
status.Visibility = typeutils.APIVisToVis(form.Visibility)
case accountDefaultVisibility != 0:
status.Visibility = accountDefaultVisibility
form.Visibility = typeutils.VisToAPIVis(accountDefaultVisibility)
default:
status.Visibility = gtsmodel.VisibilityDefault
form.Visibility = typeutils.VisToAPIVis(gtsmodel.VisibilityDefault)
}
if form.InteractionPolicy != nil {
interactionPolicy, err := typeutils.APIInteractionPolicyToInteractionPolicy(form.InteractionPolicy, form.Visibility)
if err != nil {
err := gtserror.Newf("error converting interaction policy: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
status.InteractionPolicy = interactionPolicy
}
// Insert this newly prepared status into the database.
if err := p.state.DB.PutScheduledStatus(ctx, status); err != nil {
err := gtserror.Newf("error inserting status in db: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
// Schedule the newly inserted status for publishing.
if err := p.ScheduledStatusesSchedulePublication(ctx, status.ID); err != nil {
err := gtserror.Newf("error scheduling status publish: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
apiScheduledStatus, err := p.converter.ScheduledStatusToAPIScheduledStatus(
ctx,
status,
)
if err != nil {
err := gtserror.Newf("error converting: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
return apiScheduledStatus, nil
}

View file

@ -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())

View file

@ -110,6 +110,7 @@ func (p *Processor) Edit(
requester.ID,
statusID,
form.MediaIDs,
nil,
)
if errWithCode != nil {
return nil, errWithCode

View file

@ -0,0 +1,357 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package status
import (
"context"
"errors"
"time"
apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model"
"code.superseriousbusiness.org/gotosocial/internal/config"
"code.superseriousbusiness.org/gotosocial/internal/db"
"code.superseriousbusiness.org/gotosocial/internal/gtscontext"
"code.superseriousbusiness.org/gotosocial/internal/gtserror"
"code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
"code.superseriousbusiness.org/gotosocial/internal/log"
"code.superseriousbusiness.org/gotosocial/internal/paging"
"code.superseriousbusiness.org/gotosocial/internal/typeutils"
)
// ScheduledStatusesGetPage returns a page of scheduled statuses authored
// by the requester.
func (p *Processor) ScheduledStatusesGetPage(
ctx context.Context,
requester *gtsmodel.Account,
page *paging.Page,
) (*apimodel.PageableResponse, gtserror.WithCode) {
scheduledStatuses, err := p.state.DB.GetScheduledStatusesForAcct(
ctx,
requester.ID,
page,
)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("db error getting scheduled statuses: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
count := len(scheduledStatuses)
if count == 0 {
return paging.EmptyResponse(), nil
}
var (
// Get the lowest and highest
// ID values, used for paging.
lo = scheduledStatuses[count-1].ID
hi = scheduledStatuses[0].ID
// Best-guess items length.
items = make([]interface{}, 0, count)
)
for _, scheduledStatus := range scheduledStatuses {
apiScheduledStatus, err := p.converter.ScheduledStatusToAPIScheduledStatus(
ctx, scheduledStatus,
)
if err != nil {
log.Errorf(ctx, "error converting scheduled status to api scheduled status: %v", err)
continue
}
// Append scheduledStatus to return items.
items = append(items, apiScheduledStatus)
}
return paging.PackageResponse(paging.ResponseParams{
Items: items,
Path: "/api/v1/scheduled_statuses",
Next: page.Next(lo, hi),
Prev: page.Prev(lo, hi),
}), nil
}
// ScheduledStatusesGetOne returns one scheduled
// status with the given ID.
func (p *Processor) ScheduledStatusesGetOne(
ctx context.Context,
requester *gtsmodel.Account,
id string,
) (*apimodel.ScheduledStatus, gtserror.WithCode) {
scheduledStatus, err := p.state.DB.GetScheduledStatusByID(ctx, id)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("db error getting scheduled status: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
if scheduledStatus == nil {
err := gtserror.New("scheduled status not found")
return nil, gtserror.NewErrorNotFound(err)
}
if scheduledStatus.AccountID != requester.ID {
err := gtserror.Newf(
"scheduled status %s is not authored by account %s",
scheduledStatus.ID, requester.ID,
)
return nil, gtserror.NewErrorNotFound(err)
}
apiScheduledStatus, err := p.converter.ScheduledStatusToAPIScheduledStatus(
ctx, scheduledStatus,
)
if err != nil {
err := gtserror.Newf("error converting scheduled status to api scheduled status: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
return apiScheduledStatus, nil
}
func (p *Processor) ScheduledStatusesScheduleAll(ctx context.Context) error {
// Fetch all pending statuses from the database (barebones models are enough).
statuses, err := p.state.DB.GetAllScheduledStatuses(gtscontext.SetBarebones(ctx))
if err != nil {
return gtserror.Newf("error getting scheduled statuses from db: %w", err)
}
var errs gtserror.MultiError
for _, status := range statuses {
// Schedule publication of each of the statuses and catch any errors.
if err := p.ScheduledStatusesSchedulePublication(ctx, status.ID); err != nil {
errs.Append(err)
}
}
return errs.Combine()
}
func (p *Processor) ScheduledStatusesSchedulePublication(ctx context.Context, statusID string) gtserror.WithCode {
status, err := p.state.DB.GetScheduledStatusByID(ctx, statusID)
if err != nil {
return gtserror.NewErrorNotFound(gtserror.Newf("failed to get scheduled status %s", statusID))
}
// Add the given status to the scheduler.
ok := p.state.Workers.Scheduler.AddOnce(
status.ID,
status.ScheduledAt,
p.onPublish(status.ID),
)
if !ok {
// Failed to add the status to the scheduler, either it was
// starting / stopping or there already exists a task for status.
return gtserror.NewErrorInternalError(gtserror.Newf("failed adding status %s to scheduler", status.ID))
}
atStr := status.ScheduledAt.Local().Format("Jan _2 2006 15:04:05")
log.Infof(ctx, "scheduled status publication for %s at '%s'", status.ID, atStr)
return nil
}
// onPublish returns a callback function to be used by the scheduler on the scheduled date.
func (p *Processor) onPublish(statusID string) func(context.Context, time.Time) {
return func(ctx context.Context, now time.Time) {
// Get the latest version of status from database.
status, err := p.state.DB.GetScheduledStatusByID(ctx, statusID)
if err != nil {
log.Errorf(ctx, "error getting status %s from db: %v", statusID, err)
return
}
request := &apimodel.StatusCreateRequest{
Status: status.Text,
MediaIDs: status.MediaIDs,
Poll: nil,
InReplyToID: status.InReplyToID,
Sensitive: *status.Sensitive,
SpoilerText: status.SpoilerText,
Visibility: typeutils.VisToAPIVis(status.Visibility),
Language: status.Language,
}
if status.Poll.Options != nil && len(status.Poll.Options) > 1 {
request.Poll = &apimodel.PollRequest{
Options: status.Poll.Options,
ExpiresIn: status.Poll.ExpiresIn,
Multiple: *status.Poll.Multiple,
HideTotals: *status.Poll.HideTotals,
}
}
_, errWithCode := p.Create(ctx, status.Account, status.Application, request, &statusID)
if errWithCode != nil {
log.Errorf(ctx, "could not publish scheduled status: %v", errWithCode.Unwrap())
return
}
err = p.state.DB.DeleteScheduledStatusByID(ctx, statusID)
if err != nil {
log.Error(ctx, err)
}
}
}
// Update scheduled status schedule data
func (p *Processor) ScheduledStatusesUpdate(
ctx context.Context,
requester *gtsmodel.Account,
id string,
scheduledAt *time.Time,
) (*apimodel.ScheduledStatus, gtserror.WithCode) {
scheduledStatus, err := p.state.DB.GetScheduledStatusByID(ctx, id)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("db error getting scheduled status: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
if scheduledStatus == nil {
err := gtserror.New("scheduled status not found")
return nil, gtserror.NewErrorNotFound(err)
}
if scheduledStatus.AccountID != requester.ID {
err := gtserror.Newf(
"scheduled status %s is not authored by account %s",
scheduledStatus.ID, requester.ID,
)
return nil, gtserror.NewErrorNotFound(err)
}
if errWithCode := p.validateScheduledStatusLimits(ctx, requester.ID, scheduledAt, &scheduledStatus.ScheduledAt); errWithCode != nil {
return nil, errWithCode
}
scheduledStatus.ScheduledAt = *scheduledAt
err = p.state.DB.UpdateScheduledStatusScheduledDate(ctx, scheduledStatus, scheduledAt)
if err != nil {
err := gtserror.Newf("db error getting scheduled status: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
ok := p.state.Workers.Scheduler.Cancel(id)
if !ok {
err := gtserror.Newf("failed to cancel scheduled status")
return nil, gtserror.NewErrorInternalError(err)
}
err = p.ScheduledStatusesSchedulePublication(ctx, id)
if err != nil {
err := gtserror.Newf("error scheduling status: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
apiScheduledStatus, err := p.converter.ScheduledStatusToAPIScheduledStatus(
ctx, scheduledStatus,
)
if err != nil {
err := gtserror.Newf("error converting scheduled status to api req: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
return apiScheduledStatus, nil
}
// Cancel a scheduled status
func (p *Processor) ScheduledStatusesDelete(ctx context.Context, requester *gtsmodel.Account, id string) gtserror.WithCode {
scheduledStatus, err := p.state.DB.GetScheduledStatusByID(ctx, id)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("db error getting scheduled status: %w", err)
return gtserror.NewErrorInternalError(err)
}
if scheduledStatus == nil {
err := gtserror.New("scheduled status not found")
return gtserror.NewErrorNotFound(err)
}
if scheduledStatus.AccountID != requester.ID {
err := gtserror.Newf(
"scheduled status %s is not authored by account %s",
scheduledStatus.ID, requester.ID,
)
return gtserror.NewErrorNotFound(err)
}
ok := p.state.Workers.Scheduler.Cancel(id)
if !ok {
err := gtserror.Newf("failed to cancel scheduled status")
return gtserror.NewErrorInternalError(err)
}
err = p.state.DB.DeleteScheduledStatusByID(ctx, id)
if err != nil {
err := gtserror.Newf("db error deleting scheduled status: %w", err)
return gtserror.NewErrorInternalError(err)
}
return nil
}
func (p *Processor) validateScheduledStatusLimits(ctx context.Context, acctID string, scheduledAt *time.Time, prevScheduledAt *time.Time) gtserror.WithCode {
// Skip check when the scheduled status already exists and the day stays the same
if prevScheduledAt != nil {
y1, m1, d1 := scheduledAt.Date()
y2, m2, d2 := prevScheduledAt.Date()
if y1 == y2 && m1 == m2 && d1 == d2 {
return nil
}
}
scheduledDaily, err := p.state.DB.GetScheduledStatusesCountForAcct(ctx, acctID, scheduledAt)
if err != nil {
err := gtserror.Newf("error getting scheduled statuses count for day: %w", err)
return gtserror.NewErrorInternalError(err)
}
if max := config.GetScheduledStatusesMaxDaily(); scheduledDaily >= max {
err := gtserror.Newf("scheduled statuses count for day is at the limit (%d)", max)
return gtserror.NewErrorUnprocessableEntity(err)
}
// Skip total check when editing an existing scheduled status
if prevScheduledAt != nil {
return nil
}
scheduledTotal, err := p.state.DB.GetScheduledStatusesCountForAcct(ctx, acctID, nil)
if err != nil {
err := gtserror.Newf("error getting total scheduled statuses count: %w", err)
return gtserror.NewErrorInternalError(err)
}
if max := config.GetScheduledStatusesMaxTotal(); scheduledTotal >= max {
err := gtserror.Newf("total scheduled statuses count is at the limit (%d)", max)
return gtserror.NewErrorUnprocessableEntity(err)
}
return nil
}

View file

@ -0,0 +1,69 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package status_test
import (
"context"
"testing"
"time"
"code.superseriousbusiness.org/gotosocial/internal/util"
"code.superseriousbusiness.org/gotosocial/testrig"
"github.com/stretchr/testify/suite"
)
type ScheduledStatusTestSuite struct {
StatusStandardTestSuite
}
func (suite *ScheduledStatusTestSuite) TestUpdate() {
ctx := suite.T().Context()
account1 := suite.testAccounts["local_account_1"]
scheduledStatus1 := suite.testScheduledStatuses["scheduled_status_1"]
newScheduledAt := testrig.TimeMustParse("2080-07-02T21:37:00+02:00")
suite.state.Workers.Scheduler.AddOnce(scheduledStatus1.ID, scheduledStatus1.ScheduledAt, func(ctx context.Context, t time.Time) {})
// update scheduled status publication date
scheduledStatus2, err := suite.status.ScheduledStatusesUpdate(ctx, account1, scheduledStatus1.ID, util.Ptr(newScheduledAt))
suite.NoError(err)
suite.NotNil(scheduledStatus2)
suite.Equal(scheduledStatus2.ScheduledAt, util.FormatISO8601(newScheduledAt))
// should be rescheduled
suite.Equal(suite.state.Workers.Scheduler.Cancel(scheduledStatus1.ID), true)
}
func (suite *ScheduledStatusTestSuite) TestDelete() {
ctx := suite.T().Context()
account1 := suite.testAccounts["local_account_1"]
scheduledStatus1 := suite.testScheduledStatuses["scheduled_status_1"]
suite.state.Workers.Scheduler.AddOnce(scheduledStatus1.ID, scheduledStatus1.ScheduledAt, func(ctx context.Context, t time.Time) {})
// delete scheduled status
err := suite.status.ScheduledStatusesDelete(ctx, account1, scheduledStatus1.ID)
suite.NoError(err)
// should be already cancelled
suite.Equal(suite.state.Workers.Scheduler.Cancel(scheduledStatus1.ID), false)
}
func TestScheduledStatusTestSuite(t *testing.T) {
suite.Run(t, new(ScheduledStatusTestSuite))
}

View file

@ -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() {

View file

@ -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
}

View file

@ -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,

View file

@ -140,6 +140,9 @@ func testDefaults() config.Configuration {
StatusesPollOptionMaxChars: 50,
StatusesMediaMaxFiles: 6,
ScheduledStatusesMaxTotal: 300,
ScheduledStatusesMaxDaily: 25,
LetsEncryptEnabled: false,
LetsEncryptPort: 0,
LetsEncryptCertDir: "",

View file

@ -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)
}

View file

@ -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