mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2025-10-28 06:32:25 -05:00
[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:
parent
cead741c16
commit
660cf2c94c
46 changed files with 2354 additions and 68 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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 #####
|
||||
##############################
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
52
internal/api/client/scheduledstatuses/scheduledstatus.go
Normal file
52
internal/api/client/scheduledstatuses/scheduledstatus.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
136
internal/api/client/scheduledstatuses/scheduledstatusesget.go
Normal file
136
internal/api/client/scheduledstatuses/scheduledstatusesget.go
Normal 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)
|
||||
}
|
||||
98
internal/api/client/scheduledstatuses/scheduledstatusget.go
Normal file
98
internal/api/client/scheduledstatuses/scheduledstatusget.go
Normal 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)
|
||||
}
|
||||
131
internal/api/client/scheduledstatuses/scheduledstatusput.go
Normal file
131
internal/api/client/scheduledstatuses/scheduledstatusput.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
2
internal/cache/cache.go
vendored
2
internal/cache/cache.go
vendored
|
|
@ -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
37
internal/cache/db.go
vendored
|
|
@ -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(
|
||||
|
|
|
|||
5
internal/cache/invalidate.go
vendored
5
internal/cache/invalidate.go
vendored
|
|
@ -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)
|
||||
|
|
|
|||
19
internal/cache/size.go
vendored
19
internal/cache/size.go
vendored
|
|
@ -554,6 +554,25 @@ func sizeofReport() uintptr {
|
|||
}))
|
||||
}
|
||||
|
||||
func sizeofScheduledStatus() uintptr {
|
||||
return uintptr(size.Of(>smodel.ScheduledStatus{
|
||||
ID: exampleID,
|
||||
AccountID: exampleID,
|
||||
ScheduledAt: exampleTime,
|
||||
Text: exampleText,
|
||||
Poll: gtsmodel.ScheduledStatusPoll{
|
||||
Options: []string{exampleTextSmall, exampleTextSmall, exampleTextSmall, exampleTextSmall},
|
||||
Multiple: util.Ptr(false),
|
||||
HideTotals: util.Ptr(false),
|
||||
},
|
||||
MediaIDs: []string{exampleID, exampleID, exampleID},
|
||||
Sensitive: util.Ptr(false),
|
||||
SpoilerText: exampleText,
|
||||
Visibility: gtsmodel.VisibilityPublic,
|
||||
Language: "en",
|
||||
}))
|
||||
}
|
||||
|
||||
func sizeofSinBinStatus() uintptr {
|
||||
return uintptr(size.Of(>smodel.SinBinStatus{
|
||||
ID: exampleID,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
} {
|
||||
|
|
|
|||
2
internal/config/testdata/test.json
vendored
2
internal/config/testdata/test.json
vendored
|
|
@ -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": [
|
||||
|
|
|
|||
10
internal/config/testdata/test.yaml
vendored
10
internal/config/testdata/test.yaml
vendored
|
|
@ -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 #####
|
||||
##############################
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
gtsmodel "code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
func init() {
|
||||
up := func(ctx context.Context, db *bun.DB) error {
|
||||
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
|
||||
if _, err := tx.
|
||||
NewCreateTable().
|
||||
Model(>smodel.ScheduledStatus{}).
|
||||
IfNotExists().
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add indexes to the scheduled statuses tables.
|
||||
for index, columns := range map[string][]string{
|
||||
"scheduled_statuses_account_id_idx": {"account_id"},
|
||||
"scheduled_statuses_scheduled_at_idx": {"scheduled_at"},
|
||||
} {
|
||||
if _, err := tx.
|
||||
NewCreateIndex().
|
||||
Table("scheduled_statuses").
|
||||
Index(index).
|
||||
Column(columns...).
|
||||
IfNotExists().
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
down := func(ctx context.Context, db *bun.DB) error {
|
||||
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := Migrations.Register(up, down); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
402
internal/db/bundb/scheduledstatus.go
Normal file
402
internal/db/bundb/scheduledstatus.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ type DB interface {
|
|||
Relationship
|
||||
Report
|
||||
Rule
|
||||
ScheduledStatus
|
||||
Search
|
||||
Session
|
||||
SinBinStatus
|
||||
|
|
|
|||
59
internal/db/scheduledstatus.go
Normal file
59
internal/db/scheduledstatus.go
Normal 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)
|
||||
}
|
||||
65
internal/gtsmodel/scheduledstatus.go
Normal file
65
internal/gtsmodel/scheduledstatus.go
Normal 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
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,10 +44,8 @@ func (p *Processor) Create(
|
|||
requester *gtsmodel.Account,
|
||||
application *gtsmodel.Application,
|
||||
form *apimodel.StatusCreateRequest,
|
||||
) (
|
||||
*apimodel.Status,
|
||||
gtserror.WithCode,
|
||||
) {
|
||||
scheduledStatusID *string,
|
||||
) (any, gtserror.WithCode) {
|
||||
// Validate incoming form status content.
|
||||
if errWithCode := validateStatusContent(
|
||||
form.Status,
|
||||
|
|
@ -83,16 +81,6 @@ func (p *Processor) Create(
|
|||
return nil, errWithCode
|
||||
}
|
||||
|
||||
// Process incoming status attachments.
|
||||
media, errWithCode := p.processMedia(ctx,
|
||||
requester.ID,
|
||||
statusID,
|
||||
form.MediaIDs,
|
||||
)
|
||||
if errWithCode != nil {
|
||||
return nil, errWithCode
|
||||
}
|
||||
|
||||
// Generate necessary URIs for username, to build status URIs.
|
||||
accountURIs := uris.GenerateURIsForAccount(requester.Username)
|
||||
|
||||
|
|
@ -105,16 +93,27 @@ func (p *Processor) Create(
|
|||
|
||||
// Handle backfilled/scheduled statuses.
|
||||
backfill := false
|
||||
if form.ScheduledAt != nil {
|
||||
scheduledAt := *form.ScheduledAt
|
||||
|
||||
// Statuses may only be scheduled
|
||||
// a minimum time into the future.
|
||||
if now.Before(scheduledAt) {
|
||||
const errText = "scheduled statuses are not yet supported"
|
||||
return nil, gtserror.NewErrorNotImplemented(gtserror.New(errText), errText)
|
||||
switch {
|
||||
case form.ScheduledAt == nil:
|
||||
// No scheduling/backfilling
|
||||
break
|
||||
case form.ScheduledAt.Sub(now) >= 5*time.Minute:
|
||||
// Statuses may only be scheduled a minimum time into the future.
|
||||
scheduledStatus, errWithCode := p.processScheduledStatus(ctx, statusID, form, requester, application)
|
||||
|
||||
if errWithCode != nil {
|
||||
return nil, errWithCode
|
||||
}
|
||||
|
||||
return scheduledStatus, nil
|
||||
|
||||
case now.Before(*form.ScheduledAt):
|
||||
// Invalid future scheduled status
|
||||
const errText = "scheduled_at must be at least 5 minutes in the future"
|
||||
return nil, gtserror.NewErrorUnprocessableEntity(gtserror.New(errText), errText)
|
||||
|
||||
default:
|
||||
// If not scheduled into the future, this status is being backfilled.
|
||||
if !config.GetInstanceAllowBackdatingStatuses() {
|
||||
const errText = "backdating statuses has been disabled on this instance"
|
||||
|
|
@ -127,7 +126,7 @@ func (p *Processor) Create(
|
|||
// this would also cause issues with time.Time.IsZero() checks
|
||||
// that normally signify an absent optional time,
|
||||
// but this check covers both cases.
|
||||
if scheduledAt.Compare(time.UnixMilli(0)) <= 0 {
|
||||
if form.ScheduledAt.Compare(time.UnixMilli(0)) <= 0 {
|
||||
const errText = "statuses can't be backdated to or before the UNIX epoch"
|
||||
return nil, gtserror.NewErrorNotAcceptable(gtserror.New(errText), errText)
|
||||
}
|
||||
|
|
@ -138,7 +137,7 @@ func (p *Processor) Create(
|
|||
backfill = true
|
||||
|
||||
// Update to backfill date.
|
||||
createdAt = scheduledAt
|
||||
createdAt = *form.ScheduledAt
|
||||
|
||||
// Generate an appropriate, (and unique!), ID for the creation time.
|
||||
if statusID, err = p.backfilledStatusID(ctx, createdAt); err != nil {
|
||||
|
|
@ -146,6 +145,17 @@ func (p *Processor) Create(
|
|||
}
|
||||
}
|
||||
|
||||
// Process incoming status attachments.
|
||||
media, errWithCode := p.processMedia(ctx,
|
||||
requester.ID,
|
||||
statusID,
|
||||
form.MediaIDs,
|
||||
scheduledStatusID,
|
||||
)
|
||||
if errWithCode != nil {
|
||||
return nil, errWithCode
|
||||
}
|
||||
|
||||
status := >smodel.Status{
|
||||
ID: statusID,
|
||||
URI: accountURIs.StatusesURI + "/" + statusID,
|
||||
|
|
@ -546,3 +556,103 @@ func processInteractionPolicy(
|
|||
// setting it explicitly to save space.
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Processor) processScheduledStatus(
|
||||
ctx context.Context,
|
||||
statusID string,
|
||||
form *apimodel.StatusCreateRequest,
|
||||
requester *gtsmodel.Account,
|
||||
application *gtsmodel.Application,
|
||||
) (*apimodel.ScheduledStatus, gtserror.WithCode) {
|
||||
// Validate scheduled status against server configuration
|
||||
// (max scheduled statuses limit).
|
||||
if errWithCode := p.validateScheduledStatusLimits(ctx, requester.ID, form.ScheduledAt, nil); errWithCode != nil {
|
||||
return nil, errWithCode
|
||||
}
|
||||
|
||||
media, errWithCode := p.processMedia(ctx,
|
||||
requester.ID,
|
||||
statusID,
|
||||
form.MediaIDs,
|
||||
nil,
|
||||
)
|
||||
if errWithCode != nil {
|
||||
return nil, errWithCode
|
||||
}
|
||||
status := >smodel.ScheduledStatus{
|
||||
ID: statusID,
|
||||
Account: requester,
|
||||
AccountID: requester.ID,
|
||||
Application: application,
|
||||
ApplicationID: application.ID,
|
||||
ScheduledAt: *form.ScheduledAt,
|
||||
Text: form.Status,
|
||||
MediaIDs: form.MediaIDs,
|
||||
MediaAttachments: media,
|
||||
Sensitive: &form.Sensitive,
|
||||
SpoilerText: form.SpoilerText,
|
||||
InReplyToID: form.InReplyToID,
|
||||
Language: form.Language,
|
||||
LocalOnly: form.LocalOnly,
|
||||
ContentType: string(form.ContentType),
|
||||
}
|
||||
|
||||
if form.Poll != nil {
|
||||
status.Poll = gtsmodel.ScheduledStatusPoll{
|
||||
Options: form.Poll.Options,
|
||||
ExpiresIn: form.Poll.ExpiresIn,
|
||||
Multiple: &form.Poll.Multiple,
|
||||
HideTotals: &form.Poll.HideTotals,
|
||||
}
|
||||
}
|
||||
|
||||
accountDefaultVisibility := requester.Settings.Privacy
|
||||
|
||||
switch {
|
||||
case form.Visibility != "":
|
||||
status.Visibility = typeutils.APIVisToVis(form.Visibility)
|
||||
|
||||
case accountDefaultVisibility != 0:
|
||||
status.Visibility = accountDefaultVisibility
|
||||
form.Visibility = typeutils.VisToAPIVis(accountDefaultVisibility)
|
||||
|
||||
default:
|
||||
status.Visibility = gtsmodel.VisibilityDefault
|
||||
form.Visibility = typeutils.VisToAPIVis(gtsmodel.VisibilityDefault)
|
||||
}
|
||||
|
||||
if form.InteractionPolicy != nil {
|
||||
interactionPolicy, err := typeutils.APIInteractionPolicyToInteractionPolicy(form.InteractionPolicy, form.Visibility)
|
||||
|
||||
if err != nil {
|
||||
err := gtserror.Newf("error converting interaction policy: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
status.InteractionPolicy = interactionPolicy
|
||||
}
|
||||
|
||||
// Insert this newly prepared status into the database.
|
||||
if err := p.state.DB.PutScheduledStatus(ctx, status); err != nil {
|
||||
err := gtserror.Newf("error inserting status in db: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
// Schedule the newly inserted status for publishing.
|
||||
if err := p.ScheduledStatusesSchedulePublication(ctx, status.ID); err != nil {
|
||||
err := gtserror.Newf("error scheduling status publish: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
apiScheduledStatus, err := p.converter.ScheduledStatusToAPIScheduledStatus(
|
||||
ctx,
|
||||
status,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
err := gtserror.Newf("error converting: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
return apiScheduledStatus, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@ func (p *Processor) Edit(
|
|||
requester.ID,
|
||||
statusID,
|
||||
form.MediaIDs,
|
||||
nil,
|
||||
)
|
||||
if errWithCode != nil {
|
||||
return nil, errWithCode
|
||||
|
|
|
|||
357
internal/processing/status/scheduledstatus.go
Normal file
357
internal/processing/status/scheduledstatus.go
Normal 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
|
||||
}
|
||||
69
internal/processing/status/scheduledstatus_test.go
Normal file
69
internal/processing/status/scheduledstatus_test.go
Normal 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))
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -140,6 +140,9 @@ func testDefaults() config.Configuration {
|
|||
StatusesPollOptionMaxChars: 50,
|
||||
StatusesMediaMaxFiles: 6,
|
||||
|
||||
ScheduledStatusesMaxTotal: 300,
|
||||
ScheduledStatusesMaxDaily: 25,
|
||||
|
||||
LetsEncryptEnabled: false,
|
||||
LetsEncryptPort: 0,
|
||||
LetsEncryptCertDir: "",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue