[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

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