diff options
author | 2024-08-24 11:49:37 +0200 | |
---|---|---|
committer | 2024-08-24 11:49:37 +0200 | |
commit | f23f04e0b1d117be714bf91d5266dab219ed741e (patch) | |
tree | 0b3ddd60d51c8729949c3669993910a7f8f32a7b /internal | |
parent | [performance] ffmpeg ffprobe wrapper improvements (#3225) (diff) | |
download | gotosocial-f23f04e0b1d117be714bf91d5266dab219ed741e.tar.xz |
[feature] Interaction requests client api + settings panel (#3215)
* [feature] Interaction requests client api + settings panel
* test accept / reject
* fmt
* don't pin rejected interaction
* use single db model for interaction accept, reject, and request
* swaggor
* env sharting
* append errors
* remove ErrNoEntries checks
* change intReqID to reqID
* rename "pend" to "request"
* markIntsPending -> mark interactionsPending
* use log instead of returning error when rejecting interaction
* empty migration
* jolly renaming
* make interactionURI unique again
* swag grr
* remove unnecessary locks
* invalidate as last step
Diffstat (limited to 'internal')
52 files changed, 3213 insertions, 645 deletions
diff --git a/internal/api/activitypub/users/acceptget.go b/internal/api/activitypub/users/acceptget.go index c2b438330..4d0630df7 100644 --- a/internal/api/activitypub/users/acceptget.go +++ b/internal/api/activitypub/users/acceptget.go @@ -25,7 +25,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/gtserror" ) -// AcceptGETHandler serves an interactionApproval as an ActivityStreams Accept. +// AcceptGETHandler serves an interaction request as an ActivityStreams Accept. func (m *Module) AcceptGETHandler(c *gin.Context) { username, errWithCode := apiutil.ParseUsername(c.Param(apiutil.UsernameKey)) if errWithCode != nil { @@ -33,7 +33,7 @@ func (m *Module) AcceptGETHandler(c *gin.Context) { return } - acceptID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) + reqID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) if errWithCode != nil { apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) return @@ -45,7 +45,7 @@ func (m *Module) AcceptGETHandler(c *gin.Context) { return } - resp, errWithCode := m.processor.Fedi().AcceptGet(c.Request.Context(), username, acceptID) + resp, errWithCode := m.processor.Fedi().AcceptGet(c.Request.Context(), username, reqID) if errWithCode != nil { apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) return diff --git a/internal/api/client.go b/internal/api/client.go index 65d4f29d5..77a63eb89 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -38,6 +38,7 @@ import ( importdata "github.com/superseriousbusiness/gotosocial/internal/api/client/import" "github.com/superseriousbusiness/gotosocial/internal/api/client/instance" "github.com/superseriousbusiness/gotosocial/internal/api/client/interactionpolicies" + "github.com/superseriousbusiness/gotosocial/internal/api/client/interactionrequests" "github.com/superseriousbusiness/gotosocial/internal/api/client/lists" "github.com/superseriousbusiness/gotosocial/internal/api/client/markers" "github.com/superseriousbusiness/gotosocial/internal/api/client/media" @@ -80,6 +81,7 @@ type Client struct { importData *importdata.Module // api/v1/import instance *instance.Module // api/v1/instance interactionPolicies *interactionpolicies.Module // api/v1/interaction_policies + interactionRequests *interactionrequests.Module // api/v1/interaction_requests lists *lists.Module // api/v1/lists markers *markers.Module // api/v1/markers media *media.Module // api/v1/media, api/v2/media @@ -130,6 +132,7 @@ func (c *Client) Route(r *router.Router, m ...gin.HandlerFunc) { c.importData.Route(h) c.instance.Route(h) c.interactionPolicies.Route(h) + c.interactionRequests.Route(h) c.lists.Route(h) c.markers.Route(h) c.media.Route(h) @@ -168,6 +171,7 @@ func NewClient(state *state.State, p *processing.Processor) *Client { importData: importdata.New(p), instance: instance.New(p), interactionPolicies: interactionpolicies.New(p), + interactionRequests: interactionrequests.New(p), lists: lists.New(p), markers: markers.New(p), media: media.New(p), diff --git a/internal/api/client/interactionrequests/authorize.go b/internal/api/client/interactionrequests/authorize.go new file mode 100644 index 000000000..1e5589f7e --- /dev/null +++ b/internal/api/client/interactionrequests/authorize.go @@ -0,0 +1,104 @@ +// 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 interactionrequests + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// InteractionRequestAuthorizePOSTHandler swagger:operation POST /api/v1/interaction_requests/{id}/authorize authorizeInteractionRequest +// +// Accept/authorize/approve an interaction request with the given ID. +// +// --- +// tags: +// - interaction_requests +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: string +// description: ID of the interaction request targeting you. +// in: path +// required: true +// +// security: +// - OAuth2 Bearer: +// - write:statuses +// +// responses: +// '200': +// name: Approval. +// description: The now-approved interaction request. +// schema: +// "$ref": "#/definitions/interactionRequest" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) InteractionRequestAuthorizePOSTHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + errWithCode := gtserror.NewErrorUnauthorized(err, err.Error()) + 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 { + errWithCode := gtserror.NewErrorNotAcceptable(err, err.Error()) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + reqID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiReq, errWithCode := m.processor.InteractionRequests().Accept( + c.Request.Context(), + authed.Account, + reqID, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusOK, apiReq) +} diff --git a/internal/api/client/interactionrequests/get.go b/internal/api/client/interactionrequests/get.go new file mode 100644 index 000000000..a354a8623 --- /dev/null +++ b/internal/api/client/interactionrequests/get.go @@ -0,0 +1,96 @@ +// 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 interactionrequests + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// InteractionRequestGETHandler swagger:operation GET /api/v1/interaction_requests/{id} getInteractionRequest +// +// Get interaction request with the given ID. +// +// --- +// tags: +// - interaction_requests +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: string +// description: ID of the interaction request targeting you. +// in: path +// required: true +// +// security: +// - OAuth2 Bearer: +// - read:notifications +// +// responses: +// '200': +// description: Interaction request. +// schema: +// "$ref": "#/definitions/interactionRequest" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) InteractionRequestGETHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), 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 + } + + id, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + intReq, errWithCode := m.processor.InteractionRequests().GetOne( + c.Request.Context(), + authed.Account, + id, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusOK, intReq) +} diff --git a/internal/api/client/interactionrequests/getpage.go b/internal/api/client/interactionrequests/getpage.go new file mode 100644 index 000000000..1978a055c --- /dev/null +++ b/internal/api/client/interactionrequests/getpage.go @@ -0,0 +1,211 @@ +// 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 interactionrequests + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/paging" +) + +// InteractionRequestsGETHandler swagger:operation GET /api/v1/interaction_requests getInteractionRequests +// +// Get an array of interactions requested on your statuses by other accounts, and pending your approval. +// +// ``` +// <https://example.org/api/v1/interaction_requests?limit=80&max_id=01FC0SKA48HNSVR6YKZCQGS2V8>; rel="next", <https://example.org/api/v1/interaction_requests?limit=80&min_id=01FC0SKW5JK2Q4EVAV2B462YY0>; rel="prev" +// ```` +// +// --- +// tags: +// - interaction_requests +// +// produces: +// - application/json +// +// parameters: +// - +// name: status_id +// type: string +// description: >- +// If set, then only interactions targeting the given status_id will be included in the results. +// in: query +// required: false +// - +// name: favourites +// type: boolean +// description: >- +// If true or not set, pending favourites will be included in the results. +// At least one of favourites, replies, and reblogs must be true. +// in: query +// required: false +// default: true +// - +// name: replies +// type: boolean +// description: >- +// If true or not set, pending replies will be included in the results. +// At least one of favourites, replies, and reblogs must be true. +// in: query +// required: false +// default: true +// - +// name: reblogs +// type: boolean +// description: >- +// If true or not set, pending reblogs will be included in the results. +// At least one of favourites, replies, and reblogs must be true. +// in: query +// required: false +// default: true +// - +// name: max_id +// type: string +// description: >- +// Return only interaction requests *OLDER* than the given max ID. +// The interaction with the specified ID will not be included in the response. +// in: query +// required: false +// - +// name: since_id +// type: string +// description: >- +// Return only interaction requests *NEWER* than the given since ID. +// The interaction with the specified ID will not be included in the response. +// in: query +// required: false +// - +// name: min_id +// type: string +// description: >- +// Return only interaction requests *IMMEDIATELY NEWER* than the given min ID. +// The interaction with the specified ID will not be included in the response. +// in: query +// required: false +// - +// name: limit +// type: integer +// description: Number of interaction requests to return. +// default: 40 +// minimum: 1 +// maximum: 80 +// in: query +// required: false +// +// security: +// - OAuth2 Bearer: +// - read:notifications +// +// responses: +// '200': +// headers: +// Link: +// type: string +// description: Links to the next and previous queries. +// schema: +// type: array +// items: +// "$ref": "#/definitions/interactionRequest" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) InteractionRequestsGETHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), 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 + } + + includeLikes, errWithCode := apiutil.ParseInteractionFavourites( + c.Query(apiutil.InteractionFavouritesKey), true, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + includeReplies, errWithCode := apiutil.ParseInteractionReplies( + c.Query(apiutil.InteractionRepliesKey), true, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + includeBoosts, errWithCode := apiutil.ParseInteractionReblogs( + c.Query(apiutil.InteractionReblogsKey), true, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + if !includeLikes && !includeReplies && !includeBoosts { + const text = "at least one of favourites, replies, or boosts must be true" + errWithCode := gtserror.NewErrorBadRequest(errors.New(text), text) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + page, errWithCode := paging.ParseIDPage(c, + 1, // min limit + 80, // max limit + 40, // default limit + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + resp, errWithCode := m.processor.InteractionRequests().GetPage( + c.Request.Context(), + authed.Account, + c.Query(apiutil.InteractionStatusIDKey), + includeLikes, + includeReplies, + includeBoosts, + page, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + if resp.LinkHeader != "" { + c.Header("Link", resp.LinkHeader) + } + + apiutil.JSON(c, http.StatusOK, resp.Items) +} diff --git a/internal/api/client/interactionrequests/interactionrequests.go b/internal/api/client/interactionrequests/interactionrequests.go new file mode 100644 index 000000000..172951817 --- /dev/null +++ b/internal/api/client/interactionrequests/interactionrequests.go @@ -0,0 +1,50 @@ +// 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 interactionrequests + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/processing" +) + +const ( + BasePath = "/v1/interaction_requests" + BasePathWithID = BasePath + "/:" + apiutil.IDKey + AuthorizePath = BasePathWithID + "/authorize" + RejectPath = BasePathWithID + "/reject" +) + +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.InteractionRequestsGETHandler) + attachHandler(http.MethodGet, BasePathWithID, m.InteractionRequestGETHandler) + attachHandler(http.MethodPost, AuthorizePath, m.InteractionRequestAuthorizePOSTHandler) + attachHandler(http.MethodPost, RejectPath, m.InteractionRequestRejectPOSTHandler) +} diff --git a/internal/api/client/interactionrequests/reject.go b/internal/api/client/interactionrequests/reject.go new file mode 100644 index 000000000..33c426462 --- /dev/null +++ b/internal/api/client/interactionrequests/reject.go @@ -0,0 +1,104 @@ +// 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 interactionrequests + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// InteractionRequestRejectPOSTHandler swagger:operation POST /api/v1/interaction_requests/{id}/reject rejectInteractionRequest +// +// Reject an interaction request with the given ID. +// +// --- +// tags: +// - interaction_requests +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: string +// description: ID of the interaction request targeting you. +// in: path +// required: true +// +// security: +// - OAuth2 Bearer: +// - write:statuses +// +// responses: +// '200': +// name: Rejection. +// description: The now-rejected interaction request. +// schema: +// "$ref": "#/definitions/interactionRequest" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) InteractionRequestRejectPOSTHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + errWithCode := gtserror.NewErrorUnauthorized(err, err.Error()) + 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 { + errWithCode := gtserror.NewErrorNotAcceptable(err, err.Error()) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + reqID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiReq, errWithCode := m.processor.InteractionRequests().Reject( + c.Request.Context(), + authed.Account, + reqID, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusOK, apiReq) +} diff --git a/internal/api/model/interaction.go b/internal/api/model/interaction.go new file mode 100644 index 000000000..b0543ce6b --- /dev/null +++ b/internal/api/model/interaction.go @@ -0,0 +1,46 @@ +// 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 model + +// InteractionRequest represents a pending, approved, or rejected interaction of type favourite, reply, or reblog. +// +// swagger:model interactionRequest +type InteractionRequest struct { + // The id of the interaction request in the database. + ID string `json:"id"` + // The type of interaction that this interaction request pertains to. + // + // `favourite` - Someone favourited a status. + // `reply` - Someone replied to a status. + // `reblog` - Someone reblogged / boosted a status. + Type string `json:"type"` + // The timestamp of the interaction request (ISO 8601 Datetime) + CreatedAt string `json:"created_at"` + // The account that performed the interaction. + Account *Account `json:"account"` + // Status targeted by the requested interaction. + Status *Status `json:"status"` + // If type=reply, this field will be set to the reply that is awaiting approval. If type=favourite, or type=reblog, the field will be omitted. + Reply *Status `json:"reply,omitempty"` + // The timestamp that the interaction request was accepted (ISO 8601 Datetime). Field omitted if request not accepted (yet). + AcceptedAt string `json:"accepted_at,omitempty"` + // The timestamp that the interaction request was rejected (ISO 8601 Datetime). Field omitted if request not rejected (yet). + RejectedAt string `json:"rejected_at,omitempty"` + // URI of the Accept or Reject. Only set if accepted_at or rejected_at is set, else omitted. + URI string `json:"uri,omitempty"` +} diff --git a/internal/api/util/parsequery.go b/internal/api/util/parsequery.go index 90cc30e6f..024ec028b 100644 --- a/internal/api/util/parsequery.go +++ b/internal/api/util/parsequery.go @@ -91,6 +91,13 @@ const ( AdminPermissionsKey = "permissions" AdminRoleIDsKey = "role_ids[]" AdminInvitedByKey = "invited_by" + + /* Interaction policy + request keys */ + + InteractionStatusIDKey = "status_id" + InteractionFavouritesKey = "favourites" + InteractionRepliesKey = "replies" + InteractionReblogsKey = "reblogs" ) /* @@ -194,6 +201,18 @@ func ParseAdminStaff(value string, defaultValue bool) (bool, gtserror.WithCode) return parseBool(value, defaultValue, AdminStaffKey) } +func ParseInteractionFavourites(value string, defaultValue bool) (bool, gtserror.WithCode) { + return parseBool(value, defaultValue, InteractionFavouritesKey) +} + +func ParseInteractionReplies(value string, defaultValue bool) (bool, gtserror.WithCode) { + return parseBool(value, defaultValue, InteractionRepliesKey) +} + +func ParseInteractionReblogs(value string, defaultValue bool) (bool, gtserror.WithCode) { + return parseBool(value, defaultValue, InteractionReblogsKey) +} + /* Parse functions for *REQUIRED* parameters. */ diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 2949d528a..f1c382d11 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -81,7 +81,7 @@ func (c *Caches) Init() { c.initFollowRequestIDs() c.initInReplyToIDs() c.initInstance() - c.initInteractionApproval() + c.initInteractionRequest() c.initList() c.initListEntry() c.initMarker() @@ -158,7 +158,7 @@ func (c *Caches) Sweep(threshold float64) { c.DB.FollowRequestIDs.Trim(threshold) c.DB.InReplyToIDs.Trim(threshold) c.DB.Instance.Trim(threshold) - c.DB.InteractionApproval.Trim(threshold) + c.DB.InteractionRequest.Trim(threshold) c.DB.List.Trim(threshold) c.DB.ListEntry.Trim(threshold) c.DB.Marker.Trim(threshold) diff --git a/internal/cache/db.go b/internal/cache/db.go index c1b87ef96..5e86c92a2 100644 --- a/internal/cache/db.go +++ b/internal/cache/db.go @@ -106,8 +106,8 @@ type DBCaches struct { // Instance provides access to the gtsmodel Instance database cache. Instance StructCache[*gtsmodel.Instance] - // InteractionApproval provides access to the gtsmodel InteractionApproval database cache. - InteractionApproval StructCache[*gtsmodel.InteractionApproval] + // InteractionRequest provides access to the gtsmodel InteractionRequest database cache. + InteractionRequest StructCache[*gtsmodel.InteractionRequest] // InReplyToIDs provides access to the status in reply to IDs list database cache. InReplyToIDs SliceCache[string] @@ -802,31 +802,36 @@ func (c *Caches) initInstance() { }) } -func (c *Caches) initInteractionApproval() { +func (c *Caches) initInteractionRequest() { // Calculate maximum cache size. cap := calculateResultCacheMax( - sizeofInteractionApproval(), - config.GetCacheInteractionApprovalMemRatio(), + sizeofInteractionRequest(), + config.GetCacheInteractionRequestMemRatio(), ) log.Infof(nil, "cache size = %d", cap) - copyF := func(i1 *gtsmodel.InteractionApproval) *gtsmodel.InteractionApproval { - i2 := new(gtsmodel.InteractionApproval) + copyF := func(i1 *gtsmodel.InteractionRequest) *gtsmodel.InteractionRequest { + i2 := new(gtsmodel.InteractionRequest) *i2 = *i1 // Don't include ptr fields that // will be populated separately. // See internal/db/bundb/interaction.go. - i2.Account = nil + i2.Status = nil + i2.TargetAccount = nil i2.InteractingAccount = nil + i2.Like = nil + i2.Reply = nil + i2.Announce = nil return i2 } - c.DB.InteractionApproval.Init(structr.CacheConfig[*gtsmodel.InteractionApproval]{ + c.DB.InteractionRequest.Init(structr.CacheConfig[*gtsmodel.InteractionRequest]{ Indices: []structr.IndexConfig{ {Fields: "ID"}, + {Fields: "InteractionURI"}, {Fields: "URI"}, }, MaxSize: cap, diff --git a/internal/cache/size.go b/internal/cache/size.go index 4c474fa28..29ab77fbf 100644 --- a/internal/cache/size.go +++ b/internal/cache/size.go @@ -190,7 +190,7 @@ func totalOfRatios() float64 { config.GetCacheFollowRequestMemRatio() + config.GetCacheFollowRequestIDsMemRatio() + config.GetCacheInstanceMemRatio() + - config.GetCacheInteractionApprovalMemRatio() + + config.GetCacheInteractionRequestMemRatio() + config.GetCacheInReplyToIDsMemRatio() + config.GetCacheListMemRatio() + config.GetCacheListEntryMemRatio() + @@ -441,16 +441,17 @@ func sizeofInstance() uintptr { })) } -func sizeofInteractionApproval() uintptr { - return uintptr(size.Of(>smodel.InteractionApproval{ +func sizeofInteractionRequest() uintptr { + return uintptr(size.Of(>smodel.InteractionRequest{ ID: exampleID, CreatedAt: exampleTime, - UpdatedAt: exampleTime, - AccountID: exampleID, + StatusID: exampleID, + TargetAccountID: exampleID, InteractingAccountID: exampleID, InteractionURI: exampleURI, InteractionType: gtsmodel.InteractionAnnounce, URI: exampleURI, + AcceptedAt: exampleTime, })) } diff --git a/internal/config/config.go b/internal/config/config.go index 4bb9be0c4..d6b8f0a54 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -218,7 +218,7 @@ type CacheConfiguration struct { FollowRequestIDsMemRatio float64 `name:"follow-request-ids-mem-ratio"` InReplyToIDsMemRatio float64 `name:"in-reply-to-ids-mem-ratio"` InstanceMemRatio float64 `name:"instance-mem-ratio"` - InteractionApprovalMemRatio float64 `name:"interaction-approval-mem-ratio"` + InteractionRequestMemRatio float64 `name:"interaction-request-mem-ratio"` ListMemRatio float64 `name:"list-mem-ratio"` ListEntryMemRatio float64 `name:"list-entry-mem-ratio"` MarkerMemRatio float64 `name:"marker-mem-ratio"` diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 3ef988f06..e71711cb3 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -181,7 +181,7 @@ var Defaults = Configuration{ FollowRequestIDsMemRatio: 2, InReplyToIDsMemRatio: 3, InstanceMemRatio: 1, - InteractionApprovalMemRatio: 1, + InteractionRequestMemRatio: 1, ListMemRatio: 1, ListEntryMemRatio: 2, MarkerMemRatio: 0.5, diff --git a/internal/config/helpers.gen.go b/internal/config/helpers.gen.go index f176676e5..d19e4e241 100644 --- a/internal/config/helpers.gen.go +++ b/internal/config/helpers.gen.go @@ -3412,32 +3412,30 @@ func GetCacheInstanceMemRatio() float64 { return global.GetCacheInstanceMemRatio // SetCacheInstanceMemRatio safely sets the value for global configuration 'Cache.InstanceMemRatio' field func SetCacheInstanceMemRatio(v float64) { global.SetCacheInstanceMemRatio(v) } -// GetCacheInteractionApprovalMemRatio safely fetches the Configuration value for state's 'Cache.InteractionApprovalMemRatio' field -func (st *ConfigState) GetCacheInteractionApprovalMemRatio() (v float64) { +// GetCacheInteractionRequestMemRatio safely fetches the Configuration value for state's 'Cache.InteractionRequestMemRatio' field +func (st *ConfigState) GetCacheInteractionRequestMemRatio() (v float64) { st.mutex.RLock() - v = st.config.Cache.InteractionApprovalMemRatio + v = st.config.Cache.InteractionRequestMemRatio st.mutex.RUnlock() return } -// SetCacheInteractionApprovalMemRatio safely sets the Configuration value for state's 'Cache.InteractionApprovalMemRatio' field -func (st *ConfigState) SetCacheInteractionApprovalMemRatio(v float64) { +// SetCacheInteractionRequestMemRatio safely sets the Configuration value for state's 'Cache.InteractionRequestMemRatio' field +func (st *ConfigState) SetCacheInteractionRequestMemRatio(v float64) { st.mutex.Lock() defer st.mutex.Unlock() - st.config.Cache.InteractionApprovalMemRatio = v + st.config.Cache.InteractionRequestMemRatio = v st.reloadToViper() } -// CacheInteractionApprovalMemRatioFlag returns the flag name for the 'Cache.InteractionApprovalMemRatio' field -func CacheInteractionApprovalMemRatioFlag() string { return "cache-interaction-approval-mem-ratio" } +// CacheInteractionRequestMemRatioFlag returns the flag name for the 'Cache.InteractionRequestMemRatio' field +func CacheInteractionRequestMemRatioFlag() string { return "cache-interaction-request-mem-ratio" } -// GetCacheInteractionApprovalMemRatio safely fetches the value for global configuration 'Cache.InteractionApprovalMemRatio' field -func GetCacheInteractionApprovalMemRatio() float64 { - return global.GetCacheInteractionApprovalMemRatio() -} +// GetCacheInteractionRequestMemRatio safely fetches the value for global configuration 'Cache.InteractionRequestMemRatio' field +func GetCacheInteractionRequestMemRatio() float64 { return global.GetCacheInteractionRequestMemRatio() } -// SetCacheInteractionApprovalMemRatio safely sets the value for global configuration 'Cache.InteractionApprovalMemRatio' field -func SetCacheInteractionApprovalMemRatio(v float64) { global.SetCacheInteractionApprovalMemRatio(v) } +// SetCacheInteractionRequestMemRatio safely sets the value for global configuration 'Cache.InteractionRequestMemRatio' field +func SetCacheInteractionRequestMemRatio(v float64) { global.SetCacheInteractionRequestMemRatio(v) } // GetCacheListMemRatio safely fetches the Configuration value for state's 'Cache.ListMemRatio' field func (st *ConfigState) GetCacheListMemRatio() (v float64) { diff --git a/internal/db/bundb/account.go b/internal/db/bundb/account.go index 94fd054bf..b57dcb57b 100644 --- a/internal/db/bundb/account.go +++ b/internal/db/bundb/account.go @@ -1285,34 +1285,40 @@ func (a *accountDB) RegenerateAccountStats(ctx context.Context, account *gtsmode if err := a.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { var err error - // Scan database for account statuses. + // Scan database for account statuses, ignoring + // statuses that are currently pending approval. statusesCount, err := tx.NewSelect(). - Table("statuses"). - Where("? = ?", bun.Ident("account_id"), account.ID). + TableExpr("? AS ?", bun.Ident("statuses"), bun.Ident("status")). + Where("? = ?", bun.Ident("status.account_id"), account.ID). + Where("NOT ? = ?", bun.Ident("status.pending_approval"), true). Count(ctx) if err != nil { return err } stats.StatusesCount = &statusesCount - // Scan database for pinned statuses. + // Scan database for pinned statuses, ignoring + // statuses that are currently pending approval. statusesPinnedCount, err := tx.NewSelect(). - Table("statuses"). - Where("? = ?", bun.Ident("account_id"), account.ID). - Where("? IS NOT NULL", bun.Ident("pinned_at")). + TableExpr("? AS ?", bun.Ident("statuses"), bun.Ident("status")). + Where("? = ?", bun.Ident("status.account_id"), account.ID). + Where("? IS NOT NULL", bun.Ident("status.pinned_at")). + Where("NOT ? = ?", bun.Ident("status.pending_approval"), true). Count(ctx) if err != nil { return err } stats.StatusesPinnedCount = &statusesPinnedCount - // Scan database for last status. + // Scan database for last status, ignoring + // statuses that are currently pending approval. lastStatusAt := time.Time{} err = tx. NewSelect(). TableExpr("? AS ?", bun.Ident("statuses"), bun.Ident("status")). Column("status.created_at"). Where("? = ?", bun.Ident("status.account_id"), account.ID). + Where("NOT ? = ?", bun.Ident("status.pending_approval"), true). Order("status.id DESC"). Limit(1). Scan(ctx, &lastStatusAt) diff --git a/internal/db/bundb/basic_test.go b/internal/db/bundb/basic_test.go index f6647c1f5..56159dc25 100644 --- a/internal/db/bundb/basic_test.go +++ b/internal/db/bundb/basic_test.go @@ -114,7 +114,7 @@ func (suite *BasicTestSuite) TestGetAllStatuses() { s := []*gtsmodel.Status{} err := suite.db.GetAll(context.Background(), &s) suite.NoError(err) - suite.Len(s, 24) + suite.Len(s, 25) } func (suite *BasicTestSuite) TestGetAllNotNull() { diff --git a/internal/db/bundb/bundb_test.go b/internal/db/bundb/bundb_test.go index adfd605a5..e976199e4 100644 --- a/internal/db/bundb/bundb_test.go +++ b/internal/db/bundb/bundb_test.go @@ -34,28 +34,29 @@ type BunDBStandardTestSuite struct { state state.State // standard suite models - testTokens map[string]*gtsmodel.Token - testClients map[string]*gtsmodel.Client - testApplications map[string]*gtsmodel.Application - testUsers map[string]*gtsmodel.User - testAccounts map[string]*gtsmodel.Account - testAttachments map[string]*gtsmodel.MediaAttachment - testStatuses map[string]*gtsmodel.Status - testTags map[string]*gtsmodel.Tag - testMentions map[string]*gtsmodel.Mention - testFollows map[string]*gtsmodel.Follow - testEmojis map[string]*gtsmodel.Emoji - testReports map[string]*gtsmodel.Report - testBookmarks map[string]*gtsmodel.StatusBookmark - testFaves map[string]*gtsmodel.StatusFave - testLists map[string]*gtsmodel.List - testListEntries map[string]*gtsmodel.ListEntry - testAccountNotes map[string]*gtsmodel.AccountNote - testMarkers map[string]*gtsmodel.Marker - testRules map[string]*gtsmodel.Rule - testThreads map[string]*gtsmodel.Thread - testPolls map[string]*gtsmodel.Poll - testPollVotes map[string]*gtsmodel.PollVote + testTokens map[string]*gtsmodel.Token + testClients map[string]*gtsmodel.Client + testApplications map[string]*gtsmodel.Application + testUsers map[string]*gtsmodel.User + testAccounts map[string]*gtsmodel.Account + testAttachments map[string]*gtsmodel.MediaAttachment + testStatuses map[string]*gtsmodel.Status + testTags map[string]*gtsmodel.Tag + testMentions map[string]*gtsmodel.Mention + testFollows map[string]*gtsmodel.Follow + testEmojis map[string]*gtsmodel.Emoji + testReports map[string]*gtsmodel.Report + testBookmarks map[string]*gtsmodel.StatusBookmark + testFaves map[string]*gtsmodel.StatusFave + testLists map[string]*gtsmodel.List + testListEntries map[string]*gtsmodel.ListEntry + testAccountNotes map[string]*gtsmodel.AccountNote + testMarkers map[string]*gtsmodel.Marker + testRules map[string]*gtsmodel.Rule + testThreads map[string]*gtsmodel.Thread + testPolls map[string]*gtsmodel.Poll + testPollVotes map[string]*gtsmodel.PollVote + testInteractionRequests map[string]*gtsmodel.InteractionRequest } func (suite *BunDBStandardTestSuite) SetupSuite() { @@ -81,6 +82,7 @@ func (suite *BunDBStandardTestSuite) SetupSuite() { suite.testThreads = testrig.NewTestThreads() suite.testPolls = testrig.NewTestPolls() suite.testPollVotes = testrig.NewTestPollVotes() + suite.testInteractionRequests = testrig.NewTestInteractionRequests() } func (suite *BunDBStandardTestSuite) SetupTest() { diff --git a/internal/db/bundb/instance.go b/internal/db/bundb/instance.go index 6d98c8db7..008b6c8f3 100644 --- a/internal/db/bundb/instance.go +++ b/internal/db/bundb/instance.go @@ -76,6 +76,9 @@ func (i *instanceDB) CountInstanceStatuses(ctx context.Context, domain string) ( Where("? = ?", bun.Ident("account.domain"), domain) } + // Ignore statuses that are currently pending approval. + q = q.Where("NOT ? = ?", bun.Ident("status.pending_approval"), true) + count, err := q.Count(ctx) if err != nil { return 0, err diff --git a/internal/db/bundb/interaction.go b/internal/db/bundb/interaction.go index 2ded70311..78abcc763 100644 --- a/internal/db/bundb/interaction.go +++ b/internal/db/bundb/interaction.go @@ -19,10 +19,14 @@ package bundb import ( "context" + "errors" + "slices" + "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtscontext" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/paging" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/uptrace/bun" ) @@ -32,56 +36,70 @@ type interactionDB struct { state *state.State } -func (r *interactionDB) newInteractionApprovalQ(approval interface{}) *bun.SelectQuery { - return r.db. +func (i *interactionDB) newInteractionRequestQ(request interface{}) *bun.SelectQuery { + return i.db. NewSelect(). - Model(approval) + Model(request) } -func (r *interactionDB) GetInteractionApprovalByID(ctx context.Context, id string) (*gtsmodel.InteractionApproval, error) { - return r.getInteractionApproval( +func (i *interactionDB) GetInteractionRequestByID(ctx context.Context, id string) (*gtsmodel.InteractionRequest, error) { + return i.getInteractionRequest( ctx, "ID", - func(approval *gtsmodel.InteractionApproval) error { - return r. - newInteractionApprovalQ(approval). - Where("? = ?", bun.Ident("interaction_approval.id"), id). + func(request *gtsmodel.InteractionRequest) error { + return i. + newInteractionRequestQ(request). + Where("? = ?", bun.Ident("interaction_request.id"), id). Scan(ctx) }, id, ) } -func (r *interactionDB) GetInteractionApprovalByURI(ctx context.Context, uri string) (*gtsmodel.InteractionApproval, error) { - return r.getInteractionApproval( +func (i *interactionDB) GetInteractionRequestByInteractionURI(ctx context.Context, uri string) (*gtsmodel.InteractionRequest, error) { + return i.getInteractionRequest( + ctx, + "InteractionURI", + func(request *gtsmodel.InteractionRequest) error { + return i. + newInteractionRequestQ(request). + Where("? = ?", bun.Ident("interaction_request.interaction_uri"), uri). + Scan(ctx) + }, + uri, + ) +} + +func (i *interactionDB) GetInteractionRequestByURI(ctx context.Context, uri string) (*gtsmodel.InteractionRequest, error) { + return i.getInteractionRequest( ctx, "URI", - func(approval *gtsmodel.InteractionApproval) error { - return r. - newInteractionApprovalQ(approval). - Where("? = ?", bun.Ident("interaction_approval.uri"), uri). + func(request *gtsmodel.InteractionRequest) error { + return i. + newInteractionRequestQ(request). + Where("? = ?", bun.Ident("interaction_request.uri"), uri). Scan(ctx) }, uri, ) } -func (r *interactionDB) getInteractionApproval( +func (i *interactionDB) getInteractionRequest( ctx context.Context, lookup string, - dbQuery func(*gtsmodel.InteractionApproval) error, + dbQuery func(*gtsmodel.InteractionRequest) error, keyParts ...any, -) (*gtsmodel.InteractionApproval, error) { - // Fetch approval from database cache with loader callback - approval, err := r.state.Caches.DB.InteractionApproval.LoadOne(lookup, func() (*gtsmodel.InteractionApproval, error) { - var approval gtsmodel.InteractionApproval +) (*gtsmodel.InteractionRequest, error) { + // Fetch request from database cache with loader callback + request, err := i.state.Caches.DB.InteractionRequest.LoadOne(lookup, func() (*gtsmodel.InteractionRequest, error) { + var request gtsmodel.InteractionRequest // Not cached! Perform database query - if err := dbQuery(&approval); err != nil { + if err := dbQuery(&request); err != nil { return nil, err } - return &approval, nil + return &request, nil }, keyParts...) if err != nil { // Error already processed. @@ -90,60 +108,241 @@ func (r *interactionDB) getInteractionApproval( if gtscontext.Barebones(ctx) { // Only a barebones model was requested. - return approval, nil + return request, nil } - if err := r.PopulateInteractionApproval(ctx, approval); err != nil { + if err := i.PopulateInteractionRequest(ctx, request); err != nil { return nil, err } - return approval, nil + return request, nil } -func (r *interactionDB) PopulateInteractionApproval(ctx context.Context, approval *gtsmodel.InteractionApproval) error { +func (i *interactionDB) PopulateInteractionRequest(ctx context.Context, req *gtsmodel.InteractionRequest) error { var ( err error - errs = gtserror.NewMultiError(2) + errs = gtserror.NewMultiError(4) ) - if approval.Account == nil { - // Account is not set, fetch from the database. - approval.Account, err = r.state.DB.GetAccountByID( + if req.Status == nil { + // Target status is not set, fetch from the database. + req.Status, err = i.state.DB.GetStatusByID( + gtscontext.SetBarebones(ctx), + req.StatusID, + ) + if err != nil { + errs.Appendf("error populating interactionRequest target: %w", err) + } + } + + if req.TargetAccount == nil { + // Target account is not set, fetch from the database. + req.TargetAccount, err = i.state.DB.GetAccountByID( gtscontext.SetBarebones(ctx), - approval.AccountID, + req.TargetAccountID, ) if err != nil { - errs.Appendf("error populating interactionApproval account: %w", err) + errs.Appendf("error populating interactionRequest target account: %w", err) } } - if approval.InteractingAccount == nil { + if req.InteractingAccount == nil { // InteractingAccount is not set, fetch from the database. - approval.InteractingAccount, err = r.state.DB.GetAccountByID( + req.InteractingAccount, err = i.state.DB.GetAccountByID( gtscontext.SetBarebones(ctx), - approval.InteractingAccountID, + req.InteractingAccountID, ) if err != nil { - errs.Appendf("error populating interactionApproval interacting account: %w", err) + errs.Appendf("error populating interactionRequest interacting account: %w", err) + } + } + + // Depending on the interaction type, *try* to populate + // the related model, but don't error if this is not + // possible, as it may have just already been deleted + // by its owner and we haven't cleaned up yet. + switch req.InteractionType { + + case gtsmodel.InteractionLike: + req.Like, err = i.state.DB.GetStatusFaveByURI(ctx, req.InteractionURI) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + errs.Appendf("error populating interactionRequest Like: %w", err) + } + + case gtsmodel.InteractionReply: + req.Reply, err = i.state.DB.GetStatusByURI(ctx, req.InteractionURI) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + errs.Appendf("error populating interactionRequest Reply: %w", err) + } + + case gtsmodel.InteractionAnnounce: + req.Announce, err = i.state.DB.GetStatusByURI(ctx, req.InteractionURI) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + errs.Appendf("error populating interactionRequest Announce: %w", err) } } return errs.Combine() } -func (r *interactionDB) PutInteractionApproval(ctx context.Context, approval *gtsmodel.InteractionApproval) error { - return r.state.Caches.DB.InteractionApproval.Store(approval, func() error { - _, err := r.db.NewInsert().Model(approval).Exec(ctx) +func (i *interactionDB) PutInteractionRequest(ctx context.Context, request *gtsmodel.InteractionRequest) error { + return i.state.Caches.DB.InteractionRequest.Store(request, func() error { + _, err := i.db.NewInsert().Model(request).Exec(ctx) return err }) } -func (r *interactionDB) DeleteInteractionApprovalByID(ctx context.Context, id string) error { - defer r.state.Caches.DB.InteractionApproval.Invalidate("ID", id) +func (i *interactionDB) UpdateInteractionRequest(ctx context.Context, request *gtsmodel.InteractionRequest, columns ...string) error { + return i.state.Caches.DB.InteractionRequest.Store(request, func() error { + _, err := i.db. + NewUpdate(). + Model(request). + Where("? = ?", bun.Ident("interaction_request.id"), request.ID). + Column(columns...). + Exec(ctx) + return err + }) +} + +func (i *interactionDB) DeleteInteractionRequestByID(ctx context.Context, id string) error { + defer i.state.Caches.DB.InteractionRequest.Invalidate("ID", id) - _, err := r.db.NewDelete(). - TableExpr("? AS ?", bun.Ident("interaction_approvals"), bun.Ident("interaction_approval")). - Where("? = ?", bun.Ident("interaction_approval.id"), id). + _, err := i.db.NewDelete(). + TableExpr("? AS ?", bun.Ident("interaction_requests"), bun.Ident("interaction_request")). + Where("? = ?", bun.Ident("interaction_request.id"), id). Exec(ctx) return err } + +func (i *interactionDB) GetInteractionsRequestsForAcct( + ctx context.Context, + acctID string, + statusID string, + likes bool, + replies bool, + boosts bool, + page *paging.Page, +) ([]*gtsmodel.InteractionRequest, error) { + if !likes && !replies && !boosts { + return nil, gtserror.New("at least one of likes, replies, or boosts must be true") + } + + var ( + // Get paging params. + minID = page.GetMin() + maxID = page.GetMax() + limit = page.GetLimit() + order = page.GetOrder() + + // Make educated guess for slice size + reqIDs = make([]string, 0, limit) + ) + + // Create the basic select query. + q := i.db. + NewSelect(). + Column("id"). + TableExpr( + "? AS ?", + bun.Ident("interaction_requests"), + bun.Ident("interaction_request"), + ). + // Select only interaction requests that + // are neither accepted or rejected yet, + // ie., without an Accept or Reject URI. + Where("? IS NULL", bun.Ident("uri")) + + // Select interactions targeting status. + if statusID != "" { + q = q.Where("? = ?", bun.Ident("status_id"), statusID) + } + + // Select interactions targeting account. + if acctID != "" { + q = q.Where("? = ?", bun.Ident("target_account_id"), acctID) + } + + // Figure out which types of interaction are + // being sought, and add them to the query. + wantTypes := make([]gtsmodel.InteractionType, 0, 3) + if likes { + wantTypes = append(wantTypes, gtsmodel.InteractionLike) + } + if replies { + wantTypes = append(wantTypes, gtsmodel.InteractionReply) + } + if boosts { + wantTypes = append(wantTypes, gtsmodel.InteractionAnnounce) + } + q = q.Where("? IN (?)", bun.Ident("interaction_type"), bun.In(wantTypes)) + + // 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, &reqIDs) + if err != nil { + return nil, err + } + + // Catch case of no items early + if len(reqIDs) == 0 { + return nil, db.ErrNoEntries + } + + // If we're paging up, we still want interactions + // to be sorted by ID desc, so reverse ids slice. + if order == paging.OrderAscending { + slices.Reverse(reqIDs) + } + + // For each interaction request ID, + // select the interaction request. + reqs := make([]*gtsmodel.InteractionRequest, 0, len(reqIDs)) + for _, id := range reqIDs { + req, err := i.GetInteractionRequestByID(ctx, id) + if err != nil { + return nil, err + } + + reqs = append(reqs, req) + } + + return reqs, nil +} + +func (i *interactionDB) IsInteractionRejected(ctx context.Context, interactionURI string) (bool, error) { + req, err := i.GetInteractionRequestByInteractionURI(ctx, interactionURI) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return false, gtserror.Newf("db error getting interaction request: %w", err) + } + + if req == nil { + // No interaction req at all with this + // interactionURI so it can't be rejected. + return false, nil + } + + return req.IsRejected(), nil +} diff --git a/internal/db/bundb/interaction_test.go b/internal/db/bundb/interaction_test.go new file mode 100644 index 000000000..37684f18c --- /dev/null +++ b/internal/db/bundb/interaction_test.go @@ -0,0 +1,261 @@ +// 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_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" + "github.com/superseriousbusiness/gotosocial/internal/paging" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +type InteractionTestSuite struct { + BunDBStandardTestSuite +} + +func (suite *InteractionTestSuite) markInteractionsPending( + ctx context.Context, + statusID string, +) (pendingCount int) { + // Get replies of given status. + replies, err := suite.state.DB.GetStatusReplies(ctx, statusID) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + suite.FailNow(err.Error()) + } + + // Mark each reply as pending approval. + for _, reply := range replies { + reply.PendingApproval = util.Ptr(true) + if err := suite.state.DB.UpdateStatus( + ctx, + reply, + "pending_approval", + ); err != nil { + suite.FailNow(err.Error()) + } + + // Put an interaction request + // in the DB for this reply. + req, err := typeutils.StatusToInteractionRequest(ctx, reply) + if err != nil { + suite.FailNow(err.Error()) + } + + if err := suite.state.DB.PutInteractionRequest(ctx, req); err != nil { + suite.FailNow(err.Error()) + } + + pendingCount++ + } + + // Get boosts of given status. + boosts, err := suite.state.DB.GetStatusBoosts(ctx, statusID) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + suite.FailNow(err.Error()) + } + + // Mark each boost as pending approval. + for _, boost := range boosts { + boost.PendingApproval = util.Ptr(true) + if err := suite.state.DB.UpdateStatus( + ctx, + boost, + "pending_approval", + ); err != nil { + suite.FailNow(err.Error()) + } + + // Put an interaction request + // in the DB for this boost. + req, err := typeutils.StatusToInteractionRequest(ctx, boost) + if err != nil { + suite.FailNow(err.Error()) + } + + if err := suite.state.DB.PutInteractionRequest(ctx, req); err != nil { + suite.FailNow(err.Error()) + } + + pendingCount++ + } + + // Get faves of given status. + faves, err := suite.state.DB.GetStatusFaves(ctx, statusID) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + suite.FailNow(err.Error()) + } + + // Mark each fave as pending approval. + for _, fave := range faves { + fave.PendingApproval = util.Ptr(true) + if err := suite.state.DB.UpdateStatusFave( + ctx, + fave, + "pending_approval", + ); err != nil { + suite.FailNow(err.Error()) + } + + // Put an interaction request + // in the DB for this fave. + req, err := typeutils.StatusFaveToInteractionRequest(ctx, fave) + if err != nil { + suite.FailNow(err.Error()) + } + + if err := suite.state.DB.PutInteractionRequest(ctx, req); err != nil { + suite.FailNow(err.Error()) + } + + pendingCount++ + } + + return pendingCount +} + +func (suite *InteractionTestSuite) TestGetPending() { + var ( + testStatus = suite.testStatuses["local_account_1_status_1"] + ctx = context.Background() + acctID = suite.testAccounts["local_account_1"].ID + statusID = "" + likes = true + replies = true + boosts = true + page = &paging.Page{ + Max: paging.MaxID(id.Highest), + Limit: 20, + } + ) + + // Update target test status to mark + // all interactions with it pending. + pendingCount := suite.markInteractionsPending(ctx, testStatus.ID) + + // Get pendingInts interactions. + pendingInts, err := suite.state.DB.GetInteractionsRequestsForAcct( + ctx, + acctID, + statusID, + likes, + replies, + boosts, + page, + ) + suite.NoError(err) + suite.Len(pendingInts, pendingCount) + + // Ensure relevant model populated. + for _, pendingInt := range pendingInts { + switch pendingInt.InteractionType { + + case gtsmodel.InteractionLike: + suite.NotNil(pendingInt.Like) + + case gtsmodel.InteractionReply: + suite.NotNil(pendingInt.Reply) + + case gtsmodel.InteractionAnnounce: + suite.NotNil(pendingInt.Announce) + } + } +} + +func (suite *InteractionTestSuite) TestGetPendingRepliesOnly() { + var ( + testStatus = suite.testStatuses["local_account_1_status_1"] + ctx = context.Background() + acctID = suite.testAccounts["local_account_1"].ID + statusID = "" + likes = false + replies = true + boosts = false + page = &paging.Page{ + Max: paging.MaxID(id.Highest), + Limit: 20, + } + ) + + // Update target test status to mark + // all interactions with it pending. + suite.markInteractionsPending(ctx, testStatus.ID) + + // Get pendingInts interactions. + pendingInts, err := suite.state.DB.GetInteractionsRequestsForAcct( + ctx, + acctID, + statusID, + likes, + replies, + boosts, + page, + ) + suite.NoError(err) + + // Ensure only replies returned. + for _, pendingInt := range pendingInts { + suite.Equal(gtsmodel.InteractionReply, pendingInt.InteractionType) + } +} + +func (suite *InteractionTestSuite) TestInteractionRejected() { + var ( + ctx = context.Background() + req = new(gtsmodel.InteractionRequest) + ) + + // Make a copy of the request we'll modify. + *req = *suite.testInteractionRequests["admin_account_reply_turtle"] + + // No rejection in the db for this interaction URI so it should be OK. + rejected, err := suite.state.DB.IsInteractionRejected(ctx, req.InteractionURI) + if err != nil { + suite.FailNow(err.Error()) + } + if rejected { + suite.FailNow("wanted rejected = false, got true") + } + + // Update the interaction request to mark it rejected. + req.RejectedAt = time.Now() + req.URI = "https://some.reject.uri" + if err := suite.state.DB.UpdateInteractionRequest(ctx, req, "uri", "rejected_at"); err != nil { + suite.FailNow(err.Error()) + } + + // Rejection in the db for this interaction URI now so it should be très mauvais. + rejected, err = suite.state.DB.IsInteractionRejected(ctx, req.InteractionURI) + if err != nil { + suite.FailNow(err.Error()) + } + if !rejected { + suite.FailNow("wanted rejected = true, got false") + } +} + +func TestInteractionTestSuite(t *testing.T) { + suite.Run(t, new(InteractionTestSuite)) +} diff --git a/internal/db/bundb/migrations/20240716151327_interaction_policy.go b/internal/db/bundb/migrations/20240716151327_interaction_policy.go index fb0d1d752..210d4b2c5 100644 --- a/internal/db/bundb/migrations/20240716151327_interaction_policy.go +++ b/internal/db/bundb/migrations/20240716151327_interaction_policy.go @@ -20,41 +20,12 @@ package migrations import ( "context" - gtsmodel "github.com/superseriousbusiness/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.InteractionApproval{}). - IfNotExists(). - Exec(ctx); err != nil { - return err - } - - if _, err := tx. - NewCreateIndex(). - Table("interaction_approvals"). - Index("interaction_approvals_account_id_idx"). - Column("account_id"). - IfNotExists(). - Exec(ctx); err != nil { - return err - } - - if _, err := tx. - NewCreateIndex(). - Table("interaction_approvals"). - Index("interaction_approvals_interacting_account_id_idx"). - Column("interacting_account_id"). - IfNotExists(). - Exec(ctx); err != nil { - return err - } - return nil }) } diff --git a/internal/db/bundb/migrations/20240809134448_interaction_requests_client_api.go b/internal/db/bundb/migrations/20240809134448_interaction_requests_client_api.go new file mode 100644 index 000000000..82c2b4016 --- /dev/null +++ b/internal/db/bundb/migrations/20240809134448_interaction_requests_client_api.go @@ -0,0 +1,154 @@ +// 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" + + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" + + "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 { + + // Drop interaction approvals table if it exists, + // ie., if instance was running on main between now + // and 2024-07-16. + // + // We might lose some interaction approvals this way, + // but since they weren't *really* used much yet this + // it's not a big deal, that's the running-on-main life! + if _, err := tx.NewDropTable(). + Table("interaction_approvals"). + IfExists(). + Exec(ctx); err != nil { + return err + } + + // Add `interaction_requests` + // table and new indexes. + if _, err := tx. + NewCreateTable(). + Model(>smodel.InteractionRequest{}). + IfNotExists(). + Exec(ctx); err != nil { + return err + } + + for idx, col := range map[string]string{ + "interaction_requests_status_id_idx": "status_id", + "interaction_requests_target_account_id_idx": "target_account_id", + "interaction_requests_interacting_account_id_idx": "interacting_account_id", + } { + if _, err := tx. + NewCreateIndex(). + Table("interaction_requests"). + Index(idx). + Column(col). + IfNotExists(). + Exec(ctx); err != nil { + return err + } + } + + // Select all pending statuses (replies or boosts). + pendingStatuses := []*gtsmodel.Status{} + err := tx. + NewSelect(). + Model(&pendingStatuses). + Column( + "created_at", + "in_reply_to_id", + "boost_of_id", + "in_reply_to_account_id", + "boost_of_account_id", + "account_id", + "uri", + ). + Where("? = ?", bun.Ident("pending_approval"), true). + Scan(ctx) + if err != nil { + return err + } + + // For each currently pending status, check whether it's a reply or + // a boost, and insert a corresponding interaction request into the db. + for _, pendingStatus := range pendingStatuses { + req, err := typeutils.StatusToInteractionRequest(ctx, pendingStatus) + if err != nil { + return err + } + + if _, err := tx. + NewInsert(). + Model(req). + Exec(ctx); err != nil { + return err + } + } + + // Now do the same thing for pending faves. + pendingFaves := []*gtsmodel.StatusFave{} + err = tx. + NewSelect(). + Model(&pendingFaves). + Column( + "created_at", + "status_id", + "target_account_id", + "account_id", + "uri", + ). + Where("? = ?", bun.Ident("pending_approval"), true). + Scan(ctx) + if err != nil { + return err + } + + for _, pendingFave := range pendingFaves { + req, err := typeutils.StatusFaveToInteractionRequest(ctx, pendingFave) + if err != nil { + return err + } + + if _, err := tx. + NewInsert(). + Model(req). + Exec(ctx); err != nil { + return err + } + } + + return nil + }) + } + + down := func(ctx context.Context, db *bun.DB) error { + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + return nil + }) + } + + if err := Migrations.Register(up, down); err != nil { + panic(err) + } +} diff --git a/internal/db/bundb/timeline.go b/internal/db/bundb/timeline.go index e6c7e482d..995c4e84f 100644 --- a/internal/db/bundb/timeline.go +++ b/internal/db/bundb/timeline.go @@ -89,19 +89,6 @@ func (t *timelineDB) GetHomeTimeline(ctx context.Context, accountID string, maxI q = q.Where("? = ?", bun.Ident("status.local"), local) } - if limit > 0 { - // limit amount of statuses returned - q = q.Limit(limit) - } - - if frontToBack { - // Page down. - q = q.Order("status.id DESC") - } else { - // Page up. - q = q.Order("status.id ASC") - } - // As this is the home timeline, it should be // populated by statuses from accounts followed // by accountID, and posts from accountID itself. @@ -137,6 +124,22 @@ func (t *timelineDB) GetHomeTimeline(ctx context.Context, accountID string, maxI bun.In(targetAccountIDs), ) + // Only include statuses that aren't pending approval. + q = q.Where("NOT ? = ?", bun.Ident("status.pending_approval"), true) + + if limit > 0 { + // limit amount of statuses returned + q = q.Limit(limit) + } + + if frontToBack { + // Page down. + q = q.Order("status.id DESC") + } else { + // Page up. + q = q.Order("status.id ASC") + } + if err := q.Scan(ctx, &statusIDs); err != nil { return nil, err } @@ -213,6 +216,9 @@ func (t *timelineDB) GetPublicTimeline(ctx context.Context, maxID string, sinceI q = q.Where("? = ?", bun.Ident("status.local"), local) } + // Only include statuses that aren't pending approval. + q = q.Where("NOT ? = ?", bun.Ident("status.pending_approval"), true) + if limit > 0 { // limit amount of statuses returned q = q.Limit(limit) @@ -395,6 +401,9 @@ func (t *timelineDB) GetListTimeline( frontToBack = false } + // Only include statuses that aren't pending approval. + q = q.Where("NOT ? = ?", bun.Ident("status.pending_approval"), true) + if limit > 0 { // limit amount of statuses returned q = q.Limit(limit) @@ -491,6 +500,9 @@ func (t *timelineDB) GetTagTimeline( frontToBack = false } + // Only include statuses that aren't pending approval. + q = q.Where("NOT ? = ?", bun.Ident("status.pending_approval"), true) + if limit > 0 { // limit amount of statuses returned q = q.Limit(limit) diff --git a/internal/db/interaction.go b/internal/db/interaction.go index 6f595c54e..a3a3afde9 100644 --- a/internal/db/interaction.go +++ b/internal/db/interaction.go @@ -21,21 +21,47 @@ import ( "context" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/paging" ) type Interaction interface { - // GetInteractionApprovalByID gets one approval with the given id. - GetInteractionApprovalByID(ctx context.Context, id string) (*gtsmodel.InteractionApproval, error) + // GetInteractionRequestByID gets one request with the given id. + GetInteractionRequestByID(ctx context.Context, id string) (*gtsmodel.InteractionRequest, error) - // GetInteractionApprovalByID gets one approval with the given uri. - GetInteractionApprovalByURI(ctx context.Context, id string) (*gtsmodel.InteractionApproval, error) + // GetInteractionRequestByID gets one request with the given interaction uri. + GetInteractionRequestByInteractionURI(ctx context.Context, uri string) (*gtsmodel.InteractionRequest, error) - // PopulateInteractionApproval ensures that the approval's struct fields are populated. - PopulateInteractionApproval(ctx context.Context, approval *gtsmodel.InteractionApproval) error + // GetInteractionRequestByURI returns one accepted or rejected + // interaction request with the given URI, if it exists in the db. + GetInteractionRequestByURI(ctx context.Context, uri string) (*gtsmodel.InteractionRequest, error) - // PutInteractionApproval puts a new approval in the database. - PutInteractionApproval(ctx context.Context, approval *gtsmodel.InteractionApproval) error + // PopulateInteractionRequest ensures that the request's struct fields are populated. + PopulateInteractionRequest(ctx context.Context, request *gtsmodel.InteractionRequest) error - // DeleteInteractionApprovalByID deletes one approval with the given ID. - DeleteInteractionApprovalByID(ctx context.Context, id string) error + // PutInteractionRequest puts a new request in the database. + PutInteractionRequest(ctx context.Context, request *gtsmodel.InteractionRequest) error + + // UpdateInteractionRequest updates the given interaction request. + UpdateInteractionRequest(ctx context.Context, request *gtsmodel.InteractionRequest, columns ...string) error + + // DeleteInteractionRequestByID deletes one request with the given ID. + DeleteInteractionRequestByID(ctx context.Context, id string) error + + // GetInteractionsRequestsForAcct returns pending interactions targeting + // the given (optional) account ID and the given (optional) status ID. + // + // At least one of `likes`, `replies`, or `boosts` must be true. + GetInteractionsRequestsForAcct( + ctx context.Context, + acctID string, + statusID string, + likes bool, + replies bool, + boosts bool, + page *paging.Page, + ) ([]*gtsmodel.InteractionRequest, error) + + // IsInteractionRejected returns true if an rejection exists in the database for an + // object with the given interactionURI (ie., a status or announce or fave uri). + IsInteractionRejected(ctx context.Context, interactionURI string) (bool, error) } diff --git a/internal/federation/federatingdb/accept.go b/internal/federation/federatingdb/accept.go index 60b9cfe58..8082e555f 100644 --- a/internal/federation/federatingdb/accept.go +++ b/internal/federation/federatingdb/accept.go @@ -38,11 +38,11 @@ func (f *federatingDB) GetAccept( ctx context.Context, acceptIRI *url.URL, ) (vocab.ActivityStreamsAccept, error) { - approval, err := f.state.DB.GetInteractionApprovalByURI(ctx, acceptIRI.String()) + approval, err := f.state.DB.GetInteractionRequestByURI(ctx, acceptIRI.String()) if err != nil { return nil, err } - return f.converter.InteractionApprovalToASAccept(ctx, approval) + return f.converter.InteractionReqToASAccept(ctx, approval) } func (f *federatingDB) Accept(ctx context.Context, accept vocab.ActivityStreamsAccept) error { diff --git a/internal/gtsmodel/interaction.go b/internal/gtsmodel/interaction.go new file mode 100644 index 000000000..562b752eb --- /dev/null +++ b/internal/gtsmodel/interaction.go @@ -0,0 +1,93 @@ +// 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" + +// Like / Reply / Announce +type InteractionType int + +const ( + // WARNING: DO NOT CHANGE THE ORDER OF THESE, + // as this will cause breakage of approvals! + // + // If you need to add new interaction types, + // add them *to the end* of the list. + + InteractionLike InteractionType = iota + InteractionReply + InteractionAnnounce +) + +// Stringifies this InteractionType in a +// manner suitable for serving via the API. +func (i InteractionType) String() string { + switch i { + case InteractionLike: + const text = "favourite" + return text + case InteractionReply: + const text = "reply" + return text + case InteractionAnnounce: + const text = "reblog" + return text + default: + panic("undefined InteractionType") + } +} + +// InteractionRequest represents one interaction (like, reply, fave) +// that is either accepted, rejected, or currently still awaiting +// acceptance or rejection by the target account. +type InteractionRequest struct { + ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database + CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created + StatusID string `bun:"type:CHAR(26),nullzero,notnull"` // ID of the interaction target status. + Status *Status `bun:"-"` // Not stored in DB. Status being interacted with. + TargetAccountID string `bun:"type:CHAR(26),nullzero,notnull"` // id of the account being interacted with + TargetAccount *Account `bun:"-"` // Not stored in DB. Account being interacted with. + InteractingAccountID string `bun:"type:CHAR(26),nullzero,notnull"` // id of the account requesting the interaction. + InteractingAccount *Account `bun:"-"` // Not stored in DB. Account corresponding to targetAccountID + InteractionURI string `bun:",nullzero,notnull,unique"` // URI of the interacting like, reply, or announce. Unique (only one interaction request allowed per interaction URI). + InteractionType InteractionType `bun:",notnull"` // One of Like, Reply, or Announce. + Like *StatusFave `bun:"-"` // Not stored in DB. Only set if InteractionType = InteractionLike. + Reply *Status `bun:"-"` // Not stored in DB. Only set if InteractionType = InteractionReply. + Announce *Status `bun:"-"` // Not stored in DB. Only set if InteractionType = InteractionAnnounce. + URI string `bun:",nullzero,unique"` // ActivityPub URI of the Accept (if accepted) or Reject (if rejected). Null/empty if currently neither accepted not rejected. + AcceptedAt time.Time `bun:"type:timestamptz,nullzero"` // If interaction request was accepted, time at which this occurred. + RejectedAt time.Time `bun:"type:timestamptz,nullzero"` // If interaction request was rejected, time at which this occurred. +} + +// IsHandled returns true if interaction +// request has been neither accepted or rejected. +func (ir *InteractionRequest) IsPending() bool { + return ir.URI == "" && ir.AcceptedAt.IsZero() && ir.RejectedAt.IsZero() +} + +// IsAccepted returns true if this +// interaction request has been accepted. +func (ir *InteractionRequest) IsAccepted() bool { + return ir.URI != "" && !ir.AcceptedAt.IsZero() +} + +// IsRejected returns true if this +// interaction request has been rejected. +func (ir *InteractionRequest) IsRejected() bool { + return ir.URI != "" && !ir.RejectedAt.IsZero() +} diff --git a/internal/gtsmodel/interactionapproval.go b/internal/gtsmodel/interactionapproval.go deleted file mode 100644 index f6a5da83b..000000000 --- a/internal/gtsmodel/interactionapproval.go +++ /dev/null @@ -1,55 +0,0 @@ -// 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" - -// InteractionApproval refers to a single Accept activity sent -// *from this instance* in response to an interaction request, -// in order to approve it. -// -// Accepts originating from remote instances are not stored -// using this format; the URI of the remote Accept is instead -// just added to the *gtsmodel.StatusFave or *gtsmodel.Status. -type InteractionApproval struct { - ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database - CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created - UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated - AccountID string `bun:"type:CHAR(26),nullzero,notnull"` // id of the account that owns this accept/approval - Account *Account `bun:"-"` // account corresponding to accountID - InteractingAccountID string `bun:"type:CHAR(26),nullzero,notnull"` // id of the account that did the interaction that this Accept targets. - InteractingAccount *Account `bun:"-"` // account corresponding to targetAccountID - InteractionURI string `bun:",nullzero,notnull"` // URI of the target like, reply, or announce - InteractionType InteractionType `bun:",notnull"` // One of Like, Reply, or Announce. - URI string `bun:",nullzero,notnull,unique"` // ActivityPub URI of the Accept. -} - -// Like / Reply / Announce -type InteractionType int - -const ( - // WARNING: DO NOT CHANGE THE ORDER OF THESE, - // as this will cause breakage of approvals! - // - // If you need to add new interaction types, - // add them *to the end* of the list. - - InteractionLike InteractionType = iota - InteractionReply - InteractionAnnounce -) diff --git a/internal/processing/fedi/accept.go b/internal/processing/fedi/accept.go index 72d810f94..fc699ee08 100644 --- a/internal/processing/fedi/accept.go +++ b/internal/processing/fedi/accept.go @@ -27,14 +27,14 @@ import ( ) // AcceptGet handles the getting of a fedi/activitypub -// representation of a local interaction approval. +// representation of a local interaction acceptance. // // It performs appropriate authentication before // returning a JSON serializable interface. func (p *Processor) AcceptGet( ctx context.Context, requestedUser string, - approvalID string, + reqID string, ) (interface{}, gtserror.WithCode) { // Authenticate incoming request, getting related accounts. auth, errWithCode := p.authenticate(ctx, requestedUser) @@ -52,25 +52,26 @@ func (p *Processor) AcceptGet( receivingAcct := auth.receivingAcct - approval, err := p.state.DB.GetInteractionApprovalByID(ctx, approvalID) + req, err := p.state.DB.GetInteractionRequestByID(ctx, reqID) if err != nil && !errors.Is(err, db.ErrNoEntries) { - err := gtserror.Newf("db error getting approval %s: %w", approvalID, err) + err := gtserror.Newf("db error getting interaction request %s: %w", reqID, err) return nil, gtserror.NewErrorInternalError(err) } - if approval.AccountID != receivingAcct.ID { - const text = "approval does not belong to receiving account" - return nil, gtserror.NewErrorNotFound(errors.New(text)) + if req == nil || !req.IsAccepted() { + // Request doesn't exist or hasn't been accepted. + err := gtserror.Newf("interaction request %s not found", reqID) + return nil, gtserror.NewErrorNotFound(err) } - if approval == nil { - err := gtserror.Newf("approval %s not found", approvalID) - return nil, gtserror.NewErrorNotFound(err) + if req.TargetAccountID != receivingAcct.ID { + const text = "interaction request does not belong to receiving account" + return nil, gtserror.NewErrorNotFound(errors.New(text)) } - accept, err := p.converter.InteractionApprovalToASAccept(ctx, approval) + accept, err := p.converter.InteractionReqToASAccept(ctx, req) if err != nil { - err := gtserror.Newf("error converting approval: %w", err) + err := gtserror.Newf("error converting accept: %w", err) return nil, gtserror.NewErrorInternalError(err) } diff --git a/internal/processing/interactionrequests/accept.go b/internal/processing/interactionrequests/accept.go new file mode 100644 index 000000000..ad86e50d1 --- /dev/null +++ b/internal/processing/interactionrequests/accept.go @@ -0,0 +1,239 @@ +// 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 interactionrequests + +import ( + "context" + "time" + + "github.com/superseriousbusiness/gotosocial/internal/ap" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/messages" + "github.com/superseriousbusiness/gotosocial/internal/uris" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +// Accept accepts an interaction request with the given ID, +// on behalf of the given account (whose post it must target). +func (p *Processor) Accept( + ctx context.Context, + acct *gtsmodel.Account, + reqID string, +) (*apimodel.InteractionRequest, gtserror.WithCode) { + req, err := p.state.DB.GetInteractionRequestByID(ctx, reqID) + if err != nil { + err := gtserror.Newf("db error getting interaction request: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + if req.TargetAccountID != acct.ID { + err := gtserror.Newf( + "interaction request %s does not belong to account %s", + reqID, acct.ID, + ) + return nil, gtserror.NewErrorNotFound(err) + } + + if !req.IsPending() { + err := gtserror.Newf( + "interaction request %s has already been handled", + reqID, + ) + return nil, gtserror.NewErrorNotFound(err) + } + + // Lock on the interaction req URI to + // ensure nobody else is modifying it rn. + unlock := p.state.ProcessingLocks.Lock(req.InteractionURI) + defer unlock() + + // Mark the request as accepted + // and generate a URI for it. + req.AcceptedAt = time.Now() + req.URI = uris.GenerateURIForAccept(acct.Username, req.ID) + if err := p.state.DB.UpdateInteractionRequest( + ctx, + req, + "accepted_at", + "uri", + ); err != nil { + err := gtserror.Newf("db error updating interaction request: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + switch req.InteractionType { + + case gtsmodel.InteractionLike: + if errWithCode := p.acceptLike(ctx, req); errWithCode != nil { + return nil, errWithCode + } + + case gtsmodel.InteractionReply: + if errWithCode := p.acceptReply(ctx, req); errWithCode != nil { + return nil, errWithCode + } + + case gtsmodel.InteractionAnnounce: + if errWithCode := p.acceptAnnounce(ctx, req); errWithCode != nil { + return nil, errWithCode + } + + default: + err := gtserror.Newf("unknown interaction type for interaction request %s", reqID) + return nil, gtserror.NewErrorInternalError(err) + } + + // Return the now-accepted req to the caller so + // they can do something with it if they need to. + apiReq, err := p.converter.InteractionReqToAPIInteractionReq( + ctx, + req, + acct, + ) + if err != nil { + err := gtserror.Newf("error converting interaction request: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return apiReq, nil +} + +// Package-internal convenience +// function to accept a like. +func (p *Processor) acceptLike( + ctx context.Context, + req *gtsmodel.InteractionRequest, +) gtserror.WithCode { + // If the Like is missing, that means it's + // probably already been undone by someone, + // so there's nothing to actually accept. + if req.Like == nil { + err := gtserror.Newf("no Like found for interaction request %s", req.ID) + return gtserror.NewErrorNotFound(err) + } + + // Update the Like. + req.Like.PendingApproval = util.Ptr(false) + req.Like.PreApproved = false + req.Like.ApprovedByURI = req.URI + if err := p.state.DB.UpdateStatusFave( + ctx, + req.Like, + "pending_approval", + "approved_by_uri", + ); err != nil { + err := gtserror.Newf("db error updating status fave: %w", err) + return gtserror.NewErrorInternalError(err) + } + + // Send the accepted request off through the + // client API processor to handle side effects. + p.state.Workers.Client.Queue.Push(&messages.FromClientAPI{ + APObjectType: ap.ActivityLike, + APActivityType: ap.ActivityAccept, + GTSModel: req, + Origin: req.TargetAccount, + Target: req.InteractingAccount, + }) + + return nil +} + +// Package-internal convenience +// function to accept a reply. +func (p *Processor) acceptReply( + ctx context.Context, + req *gtsmodel.InteractionRequest, +) gtserror.WithCode { + // If the Reply is missing, that means it's + // probably already been undone by someone, + // so there's nothing to actually accept. + if req.Reply == nil { + err := gtserror.Newf("no Reply found for interaction request %s", req.ID) + return gtserror.NewErrorNotFound(err) + } + + // Update the Reply. + req.Reply.PendingApproval = util.Ptr(false) + req.Reply.PreApproved = false + req.Reply.ApprovedByURI = req.URI + if err := p.state.DB.UpdateStatus( + ctx, + req.Reply, + "pending_approval", + "approved_by_uri", + ); err != nil { + err := gtserror.Newf("db error updating status reply: %w", err) + return gtserror.NewErrorInternalError(err) + } + + // Send the accepted request off through the + // client API processor to handle side effects. + p.state.Workers.Client.Queue.Push(&messages.FromClientAPI{ + APObjectType: ap.ObjectNote, + APActivityType: ap.ActivityAccept, + GTSModel: req, + Origin: req.TargetAccount, + Target: req.InteractingAccount, + }) + + return nil +} + +// Package-internal convenience +// function to accept an announce. +func (p *Processor) acceptAnnounce( + ctx context.Context, + req *gtsmodel.InteractionRequest, +) gtserror.WithCode { + // If the Announce is missing, that means it's + // probably already been undone by someone, + // so there's nothing to actually accept. + if req.Reply == nil { + err := gtserror.Newf("no Announce found for interaction request %s", req.ID) + return gtserror.NewErrorNotFound(err) + } + + // Update the Announce. + req.Announce.PendingApproval = util.Ptr(false) + req.Announce.PreApproved = false + req.Announce.ApprovedByURI = req.URI + if err := p.state.DB.UpdateStatus( + ctx, + req.Announce, + "pending_approval", + "approved_by_uri", + ); err != nil { + err := gtserror.Newf("db error updating status announce: %w", err) + return gtserror.NewErrorInternalError(err) + } + + // Send the accepted request off through the + // client API processor to handle side effects. + p.state.Workers.Client.Queue.Push(&messages.FromClientAPI{ + APObjectType: ap.ActivityAnnounce, + APActivityType: ap.ActivityAccept, + GTSModel: req, + Origin: req.TargetAccount, + Target: req.InteractingAccount, + }) + + return nil +} diff --git a/internal/processing/interactionrequests/accept_test.go b/internal/processing/interactionrequests/accept_test.go new file mode 100644 index 000000000..75fb1e512 --- /dev/null +++ b/internal/processing/interactionrequests/accept_test.go @@ -0,0 +1,89 @@ +// 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 interactionrequests_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/processing/interactionrequests" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type AcceptTestSuite struct { + InteractionRequestsTestSuite +} + +func (suite *AcceptTestSuite) TestAccept() { + testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath) + defer testrig.TearDownTestStructs(testStructs) + + var ( + ctx = context.Background() + state = testStructs.State + acct = suite.testAccounts["local_account_2"] + intReq = suite.testInteractionRequests["admin_account_reply_turtle"] + ) + + // Create interaction reqs processor. + p := interactionrequests.New( + testStructs.Common, + testStructs.State, + testStructs.TypeConverter, + ) + + apiReq, errWithCode := p.Accept(ctx, acct, intReq.ID) + if errWithCode != nil { + suite.FailNow(errWithCode.Error()) + } + + // Get db interaction request. + dbReq, err := state.DB.GetInteractionRequestByID(ctx, apiReq.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.True(dbReq.IsAccepted()) + + // Interacting status + // should now be approved. + dbStatus, err := state.DB.GetStatusByURI(ctx, dbReq.InteractionURI) + if err != nil { + suite.FailNow(err.Error()) + } + suite.False(*dbStatus.PendingApproval) + suite.Equal(dbReq.URI, dbStatus.ApprovedByURI) + + // Wait for a notification + // for interacting status. + testrig.WaitFor(func() bool { + notif, err := state.DB.GetNotification( + ctx, + gtsmodel.NotificationMention, + dbStatus.InReplyToAccountID, + dbStatus.AccountID, + dbStatus.ID, + ) + return notif != nil && err == nil + }) +} + +func TestAcceptTestSuite(t *testing.T) { + suite.Run(t, new(AcceptTestSuite)) +} diff --git a/internal/processing/interactionrequests/get.go b/internal/processing/interactionrequests/get.go new file mode 100644 index 000000000..8f8a7f35d --- /dev/null +++ b/internal/processing/interactionrequests/get.go @@ -0,0 +1,141 @@ +// 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 interactionrequests + +import ( + "context" + "errors" + "net/url" + "strconv" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/log" + "github.com/superseriousbusiness/gotosocial/internal/paging" +) + +// GetPage returns a page of interaction requests targeting +// the requester and (optionally) the given status ID. +func (p *Processor) GetPage( + ctx context.Context, + requester *gtsmodel.Account, + statusID string, + likes bool, + replies bool, + boosts bool, + page *paging.Page, +) (*apimodel.PageableResponse, gtserror.WithCode) { + reqs, err := p.state.DB.GetInteractionsRequestsForAcct( + ctx, + requester.ID, + statusID, + likes, + replies, + boosts, + page, + ) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting interaction requests: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + count := len(reqs) + if count == 0 { + return paging.EmptyResponse(), nil + } + + var ( + // Get the lowest and highest + // ID values, used for paging. + lo = reqs[count-1].ID + hi = reqs[0].ID + + // Best-guess items length. + items = make([]interface{}, 0, count) + ) + + for _, req := range reqs { + apiReq, err := p.converter.InteractionReqToAPIInteractionReq( + ctx, req, requester, + ) + if err != nil { + log.Errorf(ctx, "error converting interaction req to api req: %v", err) + continue + } + + // Append req to return items. + items = append(items, apiReq) + } + + // Build extra query params to return in Link header. + extraParams := make(url.Values, 4) + extraParams.Set(apiutil.InteractionFavouritesKey, strconv.FormatBool(likes)) + extraParams.Set(apiutil.InteractionRepliesKey, strconv.FormatBool(replies)) + extraParams.Set(apiutil.InteractionReblogsKey, strconv.FormatBool(boosts)) + if statusID != "" { + extraParams.Set(apiutil.InteractionStatusIDKey, statusID) + } + + return paging.PackageResponse(paging.ResponseParams{ + Items: items, + Path: "/api/v1/interaction_requests", + Next: page.Next(lo, hi), + Prev: page.Prev(lo, hi), + Query: extraParams, + }), nil +} + +// GetOne returns one interaction +// request with the given ID. +func (p *Processor) GetOne( + ctx context.Context, + requester *gtsmodel.Account, + id string, +) (*apimodel.InteractionRequest, gtserror.WithCode) { + req, err := p.state.DB.GetInteractionRequestByID(ctx, id) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting interaction request: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + if req == nil { + err := gtserror.New("interaction request not found") + return nil, gtserror.NewErrorNotFound(err) + } + + if req.TargetAccountID != requester.ID { + err := gtserror.Newf( + "interaction request %s does not target account %s", + req.ID, requester.ID, + ) + return nil, gtserror.NewErrorNotFound(err) + } + + apiReq, err := p.converter.InteractionReqToAPIInteractionReq( + ctx, req, requester, + ) + if err != nil { + err := gtserror.Newf("error converting interaction req to api req: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return apiReq, nil +} diff --git a/internal/processing/interactionrequests/interactionrequests.go b/internal/processing/interactionrequests/interactionrequests.go new file mode 100644 index 000000000..d56636233 --- /dev/null +++ b/internal/processing/interactionrequests/interactionrequests.go @@ -0,0 +1,47 @@ +// 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 interactionrequests + +import ( + "github.com/superseriousbusiness/gotosocial/internal/processing/common" + "github.com/superseriousbusiness/gotosocial/internal/state" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" +) + +// Processor wraps functionality for getting, +// accepting, and rejecting interaction requests. +type Processor struct { + // common processor logic + c *common.Processor + + state *state.State + converter *typeutils.Converter +} + +// New returns a new interaction requests processor. +func New( + common *common.Processor, + state *state.State, + converter *typeutils.Converter, +) Processor { + return Processor{ + c: common, + state: state, + converter: converter, + } +} diff --git a/internal/processing/interactionrequests/interactionrequests_test.go b/internal/processing/interactionrequests/interactionrequests_test.go new file mode 100644 index 000000000..ce2aa351a --- /dev/null +++ b/internal/processing/interactionrequests/interactionrequests_test.go @@ -0,0 +1,45 @@ +// 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 interactionrequests_test + +import ( + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +const ( + rMediaPath = "../../../testrig/media" + rTemplatePath = "../../../web/template" +) + +type InteractionRequestsTestSuite struct { + suite.Suite + + testAccounts map[string]*gtsmodel.Account + testStatuses map[string]*gtsmodel.Status + testInteractionRequests map[string]*gtsmodel.InteractionRequest +} + +func (suite *InteractionRequestsTestSuite) SetupTest() { + testrig.InitTestConfig() + testrig.InitTestLog() + suite.testAccounts = testrig.NewTestAccounts() + suite.testStatuses = testrig.NewTestStatuses() + suite.testInteractionRequests = testrig.NewTestInteractionRequests() +} diff --git a/internal/processing/interactionrequests/reject.go b/internal/processing/interactionrequests/reject.go new file mode 100644 index 000000000..dcf07a03f --- /dev/null +++ b/internal/processing/interactionrequests/reject.go @@ -0,0 +1,133 @@ +// 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 interactionrequests + +import ( + "context" + "time" + + "github.com/superseriousbusiness/gotosocial/internal/ap" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/messages" + "github.com/superseriousbusiness/gotosocial/internal/uris" +) + +// Reject rejects an interaction request with the given ID, +// on behalf of the given account (whose post it must target). +func (p *Processor) Reject( + ctx context.Context, + acct *gtsmodel.Account, + reqID string, +) (*apimodel.InteractionRequest, gtserror.WithCode) { + req, err := p.state.DB.GetInteractionRequestByID(ctx, reqID) + if err != nil { + err := gtserror.Newf("db error getting interaction request: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + if req.TargetAccountID != acct.ID { + err := gtserror.Newf( + "interaction request %s does not belong to account %s", + reqID, acct.ID, + ) + return nil, gtserror.NewErrorNotFound(err) + } + + if !req.IsPending() { + err := gtserror.Newf( + "interaction request %s has already been handled", + reqID, + ) + return nil, gtserror.NewErrorNotFound(err) + } + + // Lock on the interaction req URI to + // ensure nobody else is modifying it rn. + unlock := p.state.ProcessingLocks.Lock(req.InteractionURI) + defer unlock() + + // Mark the request as rejected + // and generate a URI for it. + req.RejectedAt = time.Now() + req.URI = uris.GenerateURIForReject(acct.Username, req.ID) + if err := p.state.DB.UpdateInteractionRequest( + ctx, + req, + "rejected_at", + "uri", + ); err != nil { + err := gtserror.Newf("db error updating interaction request: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + switch req.InteractionType { + + case gtsmodel.InteractionLike: + // Send the rejected request off through the + // client API processor to handle side effects. + p.state.Workers.Client.Queue.Push(&messages.FromClientAPI{ + APObjectType: ap.ActivityLike, + APActivityType: ap.ActivityReject, + GTSModel: req, + Origin: req.TargetAccount, + Target: req.InteractingAccount, + }) + + case gtsmodel.InteractionReply: + // Send the rejected request off through the + // client API processor to handle side effects. + p.state.Workers.Client.Queue.Push(&messages.FromClientAPI{ + APObjectType: ap.ObjectNote, + APActivityType: ap.ActivityReject, + GTSModel: req, + Origin: req.TargetAccount, + Target: req.InteractingAccount, + }) + + case gtsmodel.InteractionAnnounce: + // Send the rejected request off through the + // client API processor to handle side effects. + p.state.Workers.Client.Queue.Push(&messages.FromClientAPI{ + APObjectType: ap.ActivityAnnounce, + APActivityType: ap.ActivityReject, + GTSModel: req, + Origin: req.TargetAccount, + Target: req.InteractingAccount, + }) + + default: + err := gtserror.Newf("unknown interaction type for interaction request %s", reqID) + return nil, gtserror.NewErrorInternalError(err) + } + + // Return the now-rejected req to the caller so + // they can do something with it if they need to. + apiReq, err := p.converter.InteractionReqToAPIInteractionReq( + ctx, + req, + acct, + ) + if err != nil { + err := gtserror.Newf("error converting interaction request: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return apiReq, nil +} diff --git a/internal/processing/interactionrequests/reject_test.go b/internal/processing/interactionrequests/reject_test.go new file mode 100644 index 000000000..f1f6aed72 --- /dev/null +++ b/internal/processing/interactionrequests/reject_test.go @@ -0,0 +1,78 @@ +// 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 interactionrequests_test + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtscontext" + "github.com/superseriousbusiness/gotosocial/internal/processing/interactionrequests" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type RejectTestSuite struct { + InteractionRequestsTestSuite +} + +func (suite *RejectTestSuite) TestReject() { + testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath) + defer testrig.TearDownTestStructs(testStructs) + + var ( + ctx = context.Background() + state = testStructs.State + acct = suite.testAccounts["local_account_2"] + intReq = suite.testInteractionRequests["admin_account_reply_turtle"] + ) + + // Create int reqs processor. + p := interactionrequests.New( + testStructs.Common, + testStructs.State, + testStructs.TypeConverter, + ) + + apiReq, errWithCode := p.Reject(ctx, acct, intReq.ID) + if errWithCode != nil { + suite.FailNow(errWithCode.Error()) + } + + // Get db interaction rejection. + dbReq, err := state.DB.GetInteractionRequestByID(ctx, apiReq.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.True(dbReq.IsRejected()) + + // Wait for interacting status to be deleted. + testrig.WaitFor(func() bool { + status, err := state.DB.GetStatusByURI( + gtscontext.SetBarebones(ctx), + dbReq.InteractionURI, + ) + return status == nil && errors.Is(err, db.ErrNoEntries) + }) +} + +func TestRejectTestSuite(t *testing.T) { + suite.Run(t, new(RejectTestSuite)) +} diff --git a/internal/processing/processor.go b/internal/processing/processor.go index 6d39dc103..2ed13d396 100644 --- a/internal/processing/processor.go +++ b/internal/processing/processor.go @@ -34,6 +34,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/processing/fedi" filtersv1 "github.com/superseriousbusiness/gotosocial/internal/processing/filters/v1" filtersv2 "github.com/superseriousbusiness/gotosocial/internal/processing/filters/v2" + "github.com/superseriousbusiness/gotosocial/internal/processing/interactionrequests" "github.com/superseriousbusiness/gotosocial/internal/processing/list" "github.com/superseriousbusiness/gotosocial/internal/processing/markers" "github.com/superseriousbusiness/gotosocial/internal/processing/media" @@ -74,25 +75,26 @@ type Processor struct { SUB-PROCESSORS */ - account account.Processor - admin admin.Processor - advancedmigrations advancedmigrations.Processor - conversations conversations.Processor - fedi fedi.Processor - filtersv1 filtersv1.Processor - filtersv2 filtersv2.Processor - list list.Processor - markers markers.Processor - media media.Processor - polls polls.Processor - report report.Processor - search search.Processor - status status.Processor - stream stream.Processor - tags tags.Processor - timeline timeline.Processor - user user.Processor - workers workers.Processor + account account.Processor + admin admin.Processor + advancedmigrations advancedmigrations.Processor + conversations conversations.Processor + fedi fedi.Processor + filtersv1 filtersv1.Processor + filtersv2 filtersv2.Processor + interactionRequests interactionrequests.Processor + list list.Processor + markers markers.Processor + media media.Processor + polls polls.Processor + report report.Processor + search search.Processor + status status.Processor + stream stream.Processor + tags tags.Processor + timeline timeline.Processor + user user.Processor + workers workers.Processor } func (p *Processor) Account() *account.Processor { @@ -123,6 +125,10 @@ func (p *Processor) FiltersV2() *filtersv2.Processor { return &p.filtersv2 } +func (p *Processor) InteractionRequests() *interactionrequests.Processor { + return &p.interactionRequests +} + func (p *Processor) List() *list.Processor { return &p.list } @@ -209,6 +215,7 @@ func NewProcessor( processor.fedi = fedi.New(state, &common, converter, federator, visFilter) processor.filtersv1 = filtersv1.New(state, converter, &processor.stream) processor.filtersv2 = filtersv2.New(state, converter, &processor.stream) + processor.interactionRequests = interactionrequests.New(&common, state, converter) processor.list = list.New(state, converter) processor.markers = markers.New(state, converter) processor.polls = polls.New(&common, state, converter) @@ -227,6 +234,7 @@ func NewProcessor( // and pass subset of sub processors it needs. processor.workers = workers.New( state, + &common, federator, converter, visFilter, diff --git a/internal/processing/workers/federate.go b/internal/processing/workers/federate.go index 6e8b558c8..d14f38902 100644 --- a/internal/processing/workers/federate.go +++ b/internal/processing/workers/federate.go @@ -1127,17 +1127,17 @@ func (f *federate) MoveAccount(ctx context.Context, account *gtsmodel.Account) e func (f *federate) AcceptInteraction( ctx context.Context, - approval *gtsmodel.InteractionApproval, + req *gtsmodel.InteractionRequest, ) error { // Populate model. - if err := f.state.DB.PopulateInteractionApproval(ctx, approval); err != nil { - return gtserror.Newf("error populating approval: %w", err) + if err := f.state.DB.PopulateInteractionRequest(ctx, req); err != nil { + return gtserror.Newf("error populating request: %w", err) } // Bail if interacting account is ours: // we've already accepted internally and // shouldn't send an Accept to ourselves. - if approval.InteractingAccount.IsLocal() { + if req.InteractingAccount.IsLocal() { return nil } @@ -1145,27 +1145,27 @@ func (f *federate) AcceptInteraction( // we can't Accept on another // instance's behalf. (This // should never happen but...) - if approval.Account.IsRemote() { + if req.TargetAccount.IsRemote() { return nil } // Parse relevant URI(s). - outboxIRI, err := parseURI(approval.Account.OutboxURI) + outboxIRI, err := parseURI(req.TargetAccount.OutboxURI) if err != nil { return err } - acceptingAcctIRI, err := parseURI(approval.Account.URI) + acceptingAcctIRI, err := parseURI(req.TargetAccount.URI) if err != nil { return err } - interactingAcctURI, err := parseURI(approval.InteractingAccount.URI) + interactingAcctURI, err := parseURI(req.InteractingAccount.URI) if err != nil { return err } - interactionURI, err := parseURI(approval.InteractionURI) + interactionURI, err := parseURI(req.InteractionURI) if err != nil { return err } @@ -1190,7 +1190,79 @@ func (f *federate) AcceptInteraction( ); err != nil { return gtserror.Newf( "error sending activity %T for %v via outbox %s: %w", - accept, approval.InteractionType, outboxIRI, err, + accept, req.InteractionType, outboxIRI, err, + ) + } + + return nil +} + +func (f *federate) RejectInteraction( + ctx context.Context, + req *gtsmodel.InteractionRequest, +) error { + // Populate model. + if err := f.state.DB.PopulateInteractionRequest(ctx, req); err != nil { + return gtserror.Newf("error populating request: %w", err) + } + + // Bail if interacting account is ours: + // we've already rejected internally and + // shouldn't send an Reject to ourselves. + if req.InteractingAccount.IsLocal() { + return nil + } + + // Bail if account isn't ours: + // we can't Reject on another + // instance's behalf. (This + // should never happen but...) + if req.TargetAccount.IsRemote() { + return nil + } + + // Parse relevant URI(s). + outboxIRI, err := parseURI(req.TargetAccount.OutboxURI) + if err != nil { + return err + } + + rejectingAcctIRI, err := parseURI(req.TargetAccount.URI) + if err != nil { + return err + } + + interactingAcctURI, err := parseURI(req.InteractingAccount.URI) + if err != nil { + return err + } + + interactionURI, err := parseURI(req.InteractionURI) + if err != nil { + return err + } + + // Create a new Reject. + reject := streams.NewActivityStreamsReject() + + // Set interacted-with account + // as Actor of the Reject. + ap.AppendActorIRIs(reject, rejectingAcctIRI) + + // Set the interacted-with object + // as Object of the Reject. + ap.AppendObjectIRIs(reject, interactionURI) + + // Address the Reject To the interacting acct. + ap.AppendTo(reject, interactingAcctURI) + + // Send the Reject via the Actor's outbox. + if _, err := f.FederatingActor().Send( + ctx, outboxIRI, reject, + ); err != nil { + return gtserror.Newf( + "error sending activity %T for %v via outbox %s: %w", + reject, req.InteractionType, outboxIRI, err, ) } diff --git a/internal/processing/workers/fromclientapi.go b/internal/processing/workers/fromclientapi.go index 1a37341f8..c723a6001 100644 --- a/internal/processing/workers/fromclientapi.go +++ b/internal/processing/workers/fromclientapi.go @@ -20,18 +20,23 @@ package workers import ( "context" "errors" + "time" "codeberg.org/gruf/go-kv" "codeberg.org/gruf/go-logger/v2/level" "github.com/superseriousbusiness/gotosocial/internal/ap" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtscontext" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/messages" "github.com/superseriousbusiness/gotosocial/internal/processing/account" + "github.com/superseriousbusiness/gotosocial/internal/processing/common" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "github.com/superseriousbusiness/gotosocial/internal/uris" "github.com/superseriousbusiness/gotosocial/internal/util" ) @@ -44,6 +49,7 @@ type clientAPI struct { surface *Surface federate *federate account *account.Processor + common *common.Processor utils *utils } @@ -160,6 +166,18 @@ func (p *Processor) ProcessFromClientAPI(ctx context.Context, cMsg *messages.Fro // REJECT USER (ie., new user+account sign-up) case ap.ObjectProfile: return p.clientAPI.RejectUser(ctx, cMsg) + + // REJECT NOTE/STATUS (ie., reject a reply) + case ap.ObjectNote: + return p.clientAPI.RejectReply(ctx, cMsg) + + // REJECT LIKE + case ap.ActivityLike: + return p.clientAPI.RejectLike(ctx, cMsg) + + // REJECT BOOST + case ap.ActivityAnnounce: + return p.clientAPI.RejectAnnounce(ctx, cMsg) } // UNDO SOMETHING @@ -261,15 +279,13 @@ func (p *clientAPI) CreateStatus(ctx context.Context, cMsg *messages.FromClientA // and/or notify the account that's being // interacted with (if it's local): they can // approve or deny the interaction later. - - // Notify *local* account of pending reply. - if err := p.surface.notifyPendingReply(ctx, status); err != nil { - log.Errorf(ctx, "error notifying pending reply: %v", err) + if err := p.utils.requestReply(ctx, status); err != nil { + return gtserror.Newf("error pending reply: %w", err) } // Send Create to *remote* account inbox ONLY. if err := p.federate.CreateStatus(ctx, status); err != nil { - log.Errorf(ctx, "error federating pending reply: %v", err) + return gtserror.Newf("error federating pending reply: %w", err) } // Return early. @@ -285,14 +301,38 @@ func (p *clientAPI) CreateStatus(ctx context.Context, cMsg *messages.FromClientA // sending out the Create with the approval // URI attached. - // Put approval in the database and - // update the status with approvedBy URI. - approval, err := p.utils.approveReply(ctx, status) - if err != nil { - return gtserror.Newf("error pre-approving reply: %w", err) + // Store an already-accepted interaction request. + id := id.NewULID() + approval := >smodel.InteractionRequest{ + ID: id, + StatusID: status.InReplyToID, + TargetAccountID: status.InReplyToAccountID, + TargetAccount: status.InReplyToAccount, + InteractingAccountID: status.AccountID, + InteractingAccount: status.Account, + InteractionURI: status.URI, + InteractionType: gtsmodel.InteractionLike, + Reply: status, + URI: uris.GenerateURIForAccept(status.InReplyToAccount.Username, id), + AcceptedAt: time.Now(), + } + if err := p.state.DB.PutInteractionRequest(ctx, approval); err != nil { + return gtserror.Newf("db error putting pre-approved interaction request: %w", err) + } + + // Mark the status as now approved. + status.PendingApproval = util.Ptr(false) + status.PreApproved = false + status.ApprovedByURI = approval.URI + if err := p.state.DB.UpdateStatus( + ctx, + status, + "pending_approval", + "approved_by_uri", + ); err != nil { + return gtserror.Newf("db error updating status: %w", err) } - // Send out the approval as Accept. if err := p.federate.AcceptInteraction(ctx, approval); err != nil { return gtserror.Newf("error federating pre-approval of reply: %w", err) } @@ -309,16 +349,16 @@ func (p *clientAPI) CreateStatus(ctx context.Context, cMsg *messages.FromClientA log.Errorf(ctx, "error timelining and notifying status: %v", err) } + if err := p.federate.CreateStatus(ctx, status); err != nil { + log.Errorf(ctx, "error federating status: %v", err) + } + if status.InReplyToID != "" { // Interaction counts changed on the replied status; // uncache the prepared version from all timelines. p.surface.invalidateStatusFromTimelines(ctx, status.InReplyToID) } - if err := p.federate.CreateStatus(ctx, status); err != nil { - log.Errorf(ctx, "error federating status: %v", err) - } - return nil } @@ -344,9 +384,6 @@ func (p *clientAPI) CreatePollVote(ctx context.Context, cMsg *messages.FromClien status := vote.Poll.Status status.Poll = vote.Poll - // Interaction counts changed on the source status, uncache from timelines. - p.surface.invalidateStatusFromTimelines(ctx, vote.Poll.StatusID) - if *status.Local { // These are poll votes in a local status, we only need to // federate the updated status model with latest vote counts. @@ -360,6 +397,9 @@ func (p *clientAPI) CreatePollVote(ctx context.Context, cMsg *messages.FromClien } } + // Interaction counts changed on the source status, uncache from timelines. + p.surface.invalidateStatusFromTimelines(ctx, vote.Poll.StatusID) + return nil } @@ -429,10 +469,7 @@ func (p *clientAPI) CreateLike(ctx context.Context, cMsg *messages.FromClientAPI // If pending approval is true then fave must // target a status (either one of ours or a // remote) that requires approval for the fave. - pendingApproval := util.PtrOrValue( - fave.PendingApproval, - false, - ) + pendingApproval := util.PtrOrZero(fave.PendingApproval) switch { case pendingApproval && !fave.PreApproved: @@ -442,15 +479,13 @@ func (p *clientAPI) CreateLike(ctx context.Context, cMsg *messages.FromClientAPI // and/or notify the account that's being // interacted with (if it's local): they can // approve or deny the interaction later. - - // Notify *local* account of pending reply. - if err := p.surface.notifyPendingFave(ctx, fave); err != nil { - log.Errorf(ctx, "error notifying pending fave: %v", err) + if err := p.utils.requestFave(ctx, fave); err != nil { + return gtserror.Newf("error pending fave: %w", err) } // Send Like to *remote* account inbox ONLY. if err := p.federate.Like(ctx, fave); err != nil { - log.Errorf(ctx, "error federating pending Like: %v", err) + return gtserror.Newf("error federating pending Like: %v", err) } // Return early. @@ -466,14 +501,38 @@ func (p *clientAPI) CreateLike(ctx context.Context, cMsg *messages.FromClientAPI // sending out the Like with the approval // URI attached. - // Put approval in the database and - // update the fave with approvedBy URI. - approval, err := p.utils.approveFave(ctx, fave) - if err != nil { - return gtserror.Newf("error pre-approving fave: %w", err) + // Store an already-accepted interaction request. + id := id.NewULID() + approval := >smodel.InteractionRequest{ + ID: id, + StatusID: fave.StatusID, + TargetAccountID: fave.TargetAccountID, + TargetAccount: fave.TargetAccount, + InteractingAccountID: fave.AccountID, + InteractingAccount: fave.Account, + InteractionURI: fave.URI, + InteractionType: gtsmodel.InteractionLike, + Like: fave, + URI: uris.GenerateURIForAccept(fave.TargetAccount.Username, id), + AcceptedAt: time.Now(), + } + if err := p.state.DB.PutInteractionRequest(ctx, approval); err != nil { + return gtserror.Newf("db error putting pre-approved interaction request: %w", err) + } + + // Mark the fave itself as now approved. + fave.PendingApproval = util.Ptr(false) + fave.PreApproved = false + fave.ApprovedByURI = approval.URI + if err := p.state.DB.UpdateStatusFave( + ctx, + fave, + "pending_approval", + "approved_by_uri", + ); err != nil { + return gtserror.Newf("db error updating status fave: %w", err) } - // Send out the approval as Accept. if err := p.federate.AcceptInteraction(ctx, approval); err != nil { return gtserror.Newf("error federating pre-approval of fave: %w", err) } @@ -485,14 +544,14 @@ func (p *clientAPI) CreateLike(ctx context.Context, cMsg *messages.FromClientAPI log.Errorf(ctx, "error notifying fave: %v", err) } - // Interaction counts changed on the faved status; - // uncache the prepared version from all timelines. - p.surface.invalidateStatusFromTimelines(ctx, fave.StatusID) - if err := p.federate.Like(ctx, fave); err != nil { log.Errorf(ctx, "error federating like: %v", err) } + // Interaction counts changed on the faved status; + // uncache the prepared version from all timelines. + p.surface.invalidateStatusFromTimelines(ctx, fave.StatusID) + return nil } @@ -505,10 +564,7 @@ func (p *clientAPI) CreateAnnounce(ctx context.Context, cMsg *messages.FromClien // If pending approval is true then status must // boost a status (either one of ours or a // remote) that requires approval for the boost. - pendingApproval := util.PtrOrValue( - boost.PendingApproval, - false, - ) + pendingApproval := util.PtrOrZero(boost.PendingApproval) switch { case pendingApproval && !boost.PreApproved: @@ -518,15 +574,13 @@ func (p *clientAPI) CreateAnnounce(ctx context.Context, cMsg *messages.FromClien // and/or notify the account that's being // interacted with (if it's local): they can // approve or deny the interaction later. - - // Notify *local* account of pending announce. - if err := p.surface.notifyPendingAnnounce(ctx, boost); err != nil { - log.Errorf(ctx, "error notifying pending boost: %v", err) + if err := p.utils.requestAnnounce(ctx, boost); err != nil { + return gtserror.Newf("error pending boost: %w", err) } // Send Announce to *remote* account inbox ONLY. if err := p.federate.Announce(ctx, boost); err != nil { - log.Errorf(ctx, "error federating pending Announce: %v", err) + return gtserror.Newf("error federating pending Announce: %v", err) } // Return early. @@ -542,14 +596,38 @@ func (p *clientAPI) CreateAnnounce(ctx context.Context, cMsg *messages.FromClien // sending out the Create with the approval // URI attached. - // Put approval in the database and - // update the boost with approvedBy URI. - approval, err := p.utils.approveAnnounce(ctx, boost) - if err != nil { - return gtserror.Newf("error pre-approving boost: %w", err) + // Store an already-accepted interaction request. + id := id.NewULID() + approval := >smodel.InteractionRequest{ + ID: id, + StatusID: boost.BoostOfID, + TargetAccountID: boost.BoostOfAccountID, + TargetAccount: boost.BoostOfAccount, + InteractingAccountID: boost.AccountID, + InteractingAccount: boost.Account, + InteractionURI: boost.URI, + InteractionType: gtsmodel.InteractionLike, + Announce: boost, + URI: uris.GenerateURIForAccept(boost.BoostOfAccount.Username, id), + AcceptedAt: time.Now(), + } + if err := p.state.DB.PutInteractionRequest(ctx, approval); err != nil { + return gtserror.Newf("db error putting pre-approved interaction request: %w", err) + } + + // Mark the boost itself as now approved. + boost.PendingApproval = util.Ptr(false) + boost.PreApproved = false + boost.ApprovedByURI = approval.URI + if err := p.state.DB.UpdateStatus( + ctx, + boost, + "pending_approval", + "approved_by_uri", + ); err != nil { + return gtserror.Newf("db error updating status: %w", err) } - // Send out the approval as Accept. if err := p.federate.AcceptInteraction(ctx, approval); err != nil { return gtserror.Newf("error federating pre-approval of boost: %w", err) } @@ -572,14 +650,14 @@ func (p *clientAPI) CreateAnnounce(ctx context.Context, cMsg *messages.FromClien log.Errorf(ctx, "error notifying boost: %v", err) } - // Interaction counts changed on the boosted status; - // uncache the prepared version from all timelines. - p.surface.invalidateStatusFromTimelines(ctx, boost.BoostOfID) - if err := p.federate.Announce(ctx, boost); err != nil { log.Errorf(ctx, "error federating announce: %v", err) } + // Interaction counts changed on the boosted status; + // uncache the prepared version from all timelines. + p.surface.invalidateStatusFromTimelines(ctx, boost.BoostOfID) + return nil } @@ -629,9 +707,6 @@ func (p *clientAPI) UpdateStatus(ctx context.Context, cMsg *messages.FromClientA log.Errorf(ctx, "error federating status update: %v", err) } - // Status representation has changed, invalidate from timelines. - p.surface.invalidateStatusFromTimelines(ctx, status.ID) - if status.Poll != nil && status.Poll.Closing { // If the latest status has a newly closed poll, at least compared @@ -646,6 +721,9 @@ func (p *clientAPI) UpdateStatus(ctx context.Context, cMsg *messages.FromClientA log.Errorf(ctx, "error streaming status edit: %v", err) } + // Status representation has changed, invalidate from timelines. + p.surface.invalidateStatusFromTimelines(ctx, status.ID) + return nil } @@ -791,14 +869,14 @@ func (p *clientAPI) UndoFave(ctx context.Context, cMsg *messages.FromClientAPI) return gtserror.Newf("%T not parseable as *gtsmodel.StatusFave", cMsg.GTSModel) } - // Interaction counts changed on the faved status; - // uncache the prepared version from all timelines. - p.surface.invalidateStatusFromTimelines(ctx, statusFave.StatusID) - if err := p.federate.UndoLike(ctx, statusFave); err != nil { log.Errorf(ctx, "error federating like undo: %v", err) } + // Interaction counts changed on the faved status; + // uncache the prepared version from all timelines. + p.surface.invalidateStatusFromTimelines(ctx, statusFave.StatusID) + return nil } @@ -821,14 +899,14 @@ func (p *clientAPI) UndoAnnounce(ctx context.Context, cMsg *messages.FromClientA log.Errorf(ctx, "error removing timelined status: %v", err) } - // Interaction counts changed on the boosted status; - // uncache the prepared version from all timelines. - p.surface.invalidateStatusFromTimelines(ctx, status.BoostOfID) - if err := p.federate.UndoAnnounce(ctx, status); err != nil { log.Errorf(ctx, "error federating announce undo: %v", err) } + // Interaction counts changed on the boosted status; + // uncache the prepared version from all timelines. + p.surface.invalidateStatusFromTimelines(ctx, status.BoostOfID) + return nil } @@ -874,16 +952,16 @@ func (p *clientAPI) DeleteStatus(ctx context.Context, cMsg *messages.FromClientA log.Errorf(ctx, "error updating account stats: %v", err) } + if err := p.federate.DeleteStatus(ctx, status); err != nil { + log.Errorf(ctx, "error federating status delete: %v", err) + } + if status.InReplyToID != "" { // Interaction counts changed on the replied status; // uncache the prepared version from all timelines. p.surface.invalidateStatusFromTimelines(ctx, status.InReplyToID) } - if err := p.federate.DeleteStatus(ctx, status); err != nil { - log.Errorf(ctx, "error federating status delete: %v", err) - } - return nil } @@ -1050,16 +1128,188 @@ func (p *clientAPI) RejectUser(ctx context.Context, cMsg *messages.FromClientAPI } func (p *clientAPI) AcceptLike(ctx context.Context, cMsg *messages.FromClientAPI) error { - // TODO + req, ok := cMsg.GTSModel.(*gtsmodel.InteractionRequest) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.InteractionRequest", cMsg.GTSModel) + } + + // Notify the fave (distinct from the notif for the pending fave). + if err := p.surface.notifyFave(ctx, req.Like); err != nil { + log.Errorf(ctx, "error notifying fave: %v", err) + } + + // Send out the Accept. + if err := p.federate.AcceptInteraction(ctx, req); err != nil { + log.Errorf(ctx, "error federating approval of like: %v", err) + } + + // Interaction counts changed on the faved status; + // uncache the prepared version from all timelines. + p.surface.invalidateStatusFromTimelines(ctx, req.Like.StatusID) + return nil } func (p *clientAPI) AcceptReply(ctx context.Context, cMsg *messages.FromClientAPI) error { - // TODO + req, ok := cMsg.GTSModel.(*gtsmodel.InteractionRequest) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.InteractionRequest", cMsg.GTSModel) + } + + var ( + interactingAcct = req.InteractingAccount + reply = req.Reply + ) + + // Update stats for the reply author account. + if err := p.utils.incrementStatusesCount(ctx, interactingAcct, reply); err != nil { + log.Errorf(ctx, "error updating account stats: %v", err) + } + + // Timeline the reply + notify relevant accounts. + if err := p.surface.timelineAndNotifyStatus(ctx, reply); err != nil { + log.Errorf(ctx, "error timelining and notifying status reply: %v", err) + } + + // Send out the Accept. + if err := p.federate.AcceptInteraction(ctx, req); err != nil { + log.Errorf(ctx, "error federating approval of reply: %v", err) + } + + // Interaction counts changed on the replied status; + // uncache the prepared version from all timelines. + p.surface.invalidateStatusFromTimelines(ctx, reply.InReplyToID) + return nil } func (p *clientAPI) AcceptAnnounce(ctx context.Context, cMsg *messages.FromClientAPI) error { - // TODO + req, ok := cMsg.GTSModel.(*gtsmodel.InteractionRequest) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.InteractionRequest", cMsg.GTSModel) + } + + var ( + interactingAcct = req.InteractingAccount + boost = req.Announce + ) + + // Update stats for the boost author account. + if err := p.utils.incrementStatusesCount(ctx, interactingAcct, boost); err != nil { + log.Errorf(ctx, "error updating account stats: %v", err) + } + + // Timeline and notify the announce. + if err := p.surface.timelineAndNotifyStatus(ctx, boost); err != nil { + log.Errorf(ctx, "error timelining and notifying status: %v", err) + } + + // Notify the announce (distinct from the notif for the pending announce). + if err := p.surface.notifyAnnounce(ctx, boost); err != nil { + log.Errorf(ctx, "error notifying announce: %v", err) + } + + // Send out the Accept. + if err := p.federate.AcceptInteraction(ctx, req); err != nil { + log.Errorf(ctx, "error federating approval of announce: %v", err) + } + + // Interaction counts changed on the original status; + // uncache the prepared version from all timelines. + p.surface.invalidateStatusFromTimelines(ctx, boost.BoostOfID) + + return nil +} + +func (p *clientAPI) RejectLike(ctx context.Context, cMsg *messages.FromClientAPI) error { + req, ok := cMsg.GTSModel.(*gtsmodel.InteractionRequest) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.InteractionRequest", cMsg.GTSModel) + } + + // At this point the InteractionRequest should already + // be in the database, we just need to do side effects. + + // Send out the Reject. + if err := p.federate.RejectInteraction(ctx, req); err != nil { + log.Errorf(ctx, "error federating rejection of like: %v", err) + } + + // Get the rejected fave. + fave, err := p.state.DB.GetStatusFaveByURI( + gtscontext.SetBarebones(ctx), + req.InteractionURI, + ) + if err != nil { + return gtserror.Newf("db error getting rejected fave: %w", err) + } + + // Delete the status fave. + if err := p.state.DB.DeleteStatusFaveByID(ctx, fave.ID); err != nil { + return gtserror.Newf("db error deleting status fave: %w", err) + } + + return nil +} + +func (p *clientAPI) RejectReply(ctx context.Context, cMsg *messages.FromClientAPI) error { + req, ok := cMsg.GTSModel.(*gtsmodel.InteractionRequest) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.InteractionRequest", cMsg.GTSModel) + } + + // At this point the InteractionRequest should already + // be in the database, we just need to do side effects. + + // Send out the Reject. + if err := p.federate.RejectInteraction(ctx, req); err != nil { + log.Errorf(ctx, "error federating rejection of reply: %v", err) + } + + // Get the rejected status. + status, err := p.state.DB.GetStatusByURI( + gtscontext.SetBarebones(ctx), + req.InteractionURI, + ) + if err != nil { + return gtserror.Newf("db error getting rejected reply: %w", err) + } + + // Totally wipe the status. + if err := p.utils.wipeStatus(ctx, status, true); err != nil { + return gtserror.Newf("error wiping status: %w", err) + } + + return nil +} + +func (p *clientAPI) RejectAnnounce(ctx context.Context, cMsg *messages.FromClientAPI) error { + req, ok := cMsg.GTSModel.(*gtsmodel.InteractionRequest) + if !ok { + return gtserror.Newf("%T not parseable as *gtsmodel.InteractionRequest", cMsg.GTSModel) + } + + // At this point the InteractionRequest should already + // be in the database, we just need to do side effects. + + // Send out the Reject. + if err := p.federate.RejectInteraction(ctx, req); err != nil { + log.Errorf(ctx, "error federating rejection of announce: %v", err) + } + + // Get the rejected boost. + boost, err := p.state.DB.GetStatusByURI( + gtscontext.SetBarebones(ctx), + req.InteractionURI, + ) + if err != nil { + return gtserror.Newf("db error getting rejected announce: %w", err) + } + + // Totally wipe the status. + if err := p.utils.wipeStatus(ctx, boost, true); err != nil { + return gtserror.Newf("error wiping status: %w", err) + } + return nil } diff --git a/internal/processing/workers/fromclientapi_test.go b/internal/processing/workers/fromclientapi_test.go index b4eae0be0..d330e4c2b 100644 --- a/internal/processing/workers/fromclientapi_test.go +++ b/internal/processing/workers/fromclientapi_test.go @@ -231,8 +231,8 @@ func (suite *FromClientAPITestSuite) conversationJSON( } func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithNotification() { - testStructs := suite.SetupTestStructs() - defer suite.TearDownTestStructs(testStructs) + testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath) + defer testrig.TearDownTestStructs(testStructs) var ( ctx = context.Background() @@ -344,8 +344,8 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithNotification() { } func (suite *FromClientAPITestSuite) TestProcessCreateStatusReply() { - testStructs := suite.SetupTestStructs() - defer suite.TearDownTestStructs(testStructs) + testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath) + defer testrig.TearDownTestStructs(testStructs) var ( ctx = context.Background() @@ -412,8 +412,8 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusReply() { } func (suite *FromClientAPITestSuite) TestProcessCreateStatusReplyMuted() { - testStructs := suite.SetupTestStructs() - defer suite.TearDownTestStructs(testStructs) + testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath) + defer testrig.TearDownTestStructs(testStructs) var ( ctx = context.Background() @@ -473,8 +473,8 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusReplyMuted() { } func (suite *FromClientAPITestSuite) TestProcessCreateStatusBoostMuted() { - testStructs := suite.SetupTestStructs() - defer suite.TearDownTestStructs(testStructs) + testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath) + defer testrig.TearDownTestStructs(testStructs) var ( ctx = context.Background() @@ -534,8 +534,8 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusBoostMuted() { } func (suite *FromClientAPITestSuite) TestProcessCreateStatusListRepliesPolicyListOnlyOK() { - testStructs := suite.SetupTestStructs() - defer suite.TearDownTestStructs(testStructs) + testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath) + defer testrig.TearDownTestStructs(testStructs) // We're modifying the test list so take a copy. testList := new(gtsmodel.List) @@ -610,8 +610,8 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusListRepliesPolicyLis } func (suite *FromClientAPITestSuite) TestProcessCreateStatusListRepliesPolicyListOnlyNo() { - testStructs := suite.SetupTestStructs() - defer suite.TearDownTestStructs(testStructs) + testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath) + defer testrig.TearDownTestStructs(testStructs) // We're modifying the test list so take a copy. testList := new(gtsmodel.List) @@ -691,8 +691,8 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusListRepliesPolicyLis } func (suite *FromClientAPITestSuite) TestProcessCreateStatusReplyListRepliesPolicyNone() { - testStructs := suite.SetupTestStructs() - defer suite.TearDownTestStructs(testStructs) + testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath) + defer testrig.TearDownTestStructs(testStructs) // We're modifying the test list so take a copy. testList := new(gtsmodel.List) @@ -767,8 +767,8 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusReplyListRepliesPoli } func (suite *FromClientAPITestSuite) TestProcessCreateStatusBoost() { - testStructs := suite.SetupTestStructs() - defer suite.TearDownTestStructs(testStructs) + testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath) + defer testrig.TearDownTestStructs(testStructs) var ( ctx = context.Background() @@ -831,8 +831,8 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusBoost() { } func (suite *FromClientAPITestSuite) TestProcessCreateStatusBoostNoReblogs() { - testStructs := suite.SetupTestStructs() - defer suite.TearDownTestStructs(testStructs) + testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath) + defer testrig.TearDownTestStructs(testStructs) var ( ctx = context.Background() @@ -898,8 +898,8 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusBoostNoReblogs() { // A DM to a local user should create a conversation and accompanying notification. func (suite *FromClientAPITestSuite) TestProcessCreateStatusWhichBeginsConversation() { - testStructs := suite.SetupTestStructs() - defer suite.TearDownTestStructs(testStructs) + testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath) + defer testrig.TearDownTestStructs(testStructs) var ( ctx = context.Background() @@ -984,8 +984,8 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWhichBeginsConversat // A public message to a local user should not result in a conversation notification. func (suite *FromClientAPITestSuite) TestProcessCreateStatusWhichShouldNotCreateConversation() { - testStructs := suite.SetupTestStructs() - defer suite.TearDownTestStructs(testStructs) + testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath) + defer testrig.TearDownTestStructs(testStructs) var ( ctx = context.Background() @@ -1054,8 +1054,8 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWhichShouldNotCreate // A public status with a hashtag followed by a local user who does not otherwise follow the author // should end up in the tag-following user's home timeline. func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithFollowedHashtag() { - testStructs := suite.SetupTestStructs() - defer suite.TearDownTestStructs(testStructs) + testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath) + defer testrig.TearDownTestStructs(testStructs) var ( ctx = context.Background() @@ -1128,8 +1128,8 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithFollowedHashtag( // should not end up in the tag-following user's home timeline // if the user has the author blocked. func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithFollowedHashtagAndBlock() { - testStructs := suite.SetupTestStructs() - defer suite.TearDownTestStructs(testStructs) + testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath) + defer testrig.TearDownTestStructs(testStructs) var ( ctx = context.Background() @@ -1209,8 +1209,8 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithFollowedHashtagA // who does not otherwise follow the author or booster // should end up in the tag-following user's home timeline as the original status. func (suite *FromClientAPITestSuite) TestProcessCreateBoostWithFollowedHashtag() { - testStructs := suite.SetupTestStructs() - defer suite.TearDownTestStructs(testStructs) + testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath) + defer testrig.TearDownTestStructs(testStructs) var ( ctx = context.Background() @@ -1312,8 +1312,8 @@ func (suite *FromClientAPITestSuite) TestProcessCreateBoostWithFollowedHashtag() // should not end up in the tag-following user's home timeline // if the user has the author blocked. func (suite *FromClientAPITestSuite) TestProcessCreateBoostWithFollowedHashtagAndBlock() { - testStructs := suite.SetupTestStructs() - defer suite.TearDownTestStructs(testStructs) + testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath) + defer testrig.TearDownTestStructs(testStructs) var ( ctx = context.Background() @@ -1422,8 +1422,8 @@ func (suite *FromClientAPITestSuite) TestProcessCreateBoostWithFollowedHashtagAn // should not end up in the tag-following user's home timeline // if the user has the booster blocked. func (suite *FromClientAPITestSuite) TestProcessCreateBoostWithFollowedHashtagAndBlockedBoost() { - testStructs := suite.SetupTestStructs() - defer suite.TearDownTestStructs(testStructs) + testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath) + defer testrig.TearDownTestStructs(testStructs) var ( ctx = context.Background() @@ -1530,8 +1530,8 @@ func (suite *FromClientAPITestSuite) TestProcessCreateBoostWithFollowedHashtagAn // Updating a public status with a hashtag followed by a local user who does not otherwise follow the author // should stream a status update to the tag-following user's home timeline. func (suite *FromClientAPITestSuite) TestProcessUpdateStatusWithFollowedHashtag() { - testStructs := suite.SetupTestStructs() - defer suite.TearDownTestStructs(testStructs) + testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath) + defer testrig.TearDownTestStructs(testStructs) var ( ctx = context.Background() @@ -1601,8 +1601,8 @@ func (suite *FromClientAPITestSuite) TestProcessUpdateStatusWithFollowedHashtag( } func (suite *FromClientAPITestSuite) TestProcessStatusDelete() { - testStructs := suite.SetupTestStructs() - defer suite.TearDownTestStructs(testStructs) + testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath) + defer testrig.TearDownTestStructs(testStructs) var ( ctx = context.Background() diff --git a/internal/processing/workers/fromfediapi.go b/internal/processing/workers/fromfediapi.go index ce7c53388..908369ca6 100644 --- a/internal/processing/workers/fromfediapi.go +++ b/internal/processing/workers/fromfediapi.go @@ -20,18 +20,22 @@ package workers import ( "context" "errors" + "time" "codeberg.org/gruf/go-kv" "codeberg.org/gruf/go-logger/v2/level" "github.com/superseriousbusiness/gotosocial/internal/ap" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/federation/dereferencing" + "github.com/superseriousbusiness/gotosocial/internal/id" + "github.com/superseriousbusiness/gotosocial/internal/uris" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/messages" "github.com/superseriousbusiness/gotosocial/internal/processing/account" + "github.com/superseriousbusiness/gotosocial/internal/processing/common" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/util" ) @@ -44,6 +48,7 @@ type fediAPI struct { surface *Surface federate *federate account *account.Processor + common *common.Processor utils *utils } @@ -231,10 +236,7 @@ func (p *fediAPI) CreateStatus(ctx context.Context, fMsg *messages.FromFediAPI) // If pending approval is true then // status must reply to a LOCAL status // that requires approval for the reply. - pendingApproval := util.PtrOrValue( - status.PendingApproval, - false, - ) + pendingApproval := util.PtrOrZero(status.PendingApproval) switch { case pendingApproval && !status.PreApproved: @@ -242,10 +244,8 @@ func (p *fediAPI) CreateStatus(ctx context.Context, fMsg *messages.FromFediAPI) // preapproved, then just notify the account // that's being interacted with: they can // approve or deny the interaction later. - - // Notify *local* account of pending reply. - if err := p.surface.notifyPendingReply(ctx, status); err != nil { - log.Errorf(ctx, "error notifying pending reply: %v", err) + if err := p.utils.requestReply(ctx, status); err != nil { + return gtserror.Newf("error pending reply: %w", err) } // Return early. @@ -259,11 +259,33 @@ func (p *fediAPI) CreateStatus(ctx context.Context, fMsg *messages.FromFediAPI) // collection. Do the Accept immediately and // then process everything else as normal. - // Put approval in the database and - // update the status with approvedBy URI. - approval, err := p.utils.approveReply(ctx, status) - if err != nil { - return gtserror.Newf("error pre-approving reply: %w", err) + // Store an already-accepted interaction request. + id := id.NewULID() + approval := >smodel.InteractionRequest{ + ID: id, + StatusID: status.InReplyToID, + TargetAccountID: status.InReplyToAccountID, + TargetAccount: status.InReplyToAccount, + InteractingAccountID: status.AccountID, + InteractingAccount: status.Account, + InteractionURI: status.URI, + InteractionType: gtsmodel.InteractionLike, + Reply: status, + URI: uris.GenerateURIForAccept(status.InReplyToAccount.Username, id), + AcceptedAt: time.Now(), + } + + // Mark the status as now approved. + status.PendingApproval = util.Ptr(false) + status.PreApproved = false + status.ApprovedByURI = approval.URI + if err := p.state.DB.UpdateStatus( + ctx, + status, + "pending_approval", + "approved_by_uri", + ); err != nil { + return gtserror.Newf("db error updating status: %w", err) } // Send out the approval as Accept. @@ -279,6 +301,10 @@ func (p *fediAPI) CreateStatus(ctx context.Context, fMsg *messages.FromFediAPI) log.Errorf(ctx, "error updating account stats: %v", err) } + if err := p.surface.timelineAndNotifyStatus(ctx, status); err != nil { + log.Errorf(ctx, "error timelining and notifying status: %v", err) + } + if status.InReplyToID != "" { // Interaction counts changed on the replied status; uncache the // prepared version from all timelines. The status dereferencer @@ -286,10 +312,6 @@ func (p *fediAPI) CreateStatus(ctx context.Context, fMsg *messages.FromFediAPI) p.surface.invalidateStatusFromTimelines(ctx, status.InReplyToID) } - if err := p.surface.timelineAndNotifyStatus(ctx, status); err != nil { - log.Errorf(ctx, "error timelining and notifying status: %v", err) - } - return nil } @@ -320,9 +342,6 @@ func (p *fediAPI) CreatePollVote(ctx context.Context, fMsg *messages.FromFediAPI status := vote.Poll.Status status.Poll = vote.Poll - // Interaction counts changed on the source status, uncache from timelines. - p.surface.invalidateStatusFromTimelines(ctx, vote.Poll.StatusID) - if *status.Local { // Before federating it, increment the // poll vote counts on our local copy. @@ -335,6 +354,9 @@ func (p *fediAPI) CreatePollVote(ctx context.Context, fMsg *messages.FromFediAPI } } + // Interaction counts changed on the source status, uncache from timelines. + p.surface.invalidateStatusFromTimelines(ctx, vote.Poll.StatusID) + return nil } @@ -409,10 +431,7 @@ func (p *fediAPI) CreateLike(ctx context.Context, fMsg *messages.FromFediAPI) er // If pending approval is true then // fave must target a LOCAL status // that requires approval for the fave. - pendingApproval := util.PtrOrValue( - fave.PendingApproval, - false, - ) + pendingApproval := util.PtrOrZero(fave.PendingApproval) switch { case pendingApproval && !fave.PreApproved: @@ -420,10 +439,8 @@ func (p *fediAPI) CreateLike(ctx context.Context, fMsg *messages.FromFediAPI) er // preapproved, then just notify the account // that's being interacted with: they can // approve or deny the interaction later. - - // Notify *local* account of pending fave. - if err := p.surface.notifyPendingFave(ctx, fave); err != nil { - log.Errorf(ctx, "error notifying pending fave: %v", err) + if err := p.utils.requestFave(ctx, fave); err != nil { + return gtserror.Newf("error pending fave: %w", err) } // Return early. @@ -437,11 +454,33 @@ func (p *fediAPI) CreateLike(ctx context.Context, fMsg *messages.FromFediAPI) er // collection. Do the Accept immediately and // then process everything else as normal. - // Put approval in the database and - // update the fave with approvedBy URI. - approval, err := p.utils.approveFave(ctx, fave) - if err != nil { - return gtserror.Newf("error pre-approving fave: %w", err) + // Store an already-accepted interaction request. + id := id.NewULID() + approval := >smodel.InteractionRequest{ + ID: id, + StatusID: fave.StatusID, + TargetAccountID: fave.TargetAccountID, + TargetAccount: fave.TargetAccount, + InteractingAccountID: fave.AccountID, + InteractingAccount: fave.Account, + InteractionURI: fave.URI, + InteractionType: gtsmodel.InteractionLike, + Like: fave, + URI: uris.GenerateURIForAccept(fave.TargetAccount.Username, id), + AcceptedAt: time.Now(), + } + + // Mark the fave itself as now approved. + fave.PendingApproval = util.Ptr(false) + fave.PreApproved = false + fave.ApprovedByURI = approval.URI + if err := p.state.DB.UpdateStatusFave( + ctx, + fave, + "pending_approval", + "approved_by_uri", + ); err != nil { + return gtserror.Newf("db error updating status fave: %w", err) } // Send out the approval as Accept. @@ -496,10 +535,7 @@ func (p *fediAPI) CreateAnnounce(ctx context.Context, fMsg *messages.FromFediAPI // If pending approval is true then // boost must target a LOCAL status // that requires approval for the boost. - pendingApproval := util.PtrOrValue( - boost.PendingApproval, - false, - ) + pendingApproval := util.PtrOrZero(boost.PendingApproval) switch { case pendingApproval && !boost.PreApproved: @@ -507,10 +543,8 @@ func (p *fediAPI) CreateAnnounce(ctx context.Context, fMsg *messages.FromFediAPI // preapproved, then just notify the account // that's being interacted with: they can // approve or deny the interaction later. - - // Notify *local* account of pending announce. - if err := p.surface.notifyPendingAnnounce(ctx, boost); err != nil { - log.Errorf(ctx, "error notifying pending boost: %v", err) + if err := p.utils.requestAnnounce(ctx, boost); err != nil { + return gtserror.Newf("error pending boost: %w", err) } // Return early. @@ -524,11 +558,33 @@ func (p *fediAPI) CreateAnnounce(ctx context.Context, fMsg *messages.FromFediAPI // collection. Do the Accept immediately and // then process everything else as normal. - // Put approval in the database and - // update the boost with approvedBy URI. - approval, err := p.utils.approveAnnounce(ctx, boost) - if err != nil { - return gtserror.Newf("error pre-approving boost: %w", err) + // Store an already-accepted interaction request. + id := id.NewULID() + approval := >smodel.InteractionRequest{ + ID: id, + StatusID: boost.BoostOfID, + TargetAccountID: boost.BoostOfAccountID, + TargetAccount: boost.BoostOfAccount, + InteractingAccountID: boost.AccountID, + InteractingAccount: boost.Account, + InteractionURI: boost.URI, + InteractionType: gtsmodel.InteractionLike, + Announce: boost, + URI: uris.GenerateURIForAccept(boost.BoostOfAccount.Username, id), + AcceptedAt: time.Now(), + } + + // Mark the boost itself as now approved. + boost.PendingApproval = util.Ptr(false) + boost.PreApproved = false + boost.ApprovedByURI = approval.URI + if err := p.state.DB.UpdateStatus( + ctx, + boost, + "pending_approval", + "approved_by_uri", + ); err != nil { + return gtserror.Newf("db error updating status: %w", err) } // Send out the approval as Accept. @@ -729,15 +785,15 @@ func (p *fediAPI) AcceptReply(ctx context.Context, fMsg *messages.FromFediAPI) e log.Errorf(ctx, "error timelining and notifying status: %v", err) } - // Interaction counts changed on the replied-to status; - // uncache the prepared version from all timelines. - p.surface.invalidateStatusFromTimelines(ctx, status.InReplyToID) - // Send out the reply again, fully this time. if err := p.federate.CreateStatus(ctx, status); err != nil { log.Errorf(ctx, "error federating announce: %v", err) } + // Interaction counts changed on the replied-to status; + // uncache the prepared version from all timelines. + p.surface.invalidateStatusFromTimelines(ctx, status.InReplyToID) + return nil } @@ -757,15 +813,15 @@ func (p *fediAPI) AcceptAnnounce(ctx context.Context, fMsg *messages.FromFediAPI log.Errorf(ctx, "error timelining and notifying status: %v", err) } - // Interaction counts changed on the boosted status; - // uncache the prepared version from all timelines. - p.surface.invalidateStatusFromTimelines(ctx, boost.BoostOfID) - // Send out the boost again, fully this time. if err := p.federate.Announce(ctx, boost); err != nil { log.Errorf(ctx, "error federating announce: %v", err) } + // Interaction counts changed on the boosted status; + // uncache the prepared version from all timelines. + p.surface.invalidateStatusFromTimelines(ctx, boost.BoostOfID) + return nil } @@ -792,9 +848,6 @@ func (p *fediAPI) UpdateStatus(ctx context.Context, fMsg *messages.FromFediAPI) log.Errorf(ctx, "error refreshing status: %v", err) } - // Status representation was refetched, uncache from timelines. - p.surface.invalidateStatusFromTimelines(ctx, status.ID) - if status.Poll != nil && status.Poll.Closing { // If the latest status has a newly closed poll, at least compared @@ -809,6 +862,9 @@ func (p *fediAPI) UpdateStatus(ctx context.Context, fMsg *messages.FromFediAPI) log.Errorf(ctx, "error streaming status edit: %v", err) } + // Status representation was refetched, uncache from timelines. + p.surface.invalidateStatusFromTimelines(ctx, status.ID) + return nil } diff --git a/internal/processing/workers/fromfediapi_test.go b/internal/processing/workers/fromfediapi_test.go index f08f059ea..d7d7454e7 100644 --- a/internal/processing/workers/fromfediapi_test.go +++ b/internal/processing/workers/fromfediapi_test.go @@ -42,8 +42,8 @@ type FromFediAPITestSuite struct { // remote_account_1 boosts the first status of local_account_1 func (suite *FromFediAPITestSuite) TestProcessFederationAnnounce() { - testStructs := suite.SetupTestStructs() - defer suite.TearDownTestStructs(testStructs) + testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath) + defer testrig.TearDownTestStructs(testStructs) boostedStatus := >smodel.Status{} *boostedStatus = *suite.testStatuses["local_account_1_status_1"] @@ -106,8 +106,8 @@ func (suite *FromFediAPITestSuite) TestProcessFederationAnnounce() { } func (suite *FromFediAPITestSuite) TestProcessReplyMention() { - testStructs := suite.SetupTestStructs() - defer suite.TearDownTestStructs(testStructs) + testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath) + defer testrig.TearDownTestStructs(testStructs) repliedAccount := >smodel.Account{} *repliedAccount = *suite.testAccounts["local_account_1"] @@ -190,8 +190,8 @@ func (suite *FromFediAPITestSuite) TestProcessReplyMention() { } func (suite *FromFediAPITestSuite) TestProcessFave() { - testStructs := suite.SetupTestStructs() - defer suite.TearDownTestStructs(testStructs) + testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath) + defer testrig.TearDownTestStructs(testStructs) favedAccount := suite.testAccounts["local_account_1"] favedStatus := suite.testStatuses["local_account_1_status_1"] @@ -262,8 +262,8 @@ func (suite *FromFediAPITestSuite) TestProcessFave() { // This tests for an issue we were seeing where Misskey sends out faves to inboxes of people that don't own // the fave, but just follow the actor who received the fave. func (suite *FromFediAPITestSuite) TestProcessFaveWithDifferentReceivingAccount() { - testStructs := suite.SetupTestStructs() - defer suite.TearDownTestStructs(testStructs) + testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath) + defer testrig.TearDownTestStructs(testStructs) receivingAccount := suite.testAccounts["local_account_2"] favedAccount := suite.testAccounts["local_account_1"] @@ -327,8 +327,8 @@ func (suite *FromFediAPITestSuite) TestProcessFaveWithDifferentReceivingAccount( } func (suite *FromFediAPITestSuite) TestProcessAccountDelete() { - testStructs := suite.SetupTestStructs() - defer suite.TearDownTestStructs(testStructs) + testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath) + defer testrig.TearDownTestStructs(testStructs) ctx := context.Background() @@ -421,8 +421,8 @@ func (suite *FromFediAPITestSuite) TestProcessAccountDelete() { } func (suite *FromFediAPITestSuite) TestProcessFollowRequestLocked() { - testStructs := suite.SetupTestStructs() - defer suite.TearDownTestStructs(testStructs) + testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath) + defer testrig.TearDownTestStructs(testStructs) ctx := context.Background() @@ -478,8 +478,8 @@ func (suite *FromFediAPITestSuite) TestProcessFollowRequestLocked() { } func (suite *FromFediAPITestSuite) TestProcessFollowRequestUnlocked() { - testStructs := suite.SetupTestStructs() - defer suite.TearDownTestStructs(testStructs) + testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath) + defer testrig.TearDownTestStructs(testStructs) ctx := context.Background() @@ -579,8 +579,8 @@ func (suite *FromFediAPITestSuite) TestProcessFollowRequestUnlocked() { // TestCreateStatusFromIRI checks if a forwarded status can be dereferenced by the processor. func (suite *FromFediAPITestSuite) TestCreateStatusFromIRI() { - testStructs := suite.SetupTestStructs() - defer suite.TearDownTestStructs(testStructs) + testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath) + defer testrig.TearDownTestStructs(testStructs) ctx := context.Background() @@ -604,8 +604,8 @@ func (suite *FromFediAPITestSuite) TestCreateStatusFromIRI() { } func (suite *FromFediAPITestSuite) TestMoveAccount() { - testStructs := suite.SetupTestStructs() - defer suite.TearDownTestStructs(testStructs) + testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath) + defer testrig.TearDownTestStructs(testStructs) // We're gonna migrate foss_satan to our local admin account. ctx := context.Background() diff --git a/internal/processing/workers/surfacenotify_test.go b/internal/processing/workers/surfacenotify_test.go index 876f69933..dc445d0ac 100644 --- a/internal/processing/workers/surfacenotify_test.go +++ b/internal/processing/workers/surfacenotify_test.go @@ -28,6 +28,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/gtscontext" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/processing/workers" + "github.com/superseriousbusiness/gotosocial/testrig" ) type SurfaceNotifyTestSuite struct { @@ -35,8 +36,8 @@ type SurfaceNotifyTestSuite struct { } func (suite *SurfaceNotifyTestSuite) TestSpamNotifs() { - testStructs := suite.SetupTestStructs() - defer suite.TearDownTestStructs(testStructs) + testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath) + defer testrig.TearDownTestStructs(testStructs) surface := &workers.Surface{ State: testStructs.State, diff --git a/internal/processing/workers/util.go b/internal/processing/workers/util.go index 49c6183a4..bb7faffbf 100644 --- a/internal/processing/workers/util.go +++ b/internal/processing/workers/util.go @@ -26,12 +26,11 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/gtscontext" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/processing/account" "github.com/superseriousbusiness/gotosocial/internal/processing/media" "github.com/superseriousbusiness/gotosocial/internal/state" - "github.com/superseriousbusiness/gotosocial/internal/uris" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" "github.com/superseriousbusiness/gotosocial/internal/util" ) @@ -488,128 +487,143 @@ func (u *utils) decrementFollowRequestsCount( return nil } -// approveFave stores + returns an -// interactionApproval for a fave. -func (u *utils) approveFave( +// requestFave stores an interaction request +// for the given fave, and notifies the interactee. +func (u *utils) requestFave( ctx context.Context, fave *gtsmodel.StatusFave, -) (*gtsmodel.InteractionApproval, error) { - id := id.NewULID() +) error { + // Only create interaction request + // if fave targets a local status. + if fave.Status == nil || + !fave.Status.IsLocal() { + return nil + } - approval := >smodel.InteractionApproval{ - ID: id, - AccountID: fave.TargetAccountID, - Account: fave.TargetAccount, - InteractingAccountID: fave.AccountID, - InteractingAccount: fave.Account, - InteractionURI: fave.URI, - InteractionType: gtsmodel.InteractionLike, - URI: uris.GenerateURIForAccept(fave.TargetAccount.Username, id), + // Lock on the interaction URI. + unlock := u.state.ProcessingLocks.Lock(fave.URI) + defer unlock() + + // Ensure no req with this URI exists already. + req, err := u.state.DB.GetInteractionRequestByInteractionURI(ctx, fave.URI) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return gtserror.Newf("db error checking for existing interaction request: %w", err) } - if err := u.state.DB.PutInteractionApproval(ctx, approval); err != nil { - err := gtserror.Newf("db error inserting interaction approval: %w", err) - return nil, err + if req != nil { + // Interaction req already exists, + // no need to do anything else. + return nil } - // Mark the fave itself as now approved. - fave.PendingApproval = util.Ptr(false) - fave.PreApproved = false - fave.ApprovedByURI = approval.URI + // Create + store new interaction request. + req, err = typeutils.StatusFaveToInteractionRequest(ctx, fave) + if err != nil { + return gtserror.Newf("error creating interaction request: %w", err) + } - if err := u.state.DB.UpdateStatusFave( - ctx, - fave, - "pending_approval", - "approved_by_uri", - ); err != nil { - err := gtserror.Newf("db error updating status fave: %w", err) - return nil, err + if err := u.state.DB.PutInteractionRequest(ctx, req); err != nil { + return gtserror.Newf("db error storing interaction request: %w", err) } - return approval, nil + // Notify *local* account of pending announce. + if err := u.surface.notifyPendingFave(ctx, fave); err != nil { + return gtserror.Newf("error notifying pending fave: %w", err) + } + + return nil } -// approveReply stores + returns an -// interactionApproval for a reply. -func (u *utils) approveReply( +// requestReply stores an interaction request +// for the given reply, and notifies the interactee. +func (u *utils) requestReply( ctx context.Context, - status *gtsmodel.Status, -) (*gtsmodel.InteractionApproval, error) { - id := id.NewULID() + reply *gtsmodel.Status, +) error { + // Only create interaction request if + // status replies to a local status. + if reply.InReplyTo == nil || + !reply.InReplyTo.IsLocal() { + return nil + } - approval := >smodel.InteractionApproval{ - ID: id, - AccountID: status.InReplyToAccountID, - Account: status.InReplyToAccount, - InteractingAccountID: status.AccountID, - InteractingAccount: status.Account, - InteractionURI: status.URI, - InteractionType: gtsmodel.InteractionReply, - URI: uris.GenerateURIForAccept(status.InReplyToAccount.Username, id), + // Lock on the interaction URI. + unlock := u.state.ProcessingLocks.Lock(reply.URI) + defer unlock() + + // Ensure no req with this URI exists already. + req, err := u.state.DB.GetInteractionRequestByInteractionURI(ctx, reply.URI) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return gtserror.Newf("db error checking for existing interaction request: %w", err) } - if err := u.state.DB.PutInteractionApproval(ctx, approval); err != nil { - err := gtserror.Newf("db error inserting interaction approval: %w", err) - return nil, err + if req != nil { + // Interaction req already exists, + // no need to do anything else. + return nil } - // Mark the status itself as now approved. - status.PendingApproval = util.Ptr(false) - status.PreApproved = false - status.ApprovedByURI = approval.URI + // Create + store interaction request. + req, err = typeutils.StatusToInteractionRequest(ctx, reply) + if err != nil { + return gtserror.Newf("error creating interaction request: %w", err) + } - if err := u.state.DB.UpdateStatus( - ctx, - status, - "pending_approval", - "approved_by_uri", - ); err != nil { - err := gtserror.Newf("db error updating status: %w", err) - return nil, err + if err := u.state.DB.PutInteractionRequest(ctx, req); err != nil { + return gtserror.Newf("db error storing interaction request: %w", err) + } + + // Notify *local* account of pending reply. + if err := u.surface.notifyPendingReply(ctx, reply); err != nil { + return gtserror.Newf("error notifying pending reply: %w", err) } - return approval, nil + return nil } -// approveAnnounce stores + returns an -// interactionApproval for an announce. -func (u *utils) approveAnnounce( +// requestAnnounce stores an interaction request +// for the given announce, and notifies the interactee. +func (u *utils) requestAnnounce( ctx context.Context, boost *gtsmodel.Status, -) (*gtsmodel.InteractionApproval, error) { - id := id.NewULID() +) error { + // Only create interaction request if + // status announces a local status. + if boost.BoostOf == nil || + !boost.BoostOf.IsLocal() { + return nil + } + + // Lock on the interaction URI. + unlock := u.state.ProcessingLocks.Lock(boost.URI) + defer unlock() - approval := >smodel.InteractionApproval{ - ID: id, - AccountID: boost.BoostOfAccountID, - Account: boost.BoostOfAccount, - InteractingAccountID: boost.AccountID, - InteractingAccount: boost.Account, - InteractionURI: boost.URI, - InteractionType: gtsmodel.InteractionReply, - URI: uris.GenerateURIForAccept(boost.BoostOfAccount.Username, id), + // Ensure no req with this URI exists already. + req, err := u.state.DB.GetInteractionRequestByInteractionURI(ctx, boost.URI) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return gtserror.Newf("db error checking for existing interaction request: %w", err) } - if err := u.state.DB.PutInteractionApproval(ctx, approval); err != nil { - err := gtserror.Newf("db error inserting interaction approval: %w", err) - return nil, err + if req != nil { + // Interaction req already exists, + // no need to do anything else. + return nil } - // Mark the status itself as now approved. - boost.PendingApproval = util.Ptr(false) - boost.PreApproved = false - boost.ApprovedByURI = approval.URI + // Create + store interaction request. + req, err = typeutils.StatusToInteractionRequest(ctx, boost) + if err != nil { + return gtserror.Newf("error creating interaction request: %w", err) + } - if err := u.state.DB.UpdateStatus( - ctx, - boost, - "pending_approval", - "approved_by_uri", - ); err != nil { - err := gtserror.Newf("db error updating boost wrapper status: %w", err) - return nil, err + if err := u.state.DB.PutInteractionRequest(ctx, req); err != nil { + return gtserror.Newf("db error storing interaction request: %w", err) + } + + // Notify *local* account of pending announce. + if err := u.surface.notifyPendingAnnounce(ctx, boost); err != nil { + return gtserror.Newf("error notifying pending announce: %w", err) } - return approval, nil + return nil } diff --git a/internal/processing/workers/workers.go b/internal/processing/workers/workers.go index 04010a92e..d4b525783 100644 --- a/internal/processing/workers/workers.go +++ b/internal/processing/workers/workers.go @@ -22,6 +22,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/federation" "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/processing/account" + "github.com/superseriousbusiness/gotosocial/internal/processing/common" "github.com/superseriousbusiness/gotosocial/internal/processing/conversations" "github.com/superseriousbusiness/gotosocial/internal/processing/media" "github.com/superseriousbusiness/gotosocial/internal/processing/stream" @@ -38,6 +39,7 @@ type Processor struct { func New( state *state.State, + common *common.Processor, federator *federation.Federator, converter *typeutils.Converter, visFilter *visibility.Filter, @@ -82,6 +84,7 @@ func New( surface: surface, federate: federate, account: account, + common: common, utils: utils, }, fediAPI: fediAPI{ @@ -89,6 +92,7 @@ func New( surface: surface, federate: federate, account: account, + common: common, utils: utils, }, } diff --git a/internal/processing/workers/workers_test.go b/internal/processing/workers/workers_test.go index 65ed3f6b7..ffd40d8fb 100644 --- a/internal/processing/workers/workers_test.go +++ b/internal/processing/workers/workers_test.go @@ -21,19 +21,18 @@ import ( "context" "github.com/stretchr/testify/suite" - "github.com/superseriousbusiness/gotosocial/internal/cleaner" - "github.com/superseriousbusiness/gotosocial/internal/email" - "github.com/superseriousbusiness/gotosocial/internal/filter/interaction" - "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/processing" - "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/stream" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" "github.com/superseriousbusiness/gotosocial/testrig" ) +const ( + rMediaPath = "../../../testrig/media" + rTemplatePath = "../../../web/template" +) + type WorkersTestSuite struct { // standard suite interfaces suite.Suite @@ -56,23 +55,6 @@ type WorkersTestSuite struct { testListEntries map[string]*gtsmodel.ListEntry } -// TestStructs encapsulates structs needed to -// run one test in this package. Each test should -// call SetupTestStructs to get a new TestStructs, -// and defer TearDownTestStructs to close it when -// the test is complete. The reason for doing things -// this way here is to prevent the tests in this -// package from overwriting one another's processors -// and worker queues, which was causing issues -// when running all tests at once. -type TestStructs struct { - State *state.State - Processor *processing.Processor - HTTPClient *testrig.MockHTTPClient - TypeConverter *typeutils.Converter - EmailSender email.Sender -} - func (suite *WorkersTestSuite) SetupSuite() { suite.testTokens = testrig.NewTestTokens() suite.testClients = testrig.NewTestClients() @@ -132,63 +114,3 @@ func (suite *WorkersTestSuite) openStreams(ctx context.Context, processor *proce return streams } - -func (suite *WorkersTestSuite) SetupTestStructs() *TestStructs { - state := state.State{} - - state.Caches.Init() - - db := testrig.NewTestDB(&state) - state.DB = db - - storage := testrig.NewInMemoryStorage() - state.Storage = storage - typeconverter := typeutils.NewConverter(&state) - - testrig.StartTimelines( - &state, - visibility.NewFilter(&state), - typeconverter, - ) - - httpClient := testrig.NewMockHTTPClient(nil, "../../../testrig/media") - httpClient.TestRemotePeople = testrig.NewTestFediPeople() - httpClient.TestRemoteStatuses = testrig.NewTestFediStatuses() - - transportController := testrig.NewTestTransportController(&state, httpClient) - mediaManager := testrig.NewTestMediaManager(&state) - federator := testrig.NewTestFederator(&state, transportController, mediaManager) - oauthServer := testrig.NewTestOauthServer(db) - emailSender := testrig.NewEmailSender("../../../web/template/", nil) - - processor := processing.NewProcessor( - cleaner.New(&state), - typeconverter, - federator, - oauthServer, - mediaManager, - &state, - emailSender, - visibility.NewFilter(&state), - interaction.NewFilter(&state), - ) - - testrig.StartWorkers(&state, processor.Workers()) - - testrig.StandardDBSetup(db, suite.testAccounts) - testrig.StandardStorageSetup(storage, "../../../testrig/media") - - return &TestStructs{ - State: &state, - Processor: processor, - HTTPClient: httpClient, - TypeConverter: typeconverter, - EmailSender: emailSender, - } -} - -func (suite *WorkersTestSuite) TearDownTestStructs(testStructs *TestStructs) { - testrig.StandardDBTeardown(testStructs.State.DB) - testrig.StandardStorageTeardown(testStructs.State.Storage) - testrig.StopWorkers(testStructs.State) -} diff --git a/internal/timeline/prune_test.go b/internal/timeline/prune_test.go index 2cc3ef71a..e78db64e8 100644 --- a/internal/timeline/prune_test.go +++ b/internal/timeline/prune_test.go @@ -40,7 +40,7 @@ func (suite *PruneTestSuite) TestPrune() { pruned, err := suite.state.Timelines.Home.Prune(ctx, testAccountID, desiredPreparedItemsLength, desiredIndexedItemsLength) suite.NoError(err) - suite.Equal(19, pruned) + suite.Equal(20, pruned) suite.Equal(5, suite.state.Timelines.Home.GetIndexedLength(ctx, testAccountID)) } @@ -56,7 +56,7 @@ func (suite *PruneTestSuite) TestPruneTwice() { pruned, err := suite.state.Timelines.Home.Prune(ctx, testAccountID, desiredPreparedItemsLength, desiredIndexedItemsLength) suite.NoError(err) - suite.Equal(19, pruned) + suite.Equal(20, pruned) suite.Equal(5, suite.state.Timelines.Home.GetIndexedLength(ctx, testAccountID)) // Prune same again, nothing should be pruned this time. @@ -78,7 +78,7 @@ func (suite *PruneTestSuite) TestPruneTo0() { pruned, err := suite.state.Timelines.Home.Prune(ctx, testAccountID, desiredPreparedItemsLength, desiredIndexedItemsLength) suite.NoError(err) - suite.Equal(24, pruned) + suite.Equal(25, pruned) suite.Equal(0, suite.state.Timelines.Home.GetIndexedLength(ctx, testAccountID)) } @@ -95,7 +95,7 @@ func (suite *PruneTestSuite) TestPruneToInfinityAndBeyond() { pruned, err := suite.state.Timelines.Home.Prune(ctx, testAccountID, desiredPreparedItemsLength, desiredIndexedItemsLength) suite.NoError(err) suite.Equal(0, pruned) - suite.Equal(24, suite.state.Timelines.Home.GetIndexedLength(ctx, testAccountID)) + suite.Equal(25, suite.state.Timelines.Home.GetIndexedLength(ctx, testAccountID)) } func TestPruneTestSuite(t *testing.T) { diff --git a/internal/typeutils/internal.go b/internal/typeutils/internal.go index 75da4b27e..ed4ed4dd9 100644 --- a/internal/typeutils/internal.go +++ b/internal/typeutils/internal.go @@ -20,6 +20,7 @@ package typeutils import ( "context" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/uris" @@ -97,3 +98,80 @@ func (c *Converter) StatusToBoost( return boost, nil } + +func StatusToInteractionRequest( + ctx context.Context, + status *gtsmodel.Status, +) (*gtsmodel.InteractionRequest, error) { + reqID, err := id.NewULIDFromTime(status.CreatedAt) + if err != nil { + return nil, gtserror.Newf("error generating ID: %w", err) + } + + var ( + targetID string + target *gtsmodel.Status + targetAccountID string + targetAccount *gtsmodel.Account + interactionType gtsmodel.InteractionType + reply *gtsmodel.Status + announce *gtsmodel.Status + ) + + if status.InReplyToID != "" { + // It's a reply. + targetID = status.InReplyToID + target = status.InReplyTo + targetAccountID = status.InReplyToAccountID + targetAccount = status.InReplyToAccount + interactionType = gtsmodel.InteractionReply + reply = status + } else { + // It's a boost. + targetID = status.BoostOfID + target = status.BoostOf + targetAccountID = status.BoostOfAccountID + targetAccount = status.BoostOfAccount + interactionType = gtsmodel.InteractionAnnounce + announce = status + } + + return >smodel.InteractionRequest{ + ID: reqID, + CreatedAt: status.CreatedAt, + StatusID: targetID, + Status: target, + TargetAccountID: targetAccountID, + TargetAccount: targetAccount, + InteractingAccountID: status.AccountID, + InteractingAccount: status.Account, + InteractionURI: status.URI, + InteractionType: interactionType, + Reply: reply, + Announce: announce, + }, nil +} + +func StatusFaveToInteractionRequest( + ctx context.Context, + fave *gtsmodel.StatusFave, +) (*gtsmodel.InteractionRequest, error) { + reqID, err := id.NewULIDFromTime(fave.CreatedAt) + if err != nil { + return nil, gtserror.Newf("error generating ID: %w", err) + } + + return >smodel.InteractionRequest{ + ID: reqID, + CreatedAt: fave.CreatedAt, + StatusID: fave.StatusID, + Status: fave.Status, + TargetAccountID: fave.TargetAccountID, + TargetAccount: fave.TargetAccount, + InteractingAccountID: fave.AccountID, + InteractingAccount: fave.Account, + InteractionURI: fave.URI, + InteractionType: gtsmodel.InteractionLike, + Like: fave, + }, nil +} diff --git a/internal/typeutils/internaltoas.go b/internal/typeutils/internaltoas.go index 31b256b6c..03710bec8 100644 --- a/internal/typeutils/internaltoas.go +++ b/internal/typeutils/internaltoas.go @@ -1960,36 +1960,36 @@ func (c *Converter) InteractionPolicyToASInteractionPolicy( return policy, nil } -// InteractionApprovalToASAccept converts a *gtsmodel.InteractionApproval +// InteractionReqToASAccept converts a *gtsmodel.InteractionRequest // to an ActivityStreams Accept, addressed to the interacting account. -func (c *Converter) InteractionApprovalToASAccept( +func (c *Converter) InteractionReqToASAccept( ctx context.Context, - approval *gtsmodel.InteractionApproval, + req *gtsmodel.InteractionRequest, ) (vocab.ActivityStreamsAccept, error) { accept := streams.NewActivityStreamsAccept() - acceptID, err := url.Parse(approval.URI) + acceptID, err := url.Parse(req.URI) if err != nil { return nil, gtserror.Newf("invalid accept uri: %w", err) } - actorIRI, err := url.Parse(approval.Account.URI) + actorIRI, err := url.Parse(req.TargetAccount.URI) if err != nil { return nil, gtserror.Newf("invalid account uri: %w", err) } - objectIRI, err := url.Parse(approval.InteractionURI) + objectIRI, err := url.Parse(req.InteractionURI) if err != nil { return nil, gtserror.Newf("invalid target uri: %w", err) } - toIRI, err := url.Parse(approval.InteractingAccount.URI) + toIRI, err := url.Parse(req.InteractingAccount.URI) if err != nil { return nil, gtserror.Newf("invalid interacting account uri: %w", err) } // Set id to the URI of - // interactionApproval. + // interaction request. ap.SetJSONLDId(accept, acceptID) // Actor is the account that diff --git a/internal/typeutils/internaltoas_test.go b/internal/typeutils/internaltoas_test.go index 905dccfad..50719c0b4 100644 --- a/internal/typeutils/internaltoas_test.go +++ b/internal/typeutils/internaltoas_test.go @@ -1057,26 +1057,26 @@ func (suite *InternalToASTestSuite) TestPollVoteToASCreate() { }`, string(bytes)) } -func (suite *InternalToASTestSuite) TestInteractionApprovalToASAccept() { +func (suite *InternalToASTestSuite) TestInteractionReqToASAccept() { acceptingAccount := suite.testAccounts["local_account_1"] interactingAccount := suite.testAccounts["remote_account_1"] - interactionApproval := >smodel.InteractionApproval{ + req := >smodel.InteractionRequest{ ID: "01J1AKMZ8JE5NW0ZSFTRC1JJNE", CreatedAt: testrig.TimeMustParse("2022-06-09T13:12:00Z"), - UpdatedAt: testrig.TimeMustParse("2022-06-09T13:12:00Z"), - AccountID: acceptingAccount.ID, - Account: acceptingAccount, + TargetAccountID: acceptingAccount.ID, + TargetAccount: acceptingAccount, InteractingAccountID: interactingAccount.ID, InteractingAccount: interactingAccount, InteractionURI: "https://fossbros-anonymous.io/users/foss_satan/statuses/01J1AKRRHQ6MDDQHV0TP716T2K", InteractionType: gtsmodel.InteractionAnnounce, URI: "http://localhost:8080/users/the_mighty_zork/accepts/01J1AKMZ8JE5NW0ZSFTRC1JJNE", + AcceptedAt: testrig.TimeMustParse("2022-06-09T13:12:00Z"), } - accept, err := suite.typeconverter.InteractionApprovalToASAccept( + accept, err := suite.typeconverter.InteractionReqToASAccept( context.Background(), - interactionApproval, + req, ) if err != nil { suite.FailNow(err.Error()) diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index 4ed65bb08..07a4c0836 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -2592,3 +2592,74 @@ func policyValsToAPIPolicyVals(vals gtsmodel.PolicyValues) []apimodel.PolicyValu return apiVals } + +// InteractionReqToAPIInteractionReq converts the given *gtsmodel.InteractionRequest +// to an *apimodel.InteractionRequest, from the perspective of requestingAcct. +func (c *Converter) InteractionReqToAPIInteractionReq( + ctx context.Context, + req *gtsmodel.InteractionRequest, + requestingAcct *gtsmodel.Account, +) (*apimodel.InteractionRequest, error) { + // Ensure interaction request is populated. + if err := c.state.DB.PopulateInteractionRequest(ctx, req); err != nil { + err := gtserror.Newf("error populating: %w", err) + return nil, err + } + + interactingAcct, err := c.AccountToAPIAccountPublic(ctx, req.InteractingAccount) + if err != nil { + err := gtserror.Newf("error converting interacting acct: %w", err) + return nil, err + } + + interactedStatus, err := c.StatusToAPIStatus( + ctx, + req.Status, + requestingAcct, + statusfilter.FilterContextNone, + nil, + nil, + ) + if err != nil { + err := gtserror.Newf("error converting interacted status: %w", err) + return nil, err + } + + var reply *apimodel.Status + if req.InteractionType == gtsmodel.InteractionReply { + reply, err = c.StatusToAPIStatus( + ctx, + req.Reply, + requestingAcct, + statusfilter.FilterContextNone, + nil, + nil, + ) + if err != nil { + err := gtserror.Newf("error converting reply: %w", err) + return nil, err + } + } + + var acceptedAt string + if req.IsAccepted() { + acceptedAt = util.FormatISO8601(req.AcceptedAt) + } + + var rejectedAt string + if req.IsRejected() { + rejectedAt = util.FormatISO8601(req.RejectedAt) + } + + return &apimodel.InteractionRequest{ + ID: req.ID, + Type: req.InteractionType.String(), + CreatedAt: util.FormatISO8601(req.CreatedAt), + Account: interactingAcct, + Status: interactedStatus, + Reply: reply, + AcceptedAt: acceptedAt, + RejectedAt: rejectedAt, + URI: req.URI, + }, nil +} diff --git a/internal/uris/uri.go b/internal/uris/uri.go index 159508176..e1783b26c 100644 --- a/internal/uris/uri.go +++ b/internal/uris/uri.go @@ -46,7 +46,8 @@ const ( FileserverPath = "fileserver" // FileserverPath is a path component for serving attachments + media EmojiPath = "emoji" // EmojiPath represents the activitypub emoji location TagsPath = "tags" // TagsPath represents the activitypub tags location - AcceptsPath = "accepts" // AcceptsPath represents the activitypub accepts location + AcceptsPath = "accepts" // AcceptsPath represents the activitypub Accept's location + RejectsPath = "rejects" // RejectsPath represents the activitypub Reject's location ) // UserURIs contains a bunch of UserURIs and URLs for a user, host, account, etc. @@ -137,7 +138,7 @@ func GenerateURIForEmailConfirm(token string) string { return fmt.Sprintf("%s://%s/%s?token=%s", protocol, host, ConfirmEmailPath, token) } -// GenerateURIForAccept returns the AP URI for a new accept activity -- something like: +// GenerateURIForAccept returns the AP URI for a new Accept activity -- something like: // https://example.org/users/whatever_user/accepts/01F7XTH1QGBAPMGF49WJZ91XGC func GenerateURIForAccept(username string, thisAcceptID string) string { protocol := config.GetProtocol() @@ -145,6 +146,14 @@ func GenerateURIForAccept(username string, thisAcceptID string) string { return fmt.Sprintf("%s://%s/%s/%s/%s/%s", protocol, host, UsersPath, username, AcceptsPath, thisAcceptID) } +// GenerateURIForReject returns the AP URI for a new Reject activity -- something like: +// https://example.org/users/whatever_user/rejects/01F7XTH1QGBAPMGF49WJZ91XGC +func GenerateURIForReject(username string, thisRejectID string) string { + protocol := config.GetProtocol() + host := config.GetHost() + return fmt.Sprintf("%s://%s/%s/%s/%s/%s", protocol, host, UsersPath, username, RejectsPath, thisRejectID) +} + // GenerateURIsForAccount throws together a bunch of URIs for the given username, with the given protocol and host. func GenerateURIsForAccount(username string) *UserURIs { protocol := config.GetProtocol() |