mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2025-10-28 18:52:24 -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)
|
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.
|
// Initialize metrics.
|
||||||
if err := observability.InitializeMetrics(ctx, state.DB); err != nil {
|
if err := observability.InitializeMetrics(ctx, state.DB); err != nil {
|
||||||
return fmt.Errorf("error initializing metrics: %w", err)
|
return fmt.Errorf("error initializing metrics: %w", err)
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,69 @@ definitions:
|
||||||
title: NodeInfoUsers represents aggregate information about the users on the server.
|
title: NodeInfoUsers represents aggregate information about the users on the server.
|
||||||
type: object
|
type: object
|
||||||
x-go-package: code.superseriousbusiness.org/gotosocial/internal/api/model
|
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:
|
Source:
|
||||||
description: Returned as an additional entity when verifying and updated credentials, as an attribute of Account.
|
description: Returned as an additional entity when verifying and updated credentials, as an attribute of Account.
|
||||||
properties:
|
properties:
|
||||||
|
|
@ -2909,6 +2972,25 @@ definitions:
|
||||||
type: object
|
type: object
|
||||||
x-go-name: Report
|
x-go-name: Report
|
||||||
x-go-package: code.superseriousbusiness.org/gotosocial/internal/api/model
|
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:
|
searchResult:
|
||||||
properties:
|
properties:
|
||||||
accounts:
|
accounts:
|
||||||
|
|
@ -10870,6 +10952,159 @@ paths:
|
||||||
summary: Get one report with the given id.
|
summary: Get one report with the given id.
|
||||||
tags:
|
tags:
|
||||||
- reports
|
- 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:
|
/api/v1/statuses:
|
||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
|
|
@ -10994,7 +11229,6 @@ paths:
|
||||||
|
|
||||||
Providing this parameter with a *future* time will cause ScheduledStatus to be returned instead of Status.
|
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.
|
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,
|
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.
|
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
|
description: not found
|
||||||
"406":
|
"406":
|
||||||
description: not acceptable
|
description: not acceptable
|
||||||
|
"422":
|
||||||
|
description: unprocessable content
|
||||||
"500":
|
"500":
|
||||||
description: internal server error
|
description: internal server error
|
||||||
"501":
|
"501":
|
||||||
|
|
|
||||||
|
|
@ -221,9 +221,7 @@ instance-stats-mode: ""
|
||||||
|
|
||||||
# Bool. This flag controls whether local accounts may backdate statuses
|
# Bool. This flag controls whether local accounts may backdate statuses
|
||||||
# using past dates with the scheduled_at param to /api/v1/statuses.
|
# using past dates with the scheduled_at param to /api/v1/statuses.
|
||||||
# This flag does not affect scheduling posts in the future
|
# This flag can't prevent remote accounts from backdating their own statuses.
|
||||||
# (which is currently not implemented anyway),
|
|
||||||
# nor can it prevent remote accounts from backdating their own statuses.
|
|
||||||
#
|
#
|
||||||
# If true, all local accounts may backdate 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.
|
# 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]
|
# Examples: [4, 6, 10]
|
||||||
# Default: 6
|
# Default: 6
|
||||||
statuses-media-max-files: 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
|
# Bool. This flag controls whether local accounts may backdate statuses
|
||||||
# using past dates with the scheduled_at param to /api/v1/statuses.
|
# using past dates with the scheduled_at param to /api/v1/statuses.
|
||||||
# This flag does not affect scheduling posts in the future
|
# This flag can't prevent remote accounts from backdating their own statuses.
|
||||||
# (which is currently not implemented anyway),
|
|
||||||
# nor can it prevent remote accounts from backdating their own statuses.
|
|
||||||
#
|
#
|
||||||
# If true, all local accounts may backdate 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.
|
# 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
|
# Default: 6
|
||||||
statuses-media-max-files: 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 #####
|
##### LETSENCRYPT CONFIG #####
|
||||||
##############################
|
##############################
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ import (
|
||||||
"code.superseriousbusiness.org/gotosocial/internal/api/client/preferences"
|
"code.superseriousbusiness.org/gotosocial/internal/api/client/preferences"
|
||||||
"code.superseriousbusiness.org/gotosocial/internal/api/client/push"
|
"code.superseriousbusiness.org/gotosocial/internal/api/client/push"
|
||||||
"code.superseriousbusiness.org/gotosocial/internal/api/client/reports"
|
"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/search"
|
||||||
"code.superseriousbusiness.org/gotosocial/internal/api/client/statuses"
|
"code.superseriousbusiness.org/gotosocial/internal/api/client/statuses"
|
||||||
"code.superseriousbusiness.org/gotosocial/internal/api/client/streaming"
|
"code.superseriousbusiness.org/gotosocial/internal/api/client/streaming"
|
||||||
|
|
@ -95,6 +96,7 @@ type Client struct {
|
||||||
preferences *preferences.Module // api/v1/preferences
|
preferences *preferences.Module // api/v1/preferences
|
||||||
push *push.Module // api/v1/push
|
push *push.Module // api/v1/push
|
||||||
reports *reports.Module // api/v1/reports
|
reports *reports.Module // api/v1/reports
|
||||||
|
scheduledStatuses *scheduledstatuses.Module // api/v1/scheduled_statuses
|
||||||
search *search.Module // api/v1/search, api/v2/search
|
search *search.Module // api/v1/search, api/v2/search
|
||||||
statuses *statuses.Module // api/v1/statuses
|
statuses *statuses.Module // api/v1/statuses
|
||||||
streaming *streaming.Module // api/v1/streaming
|
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.preferences.Route(h)
|
||||||
c.push.Route(h)
|
c.push.Route(h)
|
||||||
c.reports.Route(h)
|
c.reports.Route(h)
|
||||||
|
c.scheduledStatuses.Route(h)
|
||||||
c.search.Route(h)
|
c.search.Route(h)
|
||||||
c.statuses.Route(h)
|
c.statuses.Route(h)
|
||||||
c.streaming.Route(h)
|
c.streaming.Route(h)
|
||||||
|
|
@ -191,6 +194,7 @@ func NewClient(state *state.State, p *processing.Processor) *Client {
|
||||||
preferences: preferences.New(p),
|
preferences: preferences.New(p),
|
||||||
push: push.New(p),
|
push: push.New(p),
|
||||||
reports: reports.New(p),
|
reports: reports.New(p),
|
||||||
|
scheduledStatuses: scheduledstatuses.New(p),
|
||||||
search: search.New(p),
|
search: search.New(p),
|
||||||
statuses: statuses.New(p),
|
statuses: statuses.New(p),
|
||||||
streaming: streaming.New(p, time.Second*30, 4096),
|
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.
|
// 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.
|
// 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,
|
// 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.
|
// 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
|
// description: not found
|
||||||
// '406':
|
// '406':
|
||||||
// description: not acceptable
|
// description: not acceptable
|
||||||
|
// '422':
|
||||||
|
// description: unprocessable content
|
||||||
// '500':
|
// '500':
|
||||||
// description: internal server error
|
// description: internal server error
|
||||||
// '501':
|
// '501':
|
||||||
|
|
@ -300,7 +301,9 @@ func (m *Module) StatusCreatePOSTHandler(c *gin.Context) {
|
||||||
authed.Account,
|
authed.Account,
|
||||||
authed.Application,
|
authed.Application,
|
||||||
form,
|
form,
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
if errWithCode != nil {
|
if errWithCode != nil {
|
||||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -476,13 +476,24 @@ func (suite *StatusCreateTestSuite) TestPostNewScheduledStatus() {
|
||||||
"scheduled_at": {"2080-10-04T15:32:02.018Z"},
|
"scheduled_at": {"2080-10-04T15:32:02.018Z"},
|
||||||
}, "")
|
}, "")
|
||||||
|
|
||||||
// We should have 501 from
|
// We should have OK from
|
||||||
// our call to the function.
|
// 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(`{
|
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)
|
}`, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,22 +17,46 @@
|
||||||
|
|
||||||
package model
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
// ScheduledStatus represents a status that will be published at a future scheduled date.
|
// ScheduledStatus represents a status that will be published at a future scheduled date.
|
||||||
|
//
|
||||||
|
// swagger:model scheduledStatus
|
||||||
type ScheduledStatus struct {
|
type ScheduledStatus struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
ScheduledAt string `json:"scheduled_at"`
|
ScheduledAt string `json:"scheduled_at"`
|
||||||
Params *StatusParams `json:"params"`
|
Params *ScheduledStatusParams `json:"params"`
|
||||||
MediaAttachments []Attachment `json:"media_attachments"`
|
MediaAttachments []*Attachment `json:"media_attachments"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// StatusParams represents parameters for a scheduled status.
|
// StatusParams represents parameters for a scheduled status.
|
||||||
type StatusParams struct {
|
type ScheduledStatusParams struct {
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
InReplyToID string `json:"in_reply_to_id,omitempty"`
|
MediaIDs []string `json:"media_ids,omitempty"`
|
||||||
MediaIDs []string `json:"media_ids,omitempty"`
|
Sensitive bool `json:"sensitive,omitempty"`
|
||||||
Sensitive bool `json:"sensitive,omitempty"`
|
Poll *ScheduledStatusParamsPoll `json:"poll,omitempty"`
|
||||||
SpoilerText string `json:"spoiler_text,omitempty"`
|
SpoilerText string `json:"spoiler_text,omitempty"`
|
||||||
Visibility string `json:"visibility"`
|
Visibility Visibility `json:"visibility"`
|
||||||
ScheduledAt string `json:"scheduled_at,omitempty"`
|
InReplyToID string `json:"in_reply_to_id,omitempty"`
|
||||||
ApplicationID string `json:"application_id"`
|
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.
|
// 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.
|
// 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,
|
// 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.
|
// 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.initPollVote()
|
||||||
c.initPollVoteIDs()
|
c.initPollVoteIDs()
|
||||||
c.initReport()
|
c.initReport()
|
||||||
|
c.initScheduledStatus()
|
||||||
c.initSinBinStatus()
|
c.initSinBinStatus()
|
||||||
c.initStatus()
|
c.initStatus()
|
||||||
c.initStatusBookmark()
|
c.initStatusBookmark()
|
||||||
|
|
@ -200,6 +201,7 @@ func (c *Caches) Sweep(threshold float64) {
|
||||||
c.DB.PollVote.Trim(threshold)
|
c.DB.PollVote.Trim(threshold)
|
||||||
c.DB.PollVoteIDs.Trim(threshold)
|
c.DB.PollVoteIDs.Trim(threshold)
|
||||||
c.DB.Report.Trim(threshold)
|
c.DB.Report.Trim(threshold)
|
||||||
|
c.DB.ScheduledStatus.Trim(threshold)
|
||||||
c.DB.SinBinStatus.Trim(threshold)
|
c.DB.SinBinStatus.Trim(threshold)
|
||||||
c.DB.Status.Trim(threshold)
|
c.DB.Status.Trim(threshold)
|
||||||
c.DB.StatusBookmark.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 provides access to the gtsmodel Report database cache.
|
||||||
Report StructCache[*gtsmodel.Report]
|
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 provides access to the gtsmodel SinBinStatus database cache.
|
||||||
SinBinStatus StructCache[*gtsmodel.SinBinStatus]
|
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() {
|
func (c *Caches) initSinBinStatus() {
|
||||||
// Calculate maximum cache size.
|
// Calculate maximum cache size.
|
||||||
cap := calculateResultCacheMax(
|
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)
|
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) {
|
func (c *Caches) OnInvalidateStatus(status *gtsmodel.Status) {
|
||||||
// Invalidate cached stats objects for this account.
|
// Invalidate cached stats objects for this account.
|
||||||
c.DB.AccountStats.Invalidate("AccountID", status.AccountID)
|
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 {
|
func sizeofSinBinStatus() uintptr {
|
||||||
return uintptr(size.Of(>smodel.SinBinStatus{
|
return uintptr(size.Of(>smodel.SinBinStatus{
|
||||||
ID: exampleID,
|
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.
|
// Media totally unused, delete it.
|
||||||
l.Debug("deleting unused media")
|
l.Debug("deleting unused media")
|
||||||
return true, m.delete(ctx, 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
|
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 {
|
func (m *Media) uncache(ctx context.Context, media *gtsmodel.MediaAttachment) error {
|
||||||
if gtscontext.DryRun(ctx) {
|
if gtscontext.DryRun(ctx) {
|
||||||
// Dry run, do nothing.
|
// 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"`
|
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"`
|
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)."`
|
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."`
|
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."`
|
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"`
|
PollVoteMemRatio float64 `name:"poll-vote-mem-ratio"`
|
||||||
PollVoteIDsMemRatio float64 `name:"poll-vote-ids-mem-ratio"`
|
PollVoteIDsMemRatio float64 `name:"poll-vote-ids-mem-ratio"`
|
||||||
ReportMemRatio float64 `name:"report-mem-ratio"`
|
ReportMemRatio float64 `name:"report-mem-ratio"`
|
||||||
|
ScheduledStatusMemRatio float64 `name:"scheduled-status-mem-ratio"`
|
||||||
SinBinStatusMemRatio float64 `name:"sin-bin-status-mem-ratio"`
|
SinBinStatusMemRatio float64 `name:"sin-bin-status-mem-ratio"`
|
||||||
StatusMemRatio float64 `name:"status-mem-ratio"`
|
StatusMemRatio float64 `name:"status-mem-ratio"`
|
||||||
StatusBookmarkMemRatio float64 `name:"status-bookmark-mem-ratio"`
|
StatusBookmarkMemRatio float64 `name:"status-bookmark-mem-ratio"`
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,9 @@ var Defaults = Configuration{
|
||||||
StatusesPollOptionMaxChars: 50,
|
StatusesPollOptionMaxChars: 50,
|
||||||
StatusesMediaMaxFiles: 6,
|
StatusesMediaMaxFiles: 6,
|
||||||
|
|
||||||
|
ScheduledStatusesMaxTotal: 300,
|
||||||
|
ScheduledStatusesMaxDaily: 25,
|
||||||
|
|
||||||
LetsEncryptEnabled: false,
|
LetsEncryptEnabled: false,
|
||||||
LetsEncryptPort: 80,
|
LetsEncryptPort: 80,
|
||||||
LetsEncryptCertDir: "/gotosocial/storage/certs",
|
LetsEncryptCertDir: "/gotosocial/storage/certs",
|
||||||
|
|
@ -217,6 +220,7 @@ var Defaults = Configuration{
|
||||||
PollVoteMemRatio: 2,
|
PollVoteMemRatio: 2,
|
||||||
PollVoteIDsMemRatio: 2,
|
PollVoteIDsMemRatio: 2,
|
||||||
ReportMemRatio: 1,
|
ReportMemRatio: 1,
|
||||||
|
ScheduledStatusMemRatio: 4,
|
||||||
SinBinStatusMemRatio: 0.5,
|
SinBinStatusMemRatio: 0.5,
|
||||||
StatusMemRatio: 5,
|
StatusMemRatio: 5,
|
||||||
StatusBookmarkMemRatio: 0.5,
|
StatusBookmarkMemRatio: 0.5,
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,8 @@ const (
|
||||||
StatusesPollMaxOptionsFlag = "statuses-poll-max-options"
|
StatusesPollMaxOptionsFlag = "statuses-poll-max-options"
|
||||||
StatusesPollOptionMaxCharsFlag = "statuses-poll-option-max-chars"
|
StatusesPollOptionMaxCharsFlag = "statuses-poll-option-max-chars"
|
||||||
StatusesMediaMaxFilesFlag = "statuses-media-max-files"
|
StatusesMediaMaxFilesFlag = "statuses-media-max-files"
|
||||||
|
ScheduledStatusesMaxTotalFlag = "scheduled-statuses-max-total"
|
||||||
|
ScheduledStatusesMaxDailyFlag = "scheduled-statuses-max-daily"
|
||||||
LetsEncryptEnabledFlag = "letsencrypt-enabled"
|
LetsEncryptEnabledFlag = "letsencrypt-enabled"
|
||||||
LetsEncryptPortFlag = "letsencrypt-port"
|
LetsEncryptPortFlag = "letsencrypt-port"
|
||||||
LetsEncryptCertDirFlag = "letsencrypt-cert-dir"
|
LetsEncryptCertDirFlag = "letsencrypt-cert-dir"
|
||||||
|
|
@ -194,6 +196,7 @@ const (
|
||||||
CachePollVoteMemRatioFlag = "cache-poll-vote-mem-ratio"
|
CachePollVoteMemRatioFlag = "cache-poll-vote-mem-ratio"
|
||||||
CachePollVoteIDsMemRatioFlag = "cache-poll-vote-ids-mem-ratio"
|
CachePollVoteIDsMemRatioFlag = "cache-poll-vote-ids-mem-ratio"
|
||||||
CacheReportMemRatioFlag = "cache-report-mem-ratio"
|
CacheReportMemRatioFlag = "cache-report-mem-ratio"
|
||||||
|
CacheScheduledStatusMemRatioFlag = "cache-scheduled-status-mem-ratio"
|
||||||
CacheSinBinStatusMemRatioFlag = "cache-sin-bin-status-mem-ratio"
|
CacheSinBinStatusMemRatioFlag = "cache-sin-bin-status-mem-ratio"
|
||||||
CacheStatusMemRatioFlag = "cache-status-mem-ratio"
|
CacheStatusMemRatioFlag = "cache-status-mem-ratio"
|
||||||
CacheStatusBookmarkMemRatioFlag = "cache-status-bookmark-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-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-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("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.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.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.")
|
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-mem-ratio", cfg.Cache.PollVoteMemRatio, "")
|
||||||
flags.Float64("cache-poll-vote-ids-mem-ratio", cfg.Cache.PollVoteIDsMemRatio, "")
|
flags.Float64("cache-poll-vote-ids-mem-ratio", cfg.Cache.PollVoteIDsMemRatio, "")
|
||||||
flags.Float64("cache-report-mem-ratio", cfg.Cache.ReportMemRatio, "")
|
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-sin-bin-status-mem-ratio", cfg.Cache.SinBinStatusMemRatio, "")
|
||||||
flags.Float64("cache-status-mem-ratio", cfg.Cache.StatusMemRatio, "")
|
flags.Float64("cache-status-mem-ratio", cfg.Cache.StatusMemRatio, "")
|
||||||
flags.Float64("cache-status-bookmark-mem-ratio", cfg.Cache.StatusBookmarkMemRatio, "")
|
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 {
|
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-level"] = cfg.LogLevel
|
||||||
cfgmap["log-format"] = cfg.LogFormat
|
cfgmap["log-format"] = cfg.LogFormat
|
||||||
cfgmap["log-timestamp-format"] = cfg.LogTimestampFormat
|
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-max-options"] = cfg.StatusesPollMaxOptions
|
||||||
cfgmap["statuses-poll-option-max-chars"] = cfg.StatusesPollOptionMaxChars
|
cfgmap["statuses-poll-option-max-chars"] = cfg.StatusesPollOptionMaxChars
|
||||||
cfgmap["statuses-media-max-files"] = cfg.StatusesMediaMaxFiles
|
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-enabled"] = cfg.LetsEncryptEnabled
|
||||||
cfgmap["letsencrypt-port"] = cfg.LetsEncryptPort
|
cfgmap["letsencrypt-port"] = cfg.LetsEncryptPort
|
||||||
cfgmap["letsencrypt-cert-dir"] = cfg.LetsEncryptCertDir
|
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-mem-ratio"] = cfg.Cache.PollVoteMemRatio
|
||||||
cfgmap["cache-poll-vote-ids-mem-ratio"] = cfg.Cache.PollVoteIDsMemRatio
|
cfgmap["cache-poll-vote-ids-mem-ratio"] = cfg.Cache.PollVoteIDsMemRatio
|
||||||
cfgmap["cache-report-mem-ratio"] = cfg.Cache.ReportMemRatio
|
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-sin-bin-status-mem-ratio"] = cfg.Cache.SinBinStatusMemRatio
|
||||||
cfgmap["cache-status-mem-ratio"] = cfg.Cache.StatusMemRatio
|
cfgmap["cache-status-mem-ratio"] = cfg.Cache.StatusMemRatio
|
||||||
cfgmap["cache-status-bookmark-mem-ratio"] = cfg.Cache.StatusBookmarkMemRatio
|
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 {
|
if ival, ok := cfgmap["letsencrypt-enabled"]; ok {
|
||||||
var err error
|
var err error
|
||||||
cfg.LetsEncryptEnabled, err = cast.ToBoolE(ival)
|
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 {
|
if ival, ok := cfgmap["cache-sin-bin-status-mem-ratio"]; ok {
|
||||||
var err error
|
var err error
|
||||||
cfg.Cache.SinBinStatusMemRatio, err = cast.ToFloat64E(ival)
|
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
|
// SetStatusesMediaMaxFiles safely sets the value for global configuration 'StatusesMediaMaxFiles' field
|
||||||
func SetStatusesMediaMaxFiles(v int) { global.SetStatusesMediaMaxFiles(v) }
|
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
|
// GetLetsEncryptEnabled safely fetches the Configuration value for state's 'LetsEncryptEnabled' field
|
||||||
func (st *ConfigState) GetLetsEncryptEnabled() (v bool) {
|
func (st *ConfigState) GetLetsEncryptEnabled() (v bool) {
|
||||||
st.mutex.RLock()
|
st.mutex.RLock()
|
||||||
|
|
@ -5859,6 +5936,28 @@ func GetCacheReportMemRatio() float64 { return global.GetCacheReportMemRatio() }
|
||||||
// SetCacheReportMemRatio safely sets the value for global configuration 'Cache.ReportMemRatio' field
|
// SetCacheReportMemRatio safely sets the value for global configuration 'Cache.ReportMemRatio' field
|
||||||
func SetCacheReportMemRatio(v float64) { global.SetCacheReportMemRatio(v) }
|
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
|
// GetCacheSinBinStatusMemRatio safely fetches the Configuration value for state's 'Cache.SinBinStatusMemRatio' field
|
||||||
func (st *ConfigState) GetCacheSinBinStatusMemRatio() (v float64) {
|
func (st *ConfigState) GetCacheSinBinStatusMemRatio() (v float64) {
|
||||||
st.mutex.RLock()
|
st.mutex.RLock()
|
||||||
|
|
@ -6545,6 +6644,7 @@ func (st *ConfigState) GetTotalOfMemRatios() (total float64) {
|
||||||
total += st.config.Cache.PollVoteMemRatio
|
total += st.config.Cache.PollVoteMemRatio
|
||||||
total += st.config.Cache.PollVoteIDsMemRatio
|
total += st.config.Cache.PollVoteIDsMemRatio
|
||||||
total += st.config.Cache.ReportMemRatio
|
total += st.config.Cache.ReportMemRatio
|
||||||
|
total += st.config.Cache.ScheduledStatusMemRatio
|
||||||
total += st.config.Cache.SinBinStatusMemRatio
|
total += st.config.Cache.SinBinStatusMemRatio
|
||||||
total += st.config.Cache.StatusMemRatio
|
total += st.config.Cache.StatusMemRatio
|
||||||
total += st.config.Cache.StatusBookmarkMemRatio
|
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{
|
for _, key := range [][]string{
|
||||||
{"cache", "sin-bin-status-mem-ratio"},
|
{"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-media-max-files": 6,
|
||||||
"statuses-poll-max-options": 6,
|
"statuses-poll-max-options": 6,
|
||||||
"statuses-poll-option-max-chars": 50,
|
"statuses-poll-option-max-chars": 50,
|
||||||
|
"scheduled-statuses-max-total": 300,
|
||||||
|
"scheduled-statuses-max-daily": 25,
|
||||||
"storage-backend": "local",
|
"storage-backend": "local",
|
||||||
"storage-local-base-path": "/gotosocial/storage",
|
"storage-local-base-path": "/gotosocial/storage",
|
||||||
"trusted-proxies": [
|
"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
|
# Default: 6
|
||||||
statuses-media-max-files: 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 #####
|
##### LETSENCRYPT CONFIG #####
|
||||||
##############################
|
##############################
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,7 @@ type DBService struct {
|
||||||
db.Relationship
|
db.Relationship
|
||||||
db.Report
|
db.Report
|
||||||
db.Rule
|
db.Rule
|
||||||
|
db.ScheduledStatus
|
||||||
db.Search
|
db.Search
|
||||||
db.Session
|
db.Session
|
||||||
db.SinBinStatus
|
db.SinBinStatus
|
||||||
|
|
@ -261,6 +262,10 @@ func NewBunDBService(ctx context.Context, state *state.State) (db.DB, error) {
|
||||||
db: db,
|
db: db,
|
||||||
state: state,
|
state: state,
|
||||||
},
|
},
|
||||||
|
ScheduledStatus: &scheduledStatusDB{
|
||||||
|
db: db,
|
||||||
|
state: state,
|
||||||
|
},
|
||||||
Search: &searchDB{
|
Search: &searchDB{
|
||||||
db: db,
|
db: db,
|
||||||
state: state,
|
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
|
// attachments to the current status
|
||||||
for _, a := range status.Attachments {
|
for _, a := range status.Attachments {
|
||||||
a.StatusID = status.ID
|
a.StatusID = status.ID
|
||||||
|
a.ScheduledStatusID = ""
|
||||||
if _, err := tx.
|
if _, err := tx.
|
||||||
NewUpdate().
|
NewUpdate().
|
||||||
Model(a).
|
Model(a).
|
||||||
Column("status_id").
|
Column("status_id", "scheduled_status_id").
|
||||||
Where("? = ?", bun.Ident("media_attachment.id"), a.ID).
|
Where("? = ?", bun.Ident("media_attachment.id"), a.ID).
|
||||||
Exec(ctx); err != nil {
|
Exec(ctx); err != nil {
|
||||||
return gtserror.Newf("error updating media: %w", err)
|
return gtserror.Newf("error updating media: %w", err)
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ type DB interface {
|
||||||
Relationship
|
Relationship
|
||||||
Report
|
Report
|
||||||
Rule
|
Rule
|
||||||
|
ScheduledStatus
|
||||||
Search
|
Search
|
||||||
Session
|
Session
|
||||||
SinBinStatus
|
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 {
|
if err := p.state.DB.DeleteAccountStats(ctx, account.ID); err != nil {
|
||||||
log.Errorf("error deleting stats for account: %v", err)
|
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.
|
// Delete all bookmarks targeting given account, local and remote.
|
||||||
|
|
|
||||||
|
|
@ -66,5 +66,11 @@ func (p *Processor) Delete(
|
||||||
return nil, gtserror.NewErrorInternalError(err)
|
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
|
return apiApp, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -282,6 +282,7 @@ func (p *Processor) processMedia(
|
||||||
authorID string,
|
authorID string,
|
||||||
statusID string,
|
statusID string,
|
||||||
mediaIDs []string,
|
mediaIDs []string,
|
||||||
|
scheduledStatusID *string,
|
||||||
) (
|
) (
|
||||||
[]*gtsmodel.MediaAttachment,
|
[]*gtsmodel.MediaAttachment,
|
||||||
gtserror.WithCode,
|
gtserror.WithCode,
|
||||||
|
|
@ -315,7 +316,7 @@ func (p *Processor) processMedia(
|
||||||
|
|
||||||
// Check media isn't already attached to another status.
|
// Check media isn't already attached to another status.
|
||||||
if (media.StatusID != "" && media.StatusID != statusID) ||
|
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)
|
text := fmt.Sprintf("media already attached to status: %s", id)
|
||||||
return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
|
return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,10 +44,8 @@ func (p *Processor) Create(
|
||||||
requester *gtsmodel.Account,
|
requester *gtsmodel.Account,
|
||||||
application *gtsmodel.Application,
|
application *gtsmodel.Application,
|
||||||
form *apimodel.StatusCreateRequest,
|
form *apimodel.StatusCreateRequest,
|
||||||
) (
|
scheduledStatusID *string,
|
||||||
*apimodel.Status,
|
) (any, gtserror.WithCode) {
|
||||||
gtserror.WithCode,
|
|
||||||
) {
|
|
||||||
// Validate incoming form status content.
|
// Validate incoming form status content.
|
||||||
if errWithCode := validateStatusContent(
|
if errWithCode := validateStatusContent(
|
||||||
form.Status,
|
form.Status,
|
||||||
|
|
@ -83,16 +81,6 @@ func (p *Processor) Create(
|
||||||
return nil, errWithCode
|
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.
|
// Generate necessary URIs for username, to build status URIs.
|
||||||
accountURIs := uris.GenerateURIsForAccount(requester.Username)
|
accountURIs := uris.GenerateURIsForAccount(requester.Username)
|
||||||
|
|
||||||
|
|
@ -105,16 +93,27 @@ func (p *Processor) Create(
|
||||||
|
|
||||||
// Handle backfilled/scheduled statuses.
|
// Handle backfilled/scheduled statuses.
|
||||||
backfill := false
|
backfill := false
|
||||||
if form.ScheduledAt != nil {
|
|
||||||
scheduledAt := *form.ScheduledAt
|
|
||||||
|
|
||||||
// Statuses may only be scheduled
|
switch {
|
||||||
// a minimum time into the future.
|
case form.ScheduledAt == nil:
|
||||||
if now.Before(scheduledAt) {
|
// No scheduling/backfilling
|
||||||
const errText = "scheduled statuses are not yet supported"
|
break
|
||||||
return nil, gtserror.NewErrorNotImplemented(gtserror.New(errText), errText)
|
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 not scheduled into the future, this status is being backfilled.
|
||||||
if !config.GetInstanceAllowBackdatingStatuses() {
|
if !config.GetInstanceAllowBackdatingStatuses() {
|
||||||
const errText = "backdating statuses has been disabled on this instance"
|
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
|
// this would also cause issues with time.Time.IsZero() checks
|
||||||
// that normally signify an absent optional time,
|
// that normally signify an absent optional time,
|
||||||
// but this check covers both cases.
|
// 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"
|
const errText = "statuses can't be backdated to or before the UNIX epoch"
|
||||||
return nil, gtserror.NewErrorNotAcceptable(gtserror.New(errText), errText)
|
return nil, gtserror.NewErrorNotAcceptable(gtserror.New(errText), errText)
|
||||||
}
|
}
|
||||||
|
|
@ -138,7 +137,7 @@ func (p *Processor) Create(
|
||||||
backfill = true
|
backfill = true
|
||||||
|
|
||||||
// Update to backfill date.
|
// Update to backfill date.
|
||||||
createdAt = scheduledAt
|
createdAt = *form.ScheduledAt
|
||||||
|
|
||||||
// Generate an appropriate, (and unique!), ID for the creation time.
|
// Generate an appropriate, (and unique!), ID for the creation time.
|
||||||
if statusID, err = p.backfilledStatusID(ctx, createdAt); err != nil {
|
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{
|
status := >smodel.Status{
|
||||||
ID: statusID,
|
ID: statusID,
|
||||||
URI: accountURIs.StatusesURI + "/" + statusID,
|
URI: accountURIs.StatusesURI + "/" + statusID,
|
||||||
|
|
@ -546,3 +556,103 @@ func processInteractionPolicy(
|
||||||
// setting it explicitly to save space.
|
// setting it explicitly to save space.
|
||||||
return nil
|
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,
|
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.NoError(err)
|
||||||
suite.NotNil(apiStatus)
|
suite.NotNil(apiStatus)
|
||||||
|
|
||||||
|
|
@ -84,7 +85,8 @@ func (suite *StatusCreateTestSuite) TestProcessStatusMarkdownWithUnderscoreEmoji
|
||||||
ContentType: apimodel.StatusContentTypeMarkdown,
|
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.NoError(err)
|
||||||
suite.NotNil(apiStatus)
|
suite.NotNil(apiStatus)
|
||||||
|
|
||||||
|
|
@ -111,7 +113,8 @@ func (suite *StatusCreateTestSuite) TestProcessStatusMarkdownWithSpoilerTextEmoj
|
||||||
ContentType: apimodel.StatusContentTypeMarkdown,
|
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.NoError(err)
|
||||||
suite.NotNil(apiStatus)
|
suite.NotNil(apiStatus)
|
||||||
|
|
||||||
|
|
@ -142,7 +145,7 @@ func (suite *StatusCreateTestSuite) TestProcessMediaDescriptionTooShort() {
|
||||||
ContentType: apimodel.StatusContentTypePlain,
|
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.EqualError(err, "media description less than min chars (100)")
|
||||||
suite.Nil(apiStatus)
|
suite.Nil(apiStatus)
|
||||||
}
|
}
|
||||||
|
|
@ -167,7 +170,8 @@ func (suite *StatusCreateTestSuite) TestProcessLanguageWithScriptPart() {
|
||||||
ContentType: apimodel.StatusContentTypePlain,
|
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.NoError(err)
|
||||||
suite.NotNil(apiStatus)
|
suite.NotNil(apiStatus)
|
||||||
|
|
||||||
|
|
@ -197,7 +201,8 @@ func (suite *StatusCreateTestSuite) TestProcessReplyToUnthreadedRemoteStatus() {
|
||||||
ContentType: apimodel.StatusContentTypePlain,
|
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.NoError(err)
|
||||||
suite.NotNil(apiStatus)
|
suite.NotNil(apiStatus)
|
||||||
|
|
||||||
|
|
@ -230,7 +235,8 @@ func (suite *StatusCreateTestSuite) TestProcessNoContentTypeUsesDefault() {
|
||||||
ContentType: "",
|
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.NoError(errWithCode)
|
||||||
suite.NotNil(apiStatus)
|
suite.NotNil(apiStatus)
|
||||||
|
|
||||||
|
|
@ -260,7 +266,7 @@ func (suite *StatusCreateTestSuite) TestProcessInvalidVisibility() {
|
||||||
ContentType: apimodel.StatusContentTypePlain,
|
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.Nil(apiStatus)
|
||||||
suite.Equal(http.StatusUnprocessableEntity, errWithCode.Code())
|
suite.Equal(http.StatusUnprocessableEntity, errWithCode.Code())
|
||||||
suite.Equal("Unprocessable Entity: processVisibility: invalid visibility", errWithCode.Safe())
|
suite.Equal("Unprocessable Entity: processVisibility: invalid visibility", errWithCode.Safe())
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,7 @@ func (p *Processor) Edit(
|
||||||
requester.ID,
|
requester.ID,
|
||||||
statusID,
|
statusID,
|
||||||
form.MediaIDs,
|
form.MediaIDs,
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
if errWithCode != nil {
|
if errWithCode != nil {
|
||||||
return nil, errWithCode
|
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
|
federator *federation.Federator
|
||||||
|
|
||||||
// standard suite models
|
// standard suite models
|
||||||
testTokens map[string]*gtsmodel.Token
|
testTokens map[string]*gtsmodel.Token
|
||||||
testApplications map[string]*gtsmodel.Application
|
testApplications map[string]*gtsmodel.Application
|
||||||
testUsers map[string]*gtsmodel.User
|
testUsers map[string]*gtsmodel.User
|
||||||
testAccounts map[string]*gtsmodel.Account
|
testAccounts map[string]*gtsmodel.Account
|
||||||
testAttachments map[string]*gtsmodel.MediaAttachment
|
testAttachments map[string]*gtsmodel.MediaAttachment
|
||||||
testStatuses map[string]*gtsmodel.Status
|
testStatuses map[string]*gtsmodel.Status
|
||||||
testTags map[string]*gtsmodel.Tag
|
testTags map[string]*gtsmodel.Tag
|
||||||
testMentions map[string]*gtsmodel.Mention
|
testMentions map[string]*gtsmodel.Mention
|
||||||
|
testScheduledStatuses map[string]*gtsmodel.ScheduledStatus
|
||||||
|
|
||||||
// module being tested
|
// module being tested
|
||||||
status status.Processor
|
status status.Processor
|
||||||
|
|
@ -73,6 +74,7 @@ func (suite *StatusStandardTestSuite) SetupSuite() {
|
||||||
suite.testStatuses = testrig.NewTestStatuses()
|
suite.testStatuses = testrig.NewTestStatuses()
|
||||||
suite.testTags = testrig.NewTestTags()
|
suite.testTags = testrig.NewTestTags()
|
||||||
suite.testMentions = testrig.NewTestMentions()
|
suite.testMentions = testrig.NewTestMentions()
|
||||||
|
suite.testScheduledStatuses = testrig.NewTestScheduledStatuses()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (suite *StatusStandardTestSuite) SetupTest() {
|
func (suite *StatusStandardTestSuite) SetupTest() {
|
||||||
|
|
|
||||||
|
|
@ -3014,3 +3014,57 @@ func (c *Converter) TokenToAPITokenInfo(
|
||||||
Application: apiApplication,
|
Application: apiApplication,
|
||||||
}, nil
|
}, 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-ids-mem-ratio": 2,
|
||||||
"cache-poll-vote-mem-ratio": 2,
|
"cache-poll-vote-mem-ratio": 2,
|
||||||
"cache-report-mem-ratio": 1,
|
"cache-report-mem-ratio": 1,
|
||||||
|
"cache-scheduled-status-mem-ratio": 4,
|
||||||
"cache-sin-bin-status-mem-ratio": 0.5,
|
"cache-sin-bin-status-mem-ratio": 0.5,
|
||||||
"cache-status-bookmark-ids-mem-ratio": 2,
|
"cache-status-bookmark-ids-mem-ratio": 2,
|
||||||
"cache-status-bookmark-mem-ratio": 0.5,
|
"cache-status-bookmark-mem-ratio": 0.5,
|
||||||
|
|
@ -177,6 +178,8 @@ EXPECT=$(cat << "EOF"
|
||||||
"protocol": "http",
|
"protocol": "http",
|
||||||
"remote-only": false,
|
"remote-only": false,
|
||||||
"request-id-header": "X-Trace-Id",
|
"request-id-header": "X-Trace-Id",
|
||||||
|
"scheduled-statuses-max-daily": 25,
|
||||||
|
"scheduled-statuses-max-total": 300,
|
||||||
"skip-db-setup": false,
|
"skip-db-setup": false,
|
||||||
"skip-db-teardown": false,
|
"skip-db-teardown": false,
|
||||||
"smtp-disclose-recipients": true,
|
"smtp-disclose-recipients": true,
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,9 @@ func testDefaults() config.Configuration {
|
||||||
StatusesPollOptionMaxChars: 50,
|
StatusesPollOptionMaxChars: 50,
|
||||||
StatusesMediaMaxFiles: 6,
|
StatusesMediaMaxFiles: 6,
|
||||||
|
|
||||||
|
ScheduledStatusesMaxTotal: 300,
|
||||||
|
ScheduledStatusesMaxDaily: 25,
|
||||||
|
|
||||||
LetsEncryptEnabled: false,
|
LetsEncryptEnabled: false,
|
||||||
LetsEncryptPort: 0,
|
LetsEncryptPort: 0,
|
||||||
LetsEncryptCertDir: "",
|
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 {
|
if err := db.CreateInstanceAccount(ctx); err != nil {
|
||||||
log.Panic(ctx, err)
|
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.
|
// 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) {
|
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
|
// convert the activity into json bytes
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue