diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index 99e1e5c0e..73ff22683 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -2009,6 +2009,47 @@ definitions: type: string x-go-name: PolicyValue x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model + interactionRequest: + properties: + accepted_at: + description: The timestamp that the interaction request was accepted (ISO 8601 Datetime). Field omitted if request not accepted (yet). + type: string + x-go-name: AcceptedAt + account: + $ref: '#/definitions/account' + created_at: + description: The timestamp of the interaction request (ISO 8601 Datetime) + type: string + x-go-name: CreatedAt + id: + description: The id of the interaction request in the database. + type: string + x-go-name: ID + rejected_at: + description: The timestamp that the interaction request was rejected (ISO 8601 Datetime). Field omitted if request not rejected (yet). + type: string + x-go-name: RejectedAt + reply: + $ref: '#/definitions/status' + status: + $ref: '#/definitions/status' + type: + description: |- + 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 + x-go-name: Type + uri: + description: URI of the Accept or Reject. Only set if accepted_at or rejected_at is set, else omitted. + type: string + x-go-name: URI + title: InteractionRequest represents a pending, approved, or rejected interaction of type favourite, reply, or reblog. + type: object + x-go-name: InteractionRequest + x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model list: properties: id: @@ -7521,6 +7562,177 @@ paths: summary: Update default interaction policies per visibility level for new statuses created by you. tags: - interaction_policies + /api/v1/interaction_requests: + get: + description: |- + ``` + ; rel="next", ; rel="prev" + ```` + operationId: getInteractionRequests + parameters: + - description: If set, then only interactions targeting the given status_id will be included in the results. + in: query + name: status_id + type: string + - default: true + 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 + name: favourites + type: boolean + - default: true + 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 + name: replies + type: boolean + - default: true + 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 + name: reblogs + type: boolean + - 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 + name: max_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 + name: since_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 + name: min_id + type: string + - default: 40 + description: Number of interaction requests to return. + in: query + maximum: 80 + minimum: 1 + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: "" + headers: + Link: + description: Links to the next and previous queries. + type: string + schema: + items: + $ref: '#/definitions/interactionRequest' + type: array + "400": + description: bad request + "401": + description: unauthorized + "404": + description: not found + "406": + description: not acceptable + "500": + description: internal server error + security: + - OAuth2 Bearer: + - read:notifications + summary: Get an array of interactions requested on your statuses by other accounts, and pending your approval. + tags: + - interaction_requests + /api/v1/interaction_requests/{id}: + get: + operationId: getInteractionRequest + parameters: + - description: ID of the interaction request targeting you. + in: path + name: id + required: true + type: string + produces: + - application/json + 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 + security: + - OAuth2 Bearer: + - read:notifications + summary: Get interaction request with the given ID. + tags: + - interaction_requests + /api/v1/interaction_requests/{id}/authorize: + post: + operationId: authorizeInteractionRequest + parameters: + - description: ID of the interaction request targeting you. + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + 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 + security: + - OAuth2 Bearer: + - write:statuses + summary: Accept/authorize/approve an interaction request with the given ID. + tags: + - interaction_requests + /api/v1/interaction_requests/{id}/reject: + post: + operationId: rejectInteractionRequest + parameters: + - description: ID of the interaction request targeting you. + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + 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 + security: + - OAuth2 Bearer: + - write:statuses + summary: Reject an interaction request with the given ID. + tags: + - interaction_requests /api/v1/lists: get: operationId: lists 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 . + +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 . + +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 . + +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. +// +// ``` +// ; rel="next", ; 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 . + +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 . + +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 . + +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, - "URI", - func(approval *gtsmodel.InteractionApproval) error { - return r. - newInteractionApprovalQ(approval). - Where("? = ?", bun.Ident("interaction_approval.uri"), uri). + "InteractionURI", + func(request *gtsmodel.InteractionRequest) error { + return i. + newInteractionRequestQ(request). + Where("? = ?", bun.Ident("interaction_request.interaction_uri"), uri). Scan(ctx) }, uri, ) } -func (r *interactionDB) getInteractionApproval( +func (i *interactionDB) GetInteractionRequestByURI(ctx context.Context, uri string) (*gtsmodel.InteractionRequest, error) { + return i.getInteractionRequest( + ctx, + "URI", + func(request *gtsmodel.InteractionRequest) error { + return i. + newInteractionRequestQ(request). + Where("? = ?", bun.Ident("interaction_request.uri"), uri). + Scan(ctx) + }, + uri, + ) +} + +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), - approval.AccountID, + req.StatusID, ) if err != nil { - errs.Appendf("error populating interactionApproval account: %w", err) + errs.Appendf("error populating interactionRequest target: %w", err) } } - if approval.InteractingAccount == nil { - // InteractingAccount is not set, fetch from the database. - approval.InteractingAccount, err = r.state.DB.GetAccountByID( + if req.TargetAccount == nil { + // Target account is not set, fetch from the database. + req.TargetAccount, err = i.state.DB.GetAccountByID( gtscontext.SetBarebones(ctx), - approval.InteractingAccountID, + req.TargetAccountID, ) if err != nil { - errs.Appendf("error populating interactionApproval interacting account: %w", err) + errs.Appendf("error populating interactionRequest target account: %w", err) + } + } + + if req.InteractingAccount == nil { + // InteractingAccount is not set, fetch from the database. + req.InteractingAccount, err = i.state.DB.GetAccountByID( + gtscontext.SetBarebones(ctx), + req.InteractingAccountID, + ) + if err != nil { + 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 + }) +} - _, err := r.db.NewDelete(). - TableExpr("? AS ?", bun.Ident("interaction_approvals"), bun.Ident("interaction_approval")). - Where("? = ?", bun.Ident("interaction_approval.id"), id). +func (i *interactionDB) DeleteInteractionRequestByID(ctx context.Context, id string) error { + defer i.state.Caches.DB.InteractionRequest.Invalidate("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 . + +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 . + +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 . + +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 . - -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 approval == nil { - err := gtserror.Newf("approval %s not found", approvalID) + 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) } - accept, err := p.converter.InteractionApprovalToASAccept(ctx, approval) + 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.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 . + +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 . + +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 . + +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 . + +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 . + +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 . + +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 . + +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() - - 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), +) error { + // Only create interaction request + // if fave targets a local status. + if fave.Status == nil || + !fave.Status.IsLocal() { + return nil } - if err := u.state.DB.PutInteractionApproval(ctx, approval); err != nil { - err := gtserror.Newf("db error inserting interaction approval: %w", err) - return nil, err + // 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) } - // Mark the fave itself as now approved. - fave.PendingApproval = util.Ptr(false) - fave.PreApproved = false - fave.ApprovedByURI = approval.URI - - 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 req != nil { + // Interaction req already exists, + // no need to do anything else. + return nil } - return approval, nil + // 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.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.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() - - 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), + reply *gtsmodel.Status, +) error { + // Only create interaction request if + // status replies to a local status. + if reply.InReplyTo == nil || + !reply.InReplyTo.IsLocal() { + return nil } - if err := u.state.DB.PutInteractionApproval(ctx, approval); err != nil { - err := gtserror.Newf("db error inserting interaction approval: %w", err) - return nil, err + // 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) } - // Mark the status itself as now approved. - status.PendingApproval = util.Ptr(false) - status.PreApproved = false - status.ApprovedByURI = approval.URI - - 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 req != nil { + // Interaction req already exists, + // no need to do anything else. + return nil } - return approval, nil + // 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.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 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() - - 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), +) error { + // Only create interaction request if + // status announces a local status. + if boost.BoostOf == nil || + !boost.BoostOf.IsLocal() { + return nil } - if err := u.state.DB.PutInteractionApproval(ctx, approval); err != nil { - err := gtserror.Newf("db error inserting interaction approval: %w", err) - return nil, err + // Lock on the interaction URI. + unlock := u.state.ProcessingLocks.Lock(boost.URI) + defer unlock() + + // 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) } - // Mark the status itself as now approved. - boost.PendingApproval = util.Ptr(false) - boost.PreApproved = false - boost.ApprovedByURI = approval.URI - - 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 req != nil { + // Interaction req already exists, + // no need to do anything else. + return nil } - return approval, nil + // 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.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 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() diff --git a/test/envparsing.sh b/test/envparsing.sh index a32107568..32842bee8 100755 --- a/test/envparsing.sh +++ b/test/envparsing.sh @@ -46,7 +46,7 @@ EXPECT=$(cat << "EOF" "follow-request-mem-ratio": 2, "in-reply-to-ids-mem-ratio": 3, "instance-mem-ratio": 1, - "interaction-approval-mem-ratio": 1, + "interaction-request-mem-ratio": 1, "list-entry-mem-ratio": 2, "list-mem-ratio": 1, "marker-mem-ratio": 0.5, diff --git a/testrig/db.go b/testrig/db.go index e6b40c846..92d963e89 100644 --- a/testrig/db.go +++ b/testrig/db.go @@ -41,6 +41,7 @@ var testModels = []interface{}{ >smodel.FilterStatus{}, >smodel.Follow{}, >smodel.FollowRequest{}, + >smodel.InteractionRequest{}, >smodel.List{}, >smodel.ListEntry{}, >smodel.Marker{}, @@ -346,6 +347,12 @@ func StandardDBSetup(db db.DB, accounts map[string]*gtsmodel.Account) { } } + for _, v := range NewTestInteractionRequests() { + if err := db.Put(ctx, v); err != nil { + log.Panic(nil, err) + } + } + if err := db.CreateInstanceAccount(ctx); err != nil { log.Panic(nil, err) } diff --git a/testrig/testmodels.go b/testrig/testmodels.go index 3cf785908..943fb7396 100644 --- a/testrig/testmodels.go +++ b/testrig/testmodels.go @@ -1509,6 +1509,31 @@ func NewTestStatuses() map[string]*gtsmodel.Status { ActivityStreamsType: ap.ObjectNote, PendingApproval: util.Ptr(false), }, + "admin_account_status_5": { + ID: "01J5QVB9VC76NPPRQ207GG4DRZ", + URI: "http://localhost:8080/users/admin/statuses/01J5QVB9VC76NPPRQ207GG4DRZ", + URL: "http://localhost:8080/@admin/statuses/01J5QVB9VC76NPPRQ207GG4DRZ", + Content: `

Hi @1happyturtle, can I reply?

`, + Text: "Hi @1happyturtle, can I reply?", + CreatedAt: TimeMustParse("2024-02-20T12:41:37+02:00"), + UpdatedAt: TimeMustParse("2024-02-20T12:41:37+02:00"), + Local: util.Ptr(true), + AccountURI: "http://localhost:8080/users/admin", + MentionIDs: []string{"01J5QVP69ANF1K4WHES6GA4WXP"}, + AccountID: "01F8MH17FWEB39HZJ76B6VXSKF", + InReplyToID: "01F8MHC8VWDRBQR0N1BATDDEM5", + InReplyToAccountID: "01F8MH5NBDF2MV7CTC4Q5128HF", + InReplyToURI: "http://localhost:8080/users/1happyturtle/statuses/01F8MHC8VWDRBQR0N1BATDDEM5", + BoostOfID: "", + BoostOfAccountID: "", + ThreadID: "01HCWE4P0EW9HBA5WHW97D5YV0", + Visibility: gtsmodel.VisibilityUnlocked, + Sensitive: util.Ptr(false), + CreatedWithApplicationID: "01F8MGXQRHYF5QPMTMXP78QC2F", + Federated: util.Ptr(true), + ActivityStreamsType: ap.ObjectNote, + PendingApproval: util.Ptr(true), + }, "local_account_1_status_1": { ID: "01F8MHAMCHF6Y650WCRSCP4WMY", URI: "http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY", @@ -2298,6 +2323,10 @@ func NewTestThreadToStatus() []*gtsmodel.ThreadToStatus { ThreadID: "01HCWE7ZNC2SS4P05WA5QYED23", StatusID: "01G20ZM733MGN8J344T4ZDDFY1", }, + { + ThreadID: "01HCWE4P0EW9HBA5WHW97D5YV0", + StatusID: "01J5QVB9VC76NPPRQ207GG4DRZ", + }, } } @@ -2352,6 +2381,18 @@ func NewTestMentions() map[string]*gtsmodel.Mention { TargetAccountURI: "http://localhost:8080/users/the_mighty_zork", TargetAccountURL: "http://localhost:8080/@the_mighty_zork", }, + "admin_account_mention_turtle": { + ID: "01J5QVP69ANF1K4WHES6GA4WXP", + StatusID: "01J5QVB9VC76NPPRQ207GG4DRZ", + CreatedAt: TimeMustParse("2024-02-20T12:41:37+02:00"), + UpdatedAt: TimeMustParse("2024-02-20T12:41:37+02:00"), + OriginAccountID: "01F8MH17FWEB39HZJ76B6VXSKF", + OriginAccountURI: "http://localhost:8080/users/admin", + TargetAccountID: "01F8MH5NBDF2MV7CTC4Q5128HF", + NameString: "@1happyturtle", + TargetAccountURI: "http://localhost:8080/users/1happyturtle", + TargetAccountURL: "http://localhost:8080/@1happyturtle", + }, "remote_account_2_mention_admin": { ID: "01HE7XQNMKTVC8MNPCE1JGK4J3", StatusID: "01HE7XJ1CG84TBKH5V9XKBVGF5", @@ -3430,6 +3471,20 @@ func NewTestUserMutes() map[string]*gtsmodel.UserMute { return map[string]*gtsmodel.UserMute{} } +func NewTestInteractionRequests() map[string]*gtsmodel.InteractionRequest { + return map[string]*gtsmodel.InteractionRequest{ + "admin_account_reply_turtle": { + ID: "01J5QVXCCEATJYSXM9H6MZT4JR", + CreatedAt: TimeMustParse("2024-02-20T12:41:37+02:00"), + StatusID: "01F8MHC8VWDRBQR0N1BATDDEM5", + TargetAccountID: "01F8MH5NBDF2MV7CTC4Q5128HF", + InteractingAccountID: "01F8MH17FWEB39HZJ76B6VXSKF", + InteractionURI: "http://localhost:8080/users/admin/statuses/01J5QVB9VC76NPPRQ207GG4DRZ", + InteractionType: gtsmodel.InteractionReply, + }, + } +} + // GetSignatureForActivity prepares a mock HTTP request as if it were going to deliver activity to destination signed for privkey and pubKeyID, signs the request and returns the header values. func GetSignatureForActivity(activity pub.Activity, pubKeyID string, privkey *rsa.PrivateKey, destination *url.URL) (signatureHeader string, digestHeader string, dateHeader string) { // convert the activity into json bytes diff --git a/testrig/teststructs.go b/testrig/teststructs.go new file mode 100644 index 000000000..b88e37d55 --- /dev/null +++ b/testrig/teststructs.go @@ -0,0 +1,121 @@ +// 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 . + +package testrig + +import ( + "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/processing" + "github.com/superseriousbusiness/gotosocial/internal/processing/common" + "github.com/superseriousbusiness/gotosocial/internal/state" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" +) + +// TestStructs encapsulates structs needed to +// run one test independently. 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 a +// 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 + Common *common.Processor + Processor *processing.Processor + HTTPClient *MockHTTPClient + TypeConverter *typeutils.Converter + EmailSender email.Sender +} + +func SetupTestStructs( + rMediaPath string, + rTemplatePath string, +) *TestStructs { + state := state.State{} + + state.Caches.Init() + + db := NewTestDB(&state) + state.DB = db + + storage := NewInMemoryStorage() + state.Storage = storage + typeconverter := typeutils.NewConverter(&state) + visFilter := visibility.NewFilter(&state) + intFilter := interaction.NewFilter(&state) + + StartTimelines( + &state, + visFilter, + typeconverter, + ) + + httpClient := NewMockHTTPClient(nil, rMediaPath) + httpClient.TestRemotePeople = NewTestFediPeople() + httpClient.TestRemoteStatuses = NewTestFediStatuses() + + transportController := NewTestTransportController(&state, httpClient) + mediaManager := NewTestMediaManager(&state) + federator := NewTestFederator(&state, transportController, mediaManager) + oauthServer := NewTestOauthServer(db) + emailSender := NewEmailSender(rTemplatePath, nil) + + common := common.New( + &state, + mediaManager, + typeconverter, + federator, + visFilter, + ) + + processor := processing.NewProcessor( + cleaner.New(&state), + typeconverter, + federator, + oauthServer, + mediaManager, + &state, + emailSender, + visFilter, + intFilter, + ) + + StartWorkers(&state, processor.Workers()) + + StandardDBSetup(db, nil) + StandardStorageSetup(storage, rMediaPath) + + return &TestStructs{ + State: &state, + Common: &common, + Processor: processor, + HTTPClient: httpClient, + TypeConverter: typeconverter, + EmailSender: emailSender, + } +} + +func TearDownTestStructs(testStructs *TestStructs) { + StandardDBTeardown(testStructs.State.DB) + StandardStorageTeardown(testStructs.State.Storage) + StopWorkers(testStructs.State) +} diff --git a/web/source/package.json b/web/source/package.json index bce3546d2..3c239419e 100644 --- a/web/source/package.json +++ b/web/source/package.json @@ -14,6 +14,7 @@ "@reduxjs/toolkit": "^1.8.6", "ariakit": "^2.0.0-next.41", "get-by-dot": "^1.0.2", + "html-to-text": "^9.0.5", "is-valid-domain": "^0.1.6", "js-file-download": "^0.4.12", "langs": "^2.0.0", @@ -45,6 +46,7 @@ "@browserify/envify": "^6.0.0", "@browserify/uglifyify": "^6.0.0", "@joepie91/eslint-config": "^1.1.1", + "@types/html-to-text": "^9.0.4", "@types/is-valid-domain": "^0.0.2", "@types/papaparse": "^5.3.9", "@types/parse-link-header": "^2.0.3", diff --git a/web/source/settings/components/status.tsx b/web/source/settings/components/status.tsx index ba38e161c..d2116e60d 100644 --- a/web/source/settings/components/status.tsx +++ b/web/source/settings/components/status.tsx @@ -220,7 +220,7 @@ function StatusMediaEntry({ media }: { media: MediaAttachment }) { function StatusFooter({ status }: { status: StatusType }) { return ( -