[feature] Interaction requests client api + settings panel (#3215)

* [feature] Interaction requests client api + settings panel

* test accept / reject

* fmt

* don't pin rejected interaction

* use single db model for interaction accept, reject, and request

* swaggor

* env sharting

* append errors

* remove ErrNoEntries checks

* change intReqID to reqID

* rename "pend" to "request"

* markIntsPending -> mark interactionsPending

* use log instead of returning error when rejecting interaction

* empty migration

* jolly renaming

* make interactionURI unique again

* swag grr

* remove unnecessary locks

* invalidate as last step
This commit is contained in:
tobi 2024-08-24 11:49:37 +02:00 committed by GitHub
parent 8e5a72ac5c
commit f23f04e0b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
72 changed files with 4446 additions and 663 deletions

View file

@ -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: |-
```
<https://example.org/api/v1/interaction_requests?limit=80&max_id=01FC0SKA48HNSVR6YKZCQGS2V8>; rel="next", <https://example.org/api/v1/interaction_requests?limit=80&min_id=01FC0SKW5JK2Q4EVAV2B462YY0>; rel="prev"
````
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

View file

@ -25,7 +25,7 @@
"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

View file

@ -38,6 +38,7 @@
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),

View file

@ -0,0 +1,104 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package interactionrequests
import (
"net/http"
"github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
// InteractionRequestAuthorizePOSTHandler swagger:operation POST /api/v1/interaction_requests/{id}/authorize authorizeInteractionRequest
//
// Accept/authorize/approve an interaction request with the given ID.
//
// ---
// tags:
// - interaction_requests
//
// produces:
// - application/json
//
// parameters:
// -
// name: id
// type: string
// description: ID of the interaction request targeting you.
// in: path
// required: true
//
// security:
// - OAuth2 Bearer:
// - write:statuses
//
// responses:
// '200':
// name: Approval.
// description: The now-approved interaction request.
// schema:
// "$ref": "#/definitions/interactionRequest"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) InteractionRequestAuthorizePOSTHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
errWithCode := gtserror.NewErrorUnauthorized(err, err.Error())
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
errWithCode := gtserror.NewErrorNotAcceptable(err, err.Error())
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
reqID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiReq, errWithCode := m.processor.InteractionRequests().Accept(
c.Request.Context(),
authed.Account,
reqID,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiutil.JSON(c, http.StatusOK, apiReq)
}

View file

@ -0,0 +1,96 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package interactionrequests
import (
"net/http"
"github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
// InteractionRequestGETHandler swagger:operation GET /api/v1/interaction_requests/{id} getInteractionRequest
//
// Get interaction request with the given ID.
//
// ---
// tags:
// - interaction_requests
//
// produces:
// - application/json
//
// parameters:
// -
// name: id
// type: string
// description: ID of the interaction request targeting you.
// in: path
// required: true
//
// security:
// - OAuth2 Bearer:
// - read:notifications
//
// responses:
// '200':
// description: Interaction request.
// schema:
// "$ref": "#/definitions/interactionRequest"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) InteractionRequestGETHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
id, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
intReq, errWithCode := m.processor.InteractionRequests().GetOne(
c.Request.Context(),
authed.Account,
id,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiutil.JSON(c, http.StatusOK, intReq)
}

View file

@ -0,0 +1,211 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package interactionrequests
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/internal/paging"
)
// InteractionRequestsGETHandler swagger:operation GET /api/v1/interaction_requests getInteractionRequests
//
// Get an array of interactions requested on your statuses by other accounts, and pending your approval.
//
// ```
// <https://example.org/api/v1/interaction_requests?limit=80&max_id=01FC0SKA48HNSVR6YKZCQGS2V8>; rel="next", <https://example.org/api/v1/interaction_requests?limit=80&min_id=01FC0SKW5JK2Q4EVAV2B462YY0>; rel="prev"
// ````
//
// ---
// tags:
// - interaction_requests
//
// produces:
// - application/json
//
// parameters:
// -
// name: status_id
// type: string
// description: >-
// If set, then only interactions targeting the given status_id will be included in the results.
// in: query
// required: false
// -
// name: favourites
// type: boolean
// description: >-
// If true or not set, pending favourites will be included in the results.
// At least one of favourites, replies, and reblogs must be true.
// in: query
// required: false
// default: true
// -
// name: replies
// type: boolean
// description: >-
// If true or not set, pending replies will be included in the results.
// At least one of favourites, replies, and reblogs must be true.
// in: query
// required: false
// default: true
// -
// name: reblogs
// type: boolean
// description: >-
// If true or not set, pending reblogs will be included in the results.
// At least one of favourites, replies, and reblogs must be true.
// in: query
// required: false
// default: true
// -
// name: max_id
// type: string
// description: >-
// Return only interaction requests *OLDER* than the given max ID.
// The interaction with the specified ID will not be included in the response.
// in: query
// required: false
// -
// name: since_id
// type: string
// description: >-
// Return only interaction requests *NEWER* than the given since ID.
// The interaction with the specified ID will not be included in the response.
// in: query
// required: false
// -
// name: min_id
// type: string
// description: >-
// Return only interaction requests *IMMEDIATELY NEWER* than the given min ID.
// The interaction with the specified ID will not be included in the response.
// in: query
// required: false
// -
// name: limit
// type: integer
// description: Number of interaction requests to return.
// default: 40
// minimum: 1
// maximum: 80
// in: query
// required: false
//
// security:
// - OAuth2 Bearer:
// - read:notifications
//
// responses:
// '200':
// headers:
// Link:
// type: string
// description: Links to the next and previous queries.
// schema:
// type: array
// items:
// "$ref": "#/definitions/interactionRequest"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) InteractionRequestsGETHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
includeLikes, errWithCode := apiutil.ParseInteractionFavourites(
c.Query(apiutil.InteractionFavouritesKey), true,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
includeReplies, errWithCode := apiutil.ParseInteractionReplies(
c.Query(apiutil.InteractionRepliesKey), true,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
includeBoosts, errWithCode := apiutil.ParseInteractionReblogs(
c.Query(apiutil.InteractionReblogsKey), true,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
if !includeLikes && !includeReplies && !includeBoosts {
const text = "at least one of favourites, replies, or boosts must be true"
errWithCode := gtserror.NewErrorBadRequest(errors.New(text), text)
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
page, errWithCode := paging.ParseIDPage(c,
1, // min limit
80, // max limit
40, // default limit
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
resp, errWithCode := m.processor.InteractionRequests().GetPage(
c.Request.Context(),
authed.Account,
c.Query(apiutil.InteractionStatusIDKey),
includeLikes,
includeReplies,
includeBoosts,
page,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
if resp.LinkHeader != "" {
c.Header("Link", resp.LinkHeader)
}
apiutil.JSON(c, http.StatusOK, resp.Items)
}

View file

@ -0,0 +1,50 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package interactionrequests
import (
"net/http"
"github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/processing"
)
const (
BasePath = "/v1/interaction_requests"
BasePathWithID = BasePath + "/:" + apiutil.IDKey
AuthorizePath = BasePathWithID + "/authorize"
RejectPath = BasePathWithID + "/reject"
)
type Module struct {
processor *processing.Processor
}
func New(processor *processing.Processor) *Module {
return &Module{
processor: processor,
}
}
func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) {
attachHandler(http.MethodGet, BasePath, m.InteractionRequestsGETHandler)
attachHandler(http.MethodGet, BasePathWithID, m.InteractionRequestGETHandler)
attachHandler(http.MethodPost, AuthorizePath, m.InteractionRequestAuthorizePOSTHandler)
attachHandler(http.MethodPost, RejectPath, m.InteractionRequestRejectPOSTHandler)
}

View file

@ -0,0 +1,104 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package interactionrequests
import (
"net/http"
"github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
// InteractionRequestRejectPOSTHandler swagger:operation POST /api/v1/interaction_requests/{id}/reject rejectInteractionRequest
//
// Reject an interaction request with the given ID.
//
// ---
// tags:
// - interaction_requests
//
// produces:
// - application/json
//
// parameters:
// -
// name: id
// type: string
// description: ID of the interaction request targeting you.
// in: path
// required: true
//
// security:
// - OAuth2 Bearer:
// - write:statuses
//
// responses:
// '200':
// name: Rejection.
// description: The now-rejected interaction request.
// schema:
// "$ref": "#/definitions/interactionRequest"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) InteractionRequestRejectPOSTHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
errWithCode := gtserror.NewErrorUnauthorized(err, err.Error())
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
if authed.Account.IsMoving() {
apiutil.ForbiddenAfterMove(c)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
errWithCode := gtserror.NewErrorNotAcceptable(err, err.Error())
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
reqID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiReq, errWithCode := m.processor.InteractionRequests().Reject(
c.Request.Context(),
authed.Account,
reqID,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiutil.JSON(c, http.StatusOK, apiReq)
}

View file

@ -0,0 +1,46 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package model
// InteractionRequest represents a pending, approved, or rejected interaction of type favourite, reply, or reblog.
//
// swagger:model interactionRequest
type InteractionRequest struct {
// The id of the interaction request in the database.
ID string `json:"id"`
// The type of interaction that this interaction request pertains to.
//
// `favourite` - Someone favourited a status.
// `reply` - Someone replied to a status.
// `reblog` - Someone reblogged / boosted a status.
Type string `json:"type"`
// The timestamp of the interaction request (ISO 8601 Datetime)
CreatedAt string `json:"created_at"`
// The account that performed the interaction.
Account *Account `json:"account"`
// Status targeted by the requested interaction.
Status *Status `json:"status"`
// If type=reply, this field will be set to the reply that is awaiting approval. If type=favourite, or type=reblog, the field will be omitted.
Reply *Status `json:"reply,omitempty"`
// The timestamp that the interaction request was accepted (ISO 8601 Datetime). Field omitted if request not accepted (yet).
AcceptedAt string `json:"accepted_at,omitempty"`
// The timestamp that the interaction request was rejected (ISO 8601 Datetime). Field omitted if request not rejected (yet).
RejectedAt string `json:"rejected_at,omitempty"`
// URI of the Accept or Reject. Only set if accepted_at or rejected_at is set, else omitted.
URI string `json:"uri,omitempty"`
}

View file

@ -91,6 +91,13 @@
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.
*/

View file

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

23
internal/cache/db.go vendored
View file

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

View file

@ -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(&gtsmodel.InteractionApproval{
func sizeofInteractionRequest() uintptr {
return uintptr(size.Of(&gtsmodel.InteractionRequest{
ID: exampleID,
CreatedAt: exampleTime,
UpdatedAt: exampleTime,
AccountID: exampleID,
StatusID: exampleID,
TargetAccountID: exampleID,
InteractingAccountID: exampleID,
InteractionURI: exampleURI,
InteractionType: gtsmodel.InteractionAnnounce,
URI: exampleURI,
AcceptedAt: exampleTime,
}))
}

View file

@ -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"`

View file

@ -181,7 +181,7 @@
FollowRequestIDsMemRatio: 2,
InReplyToIDsMemRatio: 3,
InstanceMemRatio: 1,
InteractionApprovalMemRatio: 1,
InteractionRequestMemRatio: 1,
ListMemRatio: 1,
ListEntryMemRatio: 2,
MarkerMemRatio: 0.5,

View file

@ -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) {

View file

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

View file

@ -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() {

View file

@ -56,6 +56,7 @@ type BunDBStandardTestSuite struct {
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() {

View file

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

View file

@ -19,10 +19,14 @@
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 {
if req.TargetAccount == nil {
// Target account is not set, fetch from the database.
req.TargetAccount, err = i.state.DB.GetAccountByID(
gtscontext.SetBarebones(ctx),
req.TargetAccountID,
)
if err != nil {
errs.Appendf("error populating interactionRequest target account: %w", err)
}
}
if req.InteractingAccount == nil {
// InteractingAccount is not set, fetch from the database.
approval.InteractingAccount, err = r.state.DB.GetAccountByID(
req.InteractingAccount, err = i.state.DB.GetAccountByID(
gtscontext.SetBarebones(ctx),
approval.InteractingAccountID,
req.InteractingAccountID,
)
if err != nil {
errs.Appendf("error populating interactionApproval interacting account: %w", err)
errs.Appendf("error populating interactionRequest interacting account: %w", err)
}
}
// Depending on the interaction type, *try* to populate
// the related model, but don't error if this is not
// possible, as it may have just already been deleted
// by its owner and we haven't cleaned up yet.
switch req.InteractionType {
case gtsmodel.InteractionLike:
req.Like, err = i.state.DB.GetStatusFaveByURI(ctx, req.InteractionURI)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
errs.Appendf("error populating interactionRequest Like: %w", err)
}
case gtsmodel.InteractionReply:
req.Reply, err = i.state.DB.GetStatusByURI(ctx, req.InteractionURI)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
errs.Appendf("error populating interactionRequest Reply: %w", err)
}
case gtsmodel.InteractionAnnounce:
req.Announce, err = i.state.DB.GetStatusByURI(ctx, req.InteractionURI)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
errs.Appendf("error populating interactionRequest Announce: %w", err)
}
}
return errs.Combine()
}
func (r *interactionDB) PutInteractionApproval(ctx context.Context, approval *gtsmodel.InteractionApproval) error {
return r.state.Caches.DB.InteractionApproval.Store(approval, func() error {
_, err := r.db.NewInsert().Model(approval).Exec(ctx)
func (i *interactionDB) PutInteractionRequest(ctx context.Context, request *gtsmodel.InteractionRequest) error {
return i.state.Caches.DB.InteractionRequest.Store(request, func() error {
_, err := i.db.NewInsert().Model(request).Exec(ctx)
return err
})
}
func (r *interactionDB) DeleteInteractionApprovalByID(ctx context.Context, id string) error {
defer r.state.Caches.DB.InteractionApproval.Invalidate("ID", id)
func (i *interactionDB) UpdateInteractionRequest(ctx context.Context, request *gtsmodel.InteractionRequest, columns ...string) error {
return i.state.Caches.DB.InteractionRequest.Store(request, func() error {
_, err := i.db.
NewUpdate().
Model(request).
Where("? = ?", bun.Ident("interaction_request.id"), request.ID).
Column(columns...).
Exec(ctx)
return err
})
}
_, 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
}

View file

@ -0,0 +1,261 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package bundb_test
import (
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/paging"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
type InteractionTestSuite struct {
BunDBStandardTestSuite
}
func (suite *InteractionTestSuite) markInteractionsPending(
ctx context.Context,
statusID string,
) (pendingCount int) {
// Get replies of given status.
replies, err := suite.state.DB.GetStatusReplies(ctx, statusID)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
suite.FailNow(err.Error())
}
// Mark each reply as pending approval.
for _, reply := range replies {
reply.PendingApproval = util.Ptr(true)
if err := suite.state.DB.UpdateStatus(
ctx,
reply,
"pending_approval",
); err != nil {
suite.FailNow(err.Error())
}
// Put an interaction request
// in the DB for this reply.
req, err := typeutils.StatusToInteractionRequest(ctx, reply)
if err != nil {
suite.FailNow(err.Error())
}
if err := suite.state.DB.PutInteractionRequest(ctx, req); err != nil {
suite.FailNow(err.Error())
}
pendingCount++
}
// Get boosts of given status.
boosts, err := suite.state.DB.GetStatusBoosts(ctx, statusID)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
suite.FailNow(err.Error())
}
// Mark each boost as pending approval.
for _, boost := range boosts {
boost.PendingApproval = util.Ptr(true)
if err := suite.state.DB.UpdateStatus(
ctx,
boost,
"pending_approval",
); err != nil {
suite.FailNow(err.Error())
}
// Put an interaction request
// in the DB for this boost.
req, err := typeutils.StatusToInteractionRequest(ctx, boost)
if err != nil {
suite.FailNow(err.Error())
}
if err := suite.state.DB.PutInteractionRequest(ctx, req); err != nil {
suite.FailNow(err.Error())
}
pendingCount++
}
// Get faves of given status.
faves, err := suite.state.DB.GetStatusFaves(ctx, statusID)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
suite.FailNow(err.Error())
}
// Mark each fave as pending approval.
for _, fave := range faves {
fave.PendingApproval = util.Ptr(true)
if err := suite.state.DB.UpdateStatusFave(
ctx,
fave,
"pending_approval",
); err != nil {
suite.FailNow(err.Error())
}
// Put an interaction request
// in the DB for this fave.
req, err := typeutils.StatusFaveToInteractionRequest(ctx, fave)
if err != nil {
suite.FailNow(err.Error())
}
if err := suite.state.DB.PutInteractionRequest(ctx, req); err != nil {
suite.FailNow(err.Error())
}
pendingCount++
}
return pendingCount
}
func (suite *InteractionTestSuite) TestGetPending() {
var (
testStatus = suite.testStatuses["local_account_1_status_1"]
ctx = context.Background()
acctID = suite.testAccounts["local_account_1"].ID
statusID = ""
likes = true
replies = true
boosts = true
page = &paging.Page{
Max: paging.MaxID(id.Highest),
Limit: 20,
}
)
// Update target test status to mark
// all interactions with it pending.
pendingCount := suite.markInteractionsPending(ctx, testStatus.ID)
// Get pendingInts interactions.
pendingInts, err := suite.state.DB.GetInteractionsRequestsForAcct(
ctx,
acctID,
statusID,
likes,
replies,
boosts,
page,
)
suite.NoError(err)
suite.Len(pendingInts, pendingCount)
// Ensure relevant model populated.
for _, pendingInt := range pendingInts {
switch pendingInt.InteractionType {
case gtsmodel.InteractionLike:
suite.NotNil(pendingInt.Like)
case gtsmodel.InteractionReply:
suite.NotNil(pendingInt.Reply)
case gtsmodel.InteractionAnnounce:
suite.NotNil(pendingInt.Announce)
}
}
}
func (suite *InteractionTestSuite) TestGetPendingRepliesOnly() {
var (
testStatus = suite.testStatuses["local_account_1_status_1"]
ctx = context.Background()
acctID = suite.testAccounts["local_account_1"].ID
statusID = ""
likes = false
replies = true
boosts = false
page = &paging.Page{
Max: paging.MaxID(id.Highest),
Limit: 20,
}
)
// Update target test status to mark
// all interactions with it pending.
suite.markInteractionsPending(ctx, testStatus.ID)
// Get pendingInts interactions.
pendingInts, err := suite.state.DB.GetInteractionsRequestsForAcct(
ctx,
acctID,
statusID,
likes,
replies,
boosts,
page,
)
suite.NoError(err)
// Ensure only replies returned.
for _, pendingInt := range pendingInts {
suite.Equal(gtsmodel.InteractionReply, pendingInt.InteractionType)
}
}
func (suite *InteractionTestSuite) TestInteractionRejected() {
var (
ctx = context.Background()
req = new(gtsmodel.InteractionRequest)
)
// Make a copy of the request we'll modify.
*req = *suite.testInteractionRequests["admin_account_reply_turtle"]
// No rejection in the db for this interaction URI so it should be OK.
rejected, err := suite.state.DB.IsInteractionRejected(ctx, req.InteractionURI)
if err != nil {
suite.FailNow(err.Error())
}
if rejected {
suite.FailNow("wanted rejected = false, got true")
}
// Update the interaction request to mark it rejected.
req.RejectedAt = time.Now()
req.URI = "https://some.reject.uri"
if err := suite.state.DB.UpdateInteractionRequest(ctx, req, "uri", "rejected_at"); err != nil {
suite.FailNow(err.Error())
}
// Rejection in the db for this interaction URI now so it should be très mauvais.
rejected, err = suite.state.DB.IsInteractionRejected(ctx, req.InteractionURI)
if err != nil {
suite.FailNow(err.Error())
}
if !rejected {
suite.FailNow("wanted rejected = true, got false")
}
}
func TestInteractionTestSuite(t *testing.T) {
suite.Run(t, new(InteractionTestSuite))
}

View file

@ -20,41 +20,12 @@
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(&gtsmodel.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
})
}

View file

@ -0,0 +1,154 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package migrations
import (
"context"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
"github.com/uptrace/bun"
)
func init() {
up := func(ctx context.Context, db *bun.DB) error {
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
// Drop interaction approvals table if it exists,
// ie., if instance was running on main between now
// and 2024-07-16.
//
// We might lose some interaction approvals this way,
// but since they weren't *really* used much yet this
// it's not a big deal, that's the running-on-main life!
if _, err := tx.NewDropTable().
Table("interaction_approvals").
IfExists().
Exec(ctx); err != nil {
return err
}
// Add `interaction_requests`
// table and new indexes.
if _, err := tx.
NewCreateTable().
Model(&gtsmodel.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)
}
}

View file

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

View file

@ -21,21 +21,47 @@
"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)
}

View file

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

View file

@ -0,0 +1,93 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package gtsmodel
import "time"
// Like / Reply / Announce
type InteractionType int
const (
// WARNING: DO NOT CHANGE THE ORDER OF THESE,
// as this will cause breakage of approvals!
//
// If you need to add new interaction types,
// add them *to the end* of the list.
InteractionLike InteractionType = iota
InteractionReply
InteractionAnnounce
)
// Stringifies this InteractionType in a
// manner suitable for serving via the API.
func (i InteractionType) String() string {
switch i {
case InteractionLike:
const text = "favourite"
return text
case InteractionReply:
const text = "reply"
return text
case InteractionAnnounce:
const text = "reblog"
return text
default:
panic("undefined InteractionType")
}
}
// InteractionRequest represents one interaction (like, reply, fave)
// that is either accepted, rejected, or currently still awaiting
// acceptance or rejection by the target account.
type InteractionRequest struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
StatusID string `bun:"type:CHAR(26),nullzero,notnull"` // ID of the interaction target status.
Status *Status `bun:"-"` // Not stored in DB. Status being interacted with.
TargetAccountID string `bun:"type:CHAR(26),nullzero,notnull"` // id of the account being interacted with
TargetAccount *Account `bun:"-"` // Not stored in DB. Account being interacted with.
InteractingAccountID string `bun:"type:CHAR(26),nullzero,notnull"` // id of the account requesting the interaction.
InteractingAccount *Account `bun:"-"` // Not stored in DB. Account corresponding to targetAccountID
InteractionURI string `bun:",nullzero,notnull,unique"` // URI of the interacting like, reply, or announce. Unique (only one interaction request allowed per interaction URI).
InteractionType InteractionType `bun:",notnull"` // One of Like, Reply, or Announce.
Like *StatusFave `bun:"-"` // Not stored in DB. Only set if InteractionType = InteractionLike.
Reply *Status `bun:"-"` // Not stored in DB. Only set if InteractionType = InteractionReply.
Announce *Status `bun:"-"` // Not stored in DB. Only set if InteractionType = InteractionAnnounce.
URI string `bun:",nullzero,unique"` // ActivityPub URI of the Accept (if accepted) or Reject (if rejected). Null/empty if currently neither accepted not rejected.
AcceptedAt time.Time `bun:"type:timestamptz,nullzero"` // If interaction request was accepted, time at which this occurred.
RejectedAt time.Time `bun:"type:timestamptz,nullzero"` // If interaction request was rejected, time at which this occurred.
}
// IsHandled returns true if interaction
// request has been neither accepted or rejected.
func (ir *InteractionRequest) IsPending() bool {
return ir.URI == "" && ir.AcceptedAt.IsZero() && ir.RejectedAt.IsZero()
}
// IsAccepted returns true if this
// interaction request has been accepted.
func (ir *InteractionRequest) IsAccepted() bool {
return ir.URI != "" && !ir.AcceptedAt.IsZero()
}
// IsRejected returns true if this
// interaction request has been rejected.
func (ir *InteractionRequest) IsRejected() bool {
return ir.URI != "" && !ir.RejectedAt.IsZero()
}

View file

@ -1,55 +0,0 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package gtsmodel
import "time"
// InteractionApproval refers to a single Accept activity sent
// *from this instance* in response to an interaction request,
// in order to approve it.
//
// Accepts originating from remote instances are not stored
// using this format; the URI of the remote Accept is instead
// just added to the *gtsmodel.StatusFave or *gtsmodel.Status.
type InteractionApproval struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated
AccountID string `bun:"type:CHAR(26),nullzero,notnull"` // id of the account that owns this accept/approval
Account *Account `bun:"-"` // account corresponding to accountID
InteractingAccountID string `bun:"type:CHAR(26),nullzero,notnull"` // id of the account that did the interaction that this Accept targets.
InteractingAccount *Account `bun:"-"` // account corresponding to targetAccountID
InteractionURI string `bun:",nullzero,notnull"` // URI of the target like, reply, or announce
InteractionType InteractionType `bun:",notnull"` // One of Like, Reply, or Announce.
URI string `bun:",nullzero,notnull,unique"` // ActivityPub URI of the Accept.
}
// Like / Reply / Announce
type InteractionType int
const (
// WARNING: DO NOT CHANGE THE ORDER OF THESE,
// as this will cause breakage of approvals!
//
// If you need to add new interaction types,
// add them *to the end* of the list.
InteractionLike InteractionType = iota
InteractionReply
InteractionAnnounce
)

View file

@ -27,14 +27,14 @@
)
// 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)
}

View file

@ -0,0 +1,239 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package interactionrequests
import (
"context"
"time"
"github.com/superseriousbusiness/gotosocial/internal/ap"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/messages"
"github.com/superseriousbusiness/gotosocial/internal/uris"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
// Accept accepts an interaction request with the given ID,
// on behalf of the given account (whose post it must target).
func (p *Processor) Accept(
ctx context.Context,
acct *gtsmodel.Account,
reqID string,
) (*apimodel.InteractionRequest, gtserror.WithCode) {
req, err := p.state.DB.GetInteractionRequestByID(ctx, reqID)
if err != nil {
err := gtserror.Newf("db error getting interaction request: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
if req.TargetAccountID != acct.ID {
err := gtserror.Newf(
"interaction request %s does not belong to account %s",
reqID, acct.ID,
)
return nil, gtserror.NewErrorNotFound(err)
}
if !req.IsPending() {
err := gtserror.Newf(
"interaction request %s has already been handled",
reqID,
)
return nil, gtserror.NewErrorNotFound(err)
}
// Lock on the interaction req URI to
// ensure nobody else is modifying it rn.
unlock := p.state.ProcessingLocks.Lock(req.InteractionURI)
defer unlock()
// Mark the request as accepted
// and generate a URI for it.
req.AcceptedAt = time.Now()
req.URI = uris.GenerateURIForAccept(acct.Username, req.ID)
if err := p.state.DB.UpdateInteractionRequest(
ctx,
req,
"accepted_at",
"uri",
); err != nil {
err := gtserror.Newf("db error updating interaction request: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
switch req.InteractionType {
case gtsmodel.InteractionLike:
if errWithCode := p.acceptLike(ctx, req); errWithCode != nil {
return nil, errWithCode
}
case gtsmodel.InteractionReply:
if errWithCode := p.acceptReply(ctx, req); errWithCode != nil {
return nil, errWithCode
}
case gtsmodel.InteractionAnnounce:
if errWithCode := p.acceptAnnounce(ctx, req); errWithCode != nil {
return nil, errWithCode
}
default:
err := gtserror.Newf("unknown interaction type for interaction request %s", reqID)
return nil, gtserror.NewErrorInternalError(err)
}
// Return the now-accepted req to the caller so
// they can do something with it if they need to.
apiReq, err := p.converter.InteractionReqToAPIInteractionReq(
ctx,
req,
acct,
)
if err != nil {
err := gtserror.Newf("error converting interaction request: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
return apiReq, nil
}
// Package-internal convenience
// function to accept a like.
func (p *Processor) acceptLike(
ctx context.Context,
req *gtsmodel.InteractionRequest,
) gtserror.WithCode {
// If the Like is missing, that means it's
// probably already been undone by someone,
// so there's nothing to actually accept.
if req.Like == nil {
err := gtserror.Newf("no Like found for interaction request %s", req.ID)
return gtserror.NewErrorNotFound(err)
}
// Update the Like.
req.Like.PendingApproval = util.Ptr(false)
req.Like.PreApproved = false
req.Like.ApprovedByURI = req.URI
if err := p.state.DB.UpdateStatusFave(
ctx,
req.Like,
"pending_approval",
"approved_by_uri",
); err != nil {
err := gtserror.Newf("db error updating status fave: %w", err)
return gtserror.NewErrorInternalError(err)
}
// Send the accepted request off through the
// client API processor to handle side effects.
p.state.Workers.Client.Queue.Push(&messages.FromClientAPI{
APObjectType: ap.ActivityLike,
APActivityType: ap.ActivityAccept,
GTSModel: req,
Origin: req.TargetAccount,
Target: req.InteractingAccount,
})
return nil
}
// Package-internal convenience
// function to accept a reply.
func (p *Processor) acceptReply(
ctx context.Context,
req *gtsmodel.InteractionRequest,
) gtserror.WithCode {
// If the Reply is missing, that means it's
// probably already been undone by someone,
// so there's nothing to actually accept.
if req.Reply == nil {
err := gtserror.Newf("no Reply found for interaction request %s", req.ID)
return gtserror.NewErrorNotFound(err)
}
// Update the Reply.
req.Reply.PendingApproval = util.Ptr(false)
req.Reply.PreApproved = false
req.Reply.ApprovedByURI = req.URI
if err := p.state.DB.UpdateStatus(
ctx,
req.Reply,
"pending_approval",
"approved_by_uri",
); err != nil {
err := gtserror.Newf("db error updating status reply: %w", err)
return gtserror.NewErrorInternalError(err)
}
// Send the accepted request off through the
// client API processor to handle side effects.
p.state.Workers.Client.Queue.Push(&messages.FromClientAPI{
APObjectType: ap.ObjectNote,
APActivityType: ap.ActivityAccept,
GTSModel: req,
Origin: req.TargetAccount,
Target: req.InteractingAccount,
})
return nil
}
// Package-internal convenience
// function to accept an announce.
func (p *Processor) acceptAnnounce(
ctx context.Context,
req *gtsmodel.InteractionRequest,
) gtserror.WithCode {
// If the Announce is missing, that means it's
// probably already been undone by someone,
// so there's nothing to actually accept.
if req.Reply == nil {
err := gtserror.Newf("no Announce found for interaction request %s", req.ID)
return gtserror.NewErrorNotFound(err)
}
// Update the Announce.
req.Announce.PendingApproval = util.Ptr(false)
req.Announce.PreApproved = false
req.Announce.ApprovedByURI = req.URI
if err := p.state.DB.UpdateStatus(
ctx,
req.Announce,
"pending_approval",
"approved_by_uri",
); err != nil {
err := gtserror.Newf("db error updating status announce: %w", err)
return gtserror.NewErrorInternalError(err)
}
// Send the accepted request off through the
// client API processor to handle side effects.
p.state.Workers.Client.Queue.Push(&messages.FromClientAPI{
APObjectType: ap.ActivityAnnounce,
APActivityType: ap.ActivityAccept,
GTSModel: req,
Origin: req.TargetAccount,
Target: req.InteractingAccount,
})
return nil
}

View file

@ -0,0 +1,89 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package interactionrequests_test
import (
"context"
"testing"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/processing/interactionrequests"
"github.com/superseriousbusiness/gotosocial/testrig"
)
type AcceptTestSuite struct {
InteractionRequestsTestSuite
}
func (suite *AcceptTestSuite) TestAccept() {
testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath)
defer testrig.TearDownTestStructs(testStructs)
var (
ctx = context.Background()
state = testStructs.State
acct = suite.testAccounts["local_account_2"]
intReq = suite.testInteractionRequests["admin_account_reply_turtle"]
)
// Create interaction reqs processor.
p := interactionrequests.New(
testStructs.Common,
testStructs.State,
testStructs.TypeConverter,
)
apiReq, errWithCode := p.Accept(ctx, acct, intReq.ID)
if errWithCode != nil {
suite.FailNow(errWithCode.Error())
}
// Get db interaction request.
dbReq, err := state.DB.GetInteractionRequestByID(ctx, apiReq.ID)
if err != nil {
suite.FailNow(err.Error())
}
suite.True(dbReq.IsAccepted())
// Interacting status
// should now be approved.
dbStatus, err := state.DB.GetStatusByURI(ctx, dbReq.InteractionURI)
if err != nil {
suite.FailNow(err.Error())
}
suite.False(*dbStatus.PendingApproval)
suite.Equal(dbReq.URI, dbStatus.ApprovedByURI)
// Wait for a notification
// for interacting status.
testrig.WaitFor(func() bool {
notif, err := state.DB.GetNotification(
ctx,
gtsmodel.NotificationMention,
dbStatus.InReplyToAccountID,
dbStatus.AccountID,
dbStatus.ID,
)
return notif != nil && err == nil
})
}
func TestAcceptTestSuite(t *testing.T) {
suite.Run(t, new(AcceptTestSuite))
}

View file

@ -0,0 +1,141 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package interactionrequests
import (
"context"
"errors"
"net/url"
"strconv"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/paging"
)
// GetPage returns a page of interaction requests targeting
// the requester and (optionally) the given status ID.
func (p *Processor) GetPage(
ctx context.Context,
requester *gtsmodel.Account,
statusID string,
likes bool,
replies bool,
boosts bool,
page *paging.Page,
) (*apimodel.PageableResponse, gtserror.WithCode) {
reqs, err := p.state.DB.GetInteractionsRequestsForAcct(
ctx,
requester.ID,
statusID,
likes,
replies,
boosts,
page,
)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("db error getting interaction requests: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
count := len(reqs)
if count == 0 {
return paging.EmptyResponse(), nil
}
var (
// Get the lowest and highest
// ID values, used for paging.
lo = reqs[count-1].ID
hi = reqs[0].ID
// Best-guess items length.
items = make([]interface{}, 0, count)
)
for _, req := range reqs {
apiReq, err := p.converter.InteractionReqToAPIInteractionReq(
ctx, req, requester,
)
if err != nil {
log.Errorf(ctx, "error converting interaction req to api req: %v", err)
continue
}
// Append req to return items.
items = append(items, apiReq)
}
// Build extra query params to return in Link header.
extraParams := make(url.Values, 4)
extraParams.Set(apiutil.InteractionFavouritesKey, strconv.FormatBool(likes))
extraParams.Set(apiutil.InteractionRepliesKey, strconv.FormatBool(replies))
extraParams.Set(apiutil.InteractionReblogsKey, strconv.FormatBool(boosts))
if statusID != "" {
extraParams.Set(apiutil.InteractionStatusIDKey, statusID)
}
return paging.PackageResponse(paging.ResponseParams{
Items: items,
Path: "/api/v1/interaction_requests",
Next: page.Next(lo, hi),
Prev: page.Prev(lo, hi),
Query: extraParams,
}), nil
}
// GetOne returns one interaction
// request with the given ID.
func (p *Processor) GetOne(
ctx context.Context,
requester *gtsmodel.Account,
id string,
) (*apimodel.InteractionRequest, gtserror.WithCode) {
req, err := p.state.DB.GetInteractionRequestByID(ctx, id)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("db error getting interaction request: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
if req == nil {
err := gtserror.New("interaction request not found")
return nil, gtserror.NewErrorNotFound(err)
}
if req.TargetAccountID != requester.ID {
err := gtserror.Newf(
"interaction request %s does not target account %s",
req.ID, requester.ID,
)
return nil, gtserror.NewErrorNotFound(err)
}
apiReq, err := p.converter.InteractionReqToAPIInteractionReq(
ctx, req, requester,
)
if err != nil {
err := gtserror.Newf("error converting interaction req to api req: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
return apiReq, nil
}

View file

@ -0,0 +1,47 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package interactionrequests
import (
"github.com/superseriousbusiness/gotosocial/internal/processing/common"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
)
// Processor wraps functionality for getting,
// accepting, and rejecting interaction requests.
type Processor struct {
// common processor logic
c *common.Processor
state *state.State
converter *typeutils.Converter
}
// New returns a new interaction requests processor.
func New(
common *common.Processor,
state *state.State,
converter *typeutils.Converter,
) Processor {
return Processor{
c: common,
state: state,
converter: converter,
}
}

View file

@ -0,0 +1,45 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package interactionrequests_test
import (
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/testrig"
)
const (
rMediaPath = "../../../testrig/media"
rTemplatePath = "../../../web/template"
)
type InteractionRequestsTestSuite struct {
suite.Suite
testAccounts map[string]*gtsmodel.Account
testStatuses map[string]*gtsmodel.Status
testInteractionRequests map[string]*gtsmodel.InteractionRequest
}
func (suite *InteractionRequestsTestSuite) SetupTest() {
testrig.InitTestConfig()
testrig.InitTestLog()
suite.testAccounts = testrig.NewTestAccounts()
suite.testStatuses = testrig.NewTestStatuses()
suite.testInteractionRequests = testrig.NewTestInteractionRequests()
}

View file

@ -0,0 +1,133 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package interactionrequests
import (
"context"
"time"
"github.com/superseriousbusiness/gotosocial/internal/ap"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/messages"
"github.com/superseriousbusiness/gotosocial/internal/uris"
)
// Reject rejects an interaction request with the given ID,
// on behalf of the given account (whose post it must target).
func (p *Processor) Reject(
ctx context.Context,
acct *gtsmodel.Account,
reqID string,
) (*apimodel.InteractionRequest, gtserror.WithCode) {
req, err := p.state.DB.GetInteractionRequestByID(ctx, reqID)
if err != nil {
err := gtserror.Newf("db error getting interaction request: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
if req.TargetAccountID != acct.ID {
err := gtserror.Newf(
"interaction request %s does not belong to account %s",
reqID, acct.ID,
)
return nil, gtserror.NewErrorNotFound(err)
}
if !req.IsPending() {
err := gtserror.Newf(
"interaction request %s has already been handled",
reqID,
)
return nil, gtserror.NewErrorNotFound(err)
}
// Lock on the interaction req URI to
// ensure nobody else is modifying it rn.
unlock := p.state.ProcessingLocks.Lock(req.InteractionURI)
defer unlock()
// Mark the request as rejected
// and generate a URI for it.
req.RejectedAt = time.Now()
req.URI = uris.GenerateURIForReject(acct.Username, req.ID)
if err := p.state.DB.UpdateInteractionRequest(
ctx,
req,
"rejected_at",
"uri",
); err != nil {
err := gtserror.Newf("db error updating interaction request: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
switch req.InteractionType {
case gtsmodel.InteractionLike:
// Send the rejected request off through the
// client API processor to handle side effects.
p.state.Workers.Client.Queue.Push(&messages.FromClientAPI{
APObjectType: ap.ActivityLike,
APActivityType: ap.ActivityReject,
GTSModel: req,
Origin: req.TargetAccount,
Target: req.InteractingAccount,
})
case gtsmodel.InteractionReply:
// Send the rejected request off through the
// client API processor to handle side effects.
p.state.Workers.Client.Queue.Push(&messages.FromClientAPI{
APObjectType: ap.ObjectNote,
APActivityType: ap.ActivityReject,
GTSModel: req,
Origin: req.TargetAccount,
Target: req.InteractingAccount,
})
case gtsmodel.InteractionAnnounce:
// Send the rejected request off through the
// client API processor to handle side effects.
p.state.Workers.Client.Queue.Push(&messages.FromClientAPI{
APObjectType: ap.ActivityAnnounce,
APActivityType: ap.ActivityReject,
GTSModel: req,
Origin: req.TargetAccount,
Target: req.InteractingAccount,
})
default:
err := gtserror.Newf("unknown interaction type for interaction request %s", reqID)
return nil, gtserror.NewErrorInternalError(err)
}
// Return the now-rejected req to the caller so
// they can do something with it if they need to.
apiReq, err := p.converter.InteractionReqToAPIInteractionReq(
ctx,
req,
acct,
)
if err != nil {
err := gtserror.Newf("error converting interaction request: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
return apiReq, nil
}

View file

@ -0,0 +1,78 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package interactionrequests_test
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/processing/interactionrequests"
"github.com/superseriousbusiness/gotosocial/testrig"
)
type RejectTestSuite struct {
InteractionRequestsTestSuite
}
func (suite *RejectTestSuite) TestReject() {
testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath)
defer testrig.TearDownTestStructs(testStructs)
var (
ctx = context.Background()
state = testStructs.State
acct = suite.testAccounts["local_account_2"]
intReq = suite.testInteractionRequests["admin_account_reply_turtle"]
)
// Create int reqs processor.
p := interactionrequests.New(
testStructs.Common,
testStructs.State,
testStructs.TypeConverter,
)
apiReq, errWithCode := p.Reject(ctx, acct, intReq.ID)
if errWithCode != nil {
suite.FailNow(errWithCode.Error())
}
// Get db interaction rejection.
dbReq, err := state.DB.GetInteractionRequestByID(ctx, apiReq.ID)
if err != nil {
suite.FailNow(err.Error())
}
suite.True(dbReq.IsRejected())
// Wait for interacting status to be deleted.
testrig.WaitFor(func() bool {
status, err := state.DB.GetStatusByURI(
gtscontext.SetBarebones(ctx),
dbReq.InteractionURI,
)
return status == nil && errors.Is(err, db.ErrNoEntries)
})
}
func TestRejectTestSuite(t *testing.T) {
suite.Run(t, new(RejectTestSuite))
}

View file

@ -34,6 +34,7 @@
"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"
@ -81,6 +82,7 @@ type Processor struct {
fedi fedi.Processor
filtersv1 filtersv1.Processor
filtersv2 filtersv2.Processor
interactionRequests interactionrequests.Processor
list list.Processor
markers markers.Processor
media media.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,

View file

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

View file

@ -20,18 +20,23 @@
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 := &gtsmodel.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 := &gtsmodel.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 := &gtsmodel.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
}

View file

@ -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()

View file

@ -20,18 +20,22 @@
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 := &gtsmodel.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 := &gtsmodel.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 := &gtsmodel.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
}

View file

@ -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 := &gtsmodel.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 := &gtsmodel.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()

View file

@ -28,6 +28,7 @@
"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,

View file

@ -26,12 +26,11 @@
"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 := &gtsmodel.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)
}
// approveReply stores + returns an
// interactionApproval for a reply.
func (u *utils) approveReply(
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
}
// 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 := &gtsmodel.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)
}
// approveAnnounce stores + returns an
// interactionApproval for an announce.
func (u *utils) approveAnnounce(
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
}
// 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 := &gtsmodel.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
}

View file

@ -22,6 +22,7 @@
"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,
},
}

View file

@ -21,19 +21,18 @@
"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)
}

View file

@ -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) {

View file

@ -20,6 +20,7 @@
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 &gtsmodel.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 &gtsmodel.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
}

View file

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

View file

@ -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 := &gtsmodel.InteractionApproval{
req := &gtsmodel.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())

View file

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

View file

@ -46,7 +46,8 @@
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()

View file

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

View file

@ -41,6 +41,7 @@
&gtsmodel.FilterStatus{},
&gtsmodel.Follow{},
&gtsmodel.FollowRequest{},
&gtsmodel.InteractionRequest{},
&gtsmodel.List{},
&gtsmodel.ListEntry{},
&gtsmodel.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)
}

View file

@ -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: `<p>Hi <span class="h-card"><a href="http://localhost:8080/@1happyturtle" class="u-url mention" rel="nofollow noreferrer noopener" target="_blank">@<span>1happyturtle</span></a></span>, can I reply?</p>`,
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

121
testrig/teststructs.go Normal file
View file

@ -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 <http://www.gnu.org/licenses/>.
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)
}

View file

@ -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",

View file

@ -220,7 +220,7 @@ function StatusMediaEntry({ media }: { media: MediaAttachment }) {
function StatusFooter({ status }: { status: StatusType }) {
return (
<aside className="status-info" aria-hidden="true">
<aside className="status-info">
<dl className="status-stats">
<div className="stats-grouping">
<div className="stats-item published-at text-cutoff">

View file

@ -114,7 +114,7 @@ const extended = gtsApi.injectEndpoints({
method: "POST",
url: `/api/v1/admin/accounts/${id}/${approve_or_reject}`,
asForm: true,
body: approve_or_reject === "reject" ?? formData,
body: approve_or_reject === "reject" && formData,
};
},
// Do an optimistic update on this account to mark it approved

View file

@ -77,8 +77,8 @@ const extended = gtsApi.injectEndpoints({
}),
invalidatesTags: (res) =>
res
? [{ type: "Report", id: "LIST" }, { type: "Report", id: res.id }]
: [{ type: "Report", id: "LIST" }]
? [{ type: "Report", id: "TRANSFORMED" }, { type: "Report", id: res.id }]
: [{ type: "Report", id: "TRANSFORMED" }]
})
})
});

View file

@ -168,6 +168,7 @@ export const gtsApi = createApi({
"HTTPHeaderAllows",
"HTTPHeaderBlocks",
"DefaultInteractionPolicies",
"InteractionRequest",
],
endpoints: (build) => ({
instanceV1: build.query<InstanceV1, void>({

View file

@ -0,0 +1,97 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {
InteractionRequest,
SearchInteractionRequestsParams,
SearchInteractionRequestsResp,
} from "../../types/interaction";
import { gtsApi } from "../gts-api";
import parse from "parse-link-header";
const extended = gtsApi.injectEndpoints({
endpoints: (build) => ({
getInteractionRequest: build.query<InteractionRequest, string>({
query: (id) => ({
method: "GET",
url: `/api/v1/interaction_requests/${id}`,
}),
providesTags: (_result, _error, id) => [
{ type: 'InteractionRequest', id }
],
}),
searchInteractionRequests: build.query<SearchInteractionRequestsResp, SearchInteractionRequestsParams>({
query: (form) => {
const params = new(URLSearchParams);
Object.entries(form).forEach(([k, v]) => {
if (v !== undefined) {
params.append(k, v);
}
});
let query = "";
if (params.size !== 0) {
query = `?${params.toString()}`;
}
return {
url: `/api/v1/interaction_requests${query}`
};
},
// Headers required for paging.
transformResponse: (apiResp: InteractionRequest[], meta) => {
const requests = apiResp;
const linksStr = meta?.response?.headers.get("Link");
const links = parse(linksStr);
return { requests, links };
},
providesTags: [{ type: "InteractionRequest", id: "TRANSFORMED" }]
}),
approveInteractionRequest: build.mutation<InteractionRequest, string>({
query: (id) => ({
method: "POST",
url: `/api/v1/interaction_requests/${id}/authorize`,
}),
invalidatesTags: (res) =>
res
? [{ type: "InteractionRequest", id: "TRANSFORMED" }, { type: "InteractionRequest", id: res.id }]
: [{ type: "InteractionRequest", id: "TRANSFORMED" }]
}),
rejectInteractionRequest: build.mutation<any, string>({
query: (id) => ({
method: "POST",
url: `/api/v1/interaction_requests/${id}/reject`,
}),
invalidatesTags: (res) =>
res
? [{ type: "InteractionRequest", id: "TRANSFORMED" }, { type: "InteractionRequest", id: res.id }]
: [{ type: "InteractionRequest", id: "TRANSFORMED" }]
}),
})
});
export const {
useGetInteractionRequestQuery,
useLazySearchInteractionRequestsQuery,
useApproveInteractionRequestMutation,
useRejectInteractionRequestMutation,
} = extended;

View file

@ -17,6 +17,10 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Links } from "parse-link-header";
import { Account } from "./account";
import { Status } from "./status";
export interface DefaultInteractionPolicies {
direct: InteractionPolicy;
private: InteractionPolicy;
@ -61,3 +65,81 @@ export {
PolicyValueAuthor,
PolicyValueMe,
};
/**
* Interaction request targeting a status by an account.
*/
export interface InteractionRequest {
/**
* ID of the request.
*/
id: string;
/**
* Type of interaction being requested.
*/
type: "favourite" | "reply" | "reblog";
/**
* Time when the request was created.
*/
created_at: string;
/**
* Account that created the request.
*/
account: Account;
/**
* Status being interacted with.
*/
status: Status;
/**
* Replying status, if type = "reply".
*/
reply?: Status;
}
/**
* Parameters for GET to /api/v1/interaction_requests.
*/
export interface SearchInteractionRequestsParams {
/**
* If set, show only requests targeting the given status_id.
*/
status_id?: string;
/**
* If true or not set, include favourites in the results.
*/
favourites?: boolean;
/**
* If true or not set, include replies in the results.
*/
replies?: boolean;
/**
* If true or not set, include reblogs in the results.
*/
reblogs?: boolean;
/**
* If set, show only requests older (ie., lower) than the given ID.
* Request with the given ID will not be included in response.
*/
max_id?: string;
/**
* If set, show only requests newer (ie., higher) than the given ID.
* Request with the given ID will not be included in response.
*/
since_id?: string;
/**
* If set, show only requests *immediately newer* than the given ID.
* Request with the given ID will not be included in response.
*/
min_id?: string;
/**
* If set, limit returned requests to this number.
* Else, fall back to GtS API defaults.
*/
limit?: number;
}
export interface SearchInteractionRequestsResp {
requests: InteractionRequest[];
links: Links | null;
}

View file

@ -1317,10 +1317,10 @@ button.tab-button {
word-break: break-word;
}
dt, dd {
dt, dd, span {
/*
Make sure any fa icons used in keys
or values are properly aligned.
or values etc. are properly aligned.
*/
.fa {
vertical-align: middle;
@ -1516,6 +1516,60 @@ button.tab-button {
}
}
.interaction-requests-view {
.interaction-request {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
gap: 0.5rem;
color: $fg;
.info-list {
border: none;
.info-list-entry {
grid-template-columns: max(20%, 8rem) 1fr;
background: none;
padding: 0;
}
}
.action-buttons {
display: flex;
gap: 0.5rem;
align-items: center;
> .mutation-button
> button {
font-size: 1rem;
line-height: 1rem;
}
}
}
}
.interaction-request-detail {
.overview {
margin-top: 1rem;
}
h2 {
font-size: 1rem;
}
.thread .status .status-info {
border-bottom-left-radius: $br;
border-bottom-right-radius: $br;
}
.action-buttons {
display: flex;
gap: 0.5rem;
align-items: center;
}
}
@media screen and (orientation: portrait) {
.reports .report .byline {
grid-template-columns: 1fr;

View file

@ -0,0 +1,117 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { useMemo } from "react";
import { useLocation, useParams } from "wouter";
import FormWithData from "../../../lib/form/form-with-data";
import BackButton from "../../../components/back-button";
import { useBaseUrl } from "../../../lib/navigation/util";
import { useApproveInteractionRequestMutation, useGetInteractionRequestQuery, useRejectInteractionRequestMutation } from "../../../lib/query/user/interactions";
import { InteractionRequest } from "../../../lib/types/interaction";
import { useIcon, useNoun, useVerbed } from "./util";
import MutationButton from "../../../components/form/mutation-button";
import { Status } from "../../../components/status";
export default function InteractionRequestDetail({ }) {
const params: { reqId: string } = useParams();
const baseUrl = useBaseUrl();
const backLocation: String = history.state?.backLocation ?? `~${baseUrl}`;
return (
<div className="interaction-request-detail">
<h1><BackButton to={backLocation}/> Interaction Request Details</h1>
<FormWithData
dataQuery={useGetInteractionRequestQuery}
queryArg={params.reqId}
DataForm={InteractionRequestDetailForm}
{...{ backLocation: backLocation }}
/>
</div>
);
}
function InteractionRequestDetailForm({ data: req, backLocation }: { data: InteractionRequest, backLocation: string }) {
const [ _location, setLocation ] = useLocation();
const [ approve, approveResult ] = useApproveInteractionRequestMutation();
const [ reject, rejectResult ] = useRejectInteractionRequestMutation();
const verbed = useVerbed(req.type);
const noun = useNoun(req.type);
const icon = useIcon(req.type);
const strap = useMemo(() => {
return "@" + req.account.acct + " " + verbed + " your post.";
}, [req.account, verbed]);
return (
<>
<span className="overview">
<i
className={`fa fa-fw ${icon}`}
aria-hidden="true"
/> <strong>{strap}</strong>
</span>
<h2>You wrote:</h2>
<div className="thread">
<Status status={req.status} />
</div>
{ req.reply && <>
<h2>They replied:</h2>
<div className="thread">
<Status status={req.reply} />
</div>
</> }
<div className="action-buttons">
<MutationButton
label={`Accept ${noun}`}
title={`Accept ${noun}`}
type="button"
className="button"
onClick={(e) => {
e.preventDefault();
approve(req.id);
setLocation(backLocation);
}}
disabled={false}
showError={false}
result={approveResult}
/>
<MutationButton
label={`Reject ${noun}`}
title={`Reject ${noun}`}
type="button"
className="button danger"
onClick={(e) => {
e.preventDefault();
reject(req.id);
setLocation(backLocation);
}}
disabled={false}
showError={false}
result={rejectResult}
/>
</div>
</>
);
}

View file

@ -0,0 +1,36 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from "react";
import InteractionRequestsSearchForm from "./search";
export default function InteractionRequests() {
return (
<div className="interaction-requests-view">
<div className="form-section-docs">
<h1>Interaction Requests</h1>
<p>
On this page you can search through interaction requests
targeting your statuses, and approve or reject them.
</p>
</div>
<InteractionRequestsSearchForm />
</div>
);
}

View file

@ -0,0 +1,251 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { ReactNode, useEffect, useMemo } from "react";
import { useBoolInput, useTextInput } from "../../../lib/form";
import { PageableList } from "../../../components/pageable-list";
import MutationButton from "../../../components/form/mutation-button";
import { useLocation, useSearch } from "wouter";
import { useApproveInteractionRequestMutation, useLazySearchInteractionRequestsQuery, useRejectInteractionRequestMutation } from "../../../lib/query/user/interactions";
import { InteractionRequest } from "../../../lib/types/interaction";
import { Checkbox } from "../../../components/form/inputs";
import { useContent, useIcon, useNoun, useVerbed } from "./util";
function defaultTrue(urlQueryVal: string | null): boolean {
if (urlQueryVal === null) {
return true;
}
return urlQueryVal.toLowerCase() !== "false";
}
export default function InteractionRequestsSearchForm() {
const [ location, setLocation ] = useLocation();
const search = useSearch();
const urlQueryParams = useMemo(() => new URLSearchParams(search), [search]);
const [ searchReqs, searchRes ] = useLazySearchInteractionRequestsQuery();
// Populate search form using values from
// urlQueryParams, to allow paging.
const form = {
statusID: useTextInput("status_id", {
defaultValue: urlQueryParams.get("status_id") ?? ""
}),
likes: useBoolInput("favourites", {
defaultValue: defaultTrue(urlQueryParams.get("favourites"))
}),
replies: useBoolInput("replies", {
defaultValue: defaultTrue(urlQueryParams.get("replies"))
}),
boosts: useBoolInput("reblogs", {
defaultValue: defaultTrue(urlQueryParams.get("reblogs"))
}),
};
// On mount, trigger search.
useEffect(() => {
searchReqs(Object.fromEntries(urlQueryParams), true);
}, [urlQueryParams, searchReqs]);
// Rather than triggering the search directly,
// the "submit" button changes the location
// based on form field params, and lets the
// useEffect hook above actually do the search.
function submitQuery(e) {
e.preventDefault();
// Parse query parameters.
const entries = Object.entries(form).map(([k, v]) => {
// Take only defined form fields.
if (v.value === undefined) {
return null;
} else if (typeof v.value === "string" && v.value.length === 0) {
return null;
}
return [[k, v.value.toString()]];
}).flatMap(kv => {
// Remove any nulls.
return kv !== null ? kv : [];
});
const searchParams = new URLSearchParams(entries);
setLocation(location + "?" + searchParams.toString());
}
// Location to return to when user clicks
// "back" on the interaction req detail view.
const backLocation = location + (urlQueryParams.size > 0 ? `?${urlQueryParams}` : "");
// Function to map an item to a list entry.
function itemToEntry(req: InteractionRequest): ReactNode {
return (
<ReqsListEntry
key={req.id}
req={req}
linkTo={`/${req.id}`}
backLocation={backLocation}
/>
);
}
return (
<>
<form
onSubmit={submitQuery}
// Prevent password managers
// trying to fill in fields.
autoComplete="off"
>
<Checkbox
label="Include likes"
field={form.likes}
/>
<Checkbox
label="Include replies"
field={form.replies}
/>
<Checkbox
label="Include boosts"
field={form.boosts}
/>
<MutationButton
disabled={false}
label={"Search"}
result={searchRes}
/>
</form>
<PageableList
isLoading={searchRes.isLoading}
isFetching={searchRes.isFetching}
isSuccess={searchRes.isSuccess}
items={searchRes.data?.requests}
itemToEntry={itemToEntry}
isError={searchRes.isError}
error={searchRes.error}
emptyMessage={<b>No interaction requests found that match your query.</b>}
prevNextLinks={searchRes.data?.links}
/>
</>
);
}
interface ReqsListEntryProps {
req: InteractionRequest;
linkTo: string;
backLocation: string;
}
function ReqsListEntry({ req, linkTo, backLocation }: ReqsListEntryProps) {
const [ _location, setLocation ] = useLocation();
const [ approve, approveResult ] = useApproveInteractionRequestMutation();
const [ reject, rejectResult ] = useRejectInteractionRequestMutation();
const verbed = useVerbed(req.type);
const noun = useNoun(req.type);
const icon = useIcon(req.type);
const strap = useMemo(() => {
return "@" + req.account.acct + " " + verbed + " your post.";
}, [req.account, verbed]);
const label = useMemo(() => {
return noun + " from @" + req.account.acct;
}, [req.account, noun]);
const ourContent = useContent(req.status);
const theirContent = useContent(req.reply);
return (
<span
className={`pseudolink entry interaction-request`}
aria-label={label}
title={label}
onClick={() => {
// When clicking on a request, direct
// to the detail view for that request.
setLocation(linkTo, {
// Store the back location in history so
// the detail view can use it to return to
// this page (including query parameters).
state: { backLocation: backLocation }
});
}}
role="link"
tabIndex={0}
>
<span className="text-cutoff">
<i
className={`fa fa-fw ${icon}`}
aria-hidden="true"
/> <strong>{strap}</strong>
</span>
<dl className="info-list">
<div className="info-list-entry">
<dt>You wrote:</dt>
<dd className="text-cutoff">
{ourContent}
</dd>
</div>
{ req.type === "reply" &&
<div className="info-list-entry">
<dt>They wrote:</dt>
<dd className="text-cutoff">
{theirContent}
</dd>
</div>
}
</dl>
<div className="action-buttons">
<MutationButton
label="Accept"
title={`Accept ${noun}`}
type="button"
className="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
approve(req.id);
}}
disabled={false}
showError={false}
result={approveResult}
/>
<MutationButton
label="Reject"
title={`Reject ${noun}`}
type="button"
className="button danger"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
reject(req.id);
}}
disabled={false}
showError={false}
result={rejectResult}
/>
</div>
</span>
);
}

View file

@ -0,0 +1,98 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useMemo } from "react";
import sanitize from "sanitize-html";
import { compile, HtmlToTextOptions } from "html-to-text";
import { Status } from "../../../lib/types/status";
// Options for converting HTML statuses
// to plaintext representations.
const convertOptions: HtmlToTextOptions = {
selectors: [
// Don't fancy format links, just use their text value.
{ selector: 'a', options: { ignoreHref: true } },
]
};
const convertHTML = compile(convertOptions);
/**
* Convert input status to plaintext representation.
* @param status
* @returns
*/
export function useContent(status: Status | undefined): string {
return useMemo(() => {
if (!status) {
return "";
}
if (status.content.length === 0) {
return "[no content set]";
} else {
// HTML has already been through
// the instance sanitizer by now,
// but do it again just in case.
const content = sanitize(status.content);
// Return plaintext of sanitized HTML.
return convertHTML(content);
}
}, [status]);
}
export function useVerbed(type: "favourite" | "reply" | "reblog"): string {
return useMemo(() => {
switch (type) {
case "favourite":
return "liked";
case "reply":
return "replied to";
case "reblog":
return "boosted";
}
}, [type]);
}
export function useNoun(type: "favourite" | "reply" | "reblog"): string {
return useMemo(() => {
switch (type) {
case "favourite":
return "Like";
case "reply":
return "Reply";
case "reblog":
return "Boost";
}
}, [type]);
}
export function useIcon(type: "favourite" | "reply" | "reblog"): string {
return useMemo(() => {
switch (type) {
case "favourite":
return "fa-star";
case "reply":
return "fa-reply";
case "reblog":
return "fa-retweet";
}
}, [type]);
}

View file

@ -43,6 +43,11 @@ export default function UserMenu() {
itemUrl="posts"
icon="fa-paper-plane"
/>
<MenuItem
name="Interaction Requests"
itemUrl="interaction_requests"
icon="fa-commenting-o"
/>
<MenuItem
name="Email & Password"
itemUrl="emailpassword"

View file

@ -26,6 +26,8 @@ import UserMigration from "./migration";
import PostSettings from "./posts";
import EmailPassword from "./emailpassword";
import ExportImport from "./export-import";
import InteractionRequests from "./interactions";
import InteractionRequestDetail from "./interactions/detail";
/**
* - /settings/user/profile
@ -33,6 +35,7 @@ import ExportImport from "./export-import";
* - /settings/user/emailpassword
* - /settings/user/migration
* - /settings/user/export-import
* - /settings/users/interaction_requests
*/
export default function UserRouter() {
const baseUrl = useBaseUrl();
@ -52,6 +55,31 @@ export default function UserRouter() {
<Route><Redirect to="/profile" /></Route>
</Switch>
</ErrorBoundary>
<InteractionRequestsRouter />
</Router>
</BaseUrlContext.Provider>
);
}
/**
* - /settings/users/interaction_requests/search
* - /settings/users/interaction_requests/{reqId}
*/
function InteractionRequestsRouter() {
const parentUrl = useBaseUrl();
const thisBase = "/interaction_requests";
const absBase = parentUrl + thisBase;
return (
<BaseUrlContext.Provider value={absBase}>
<Router base={thisBase}>
<ErrorBoundary>
<Switch>
<Route path="/search" component={InteractionRequests} />
<Route path="/:reqId" component={InteractionRequestDetail} />
<Route><Redirect to="/search"/></Route>
</Switch>
</ErrorBoundary>
</Router>
</BaseUrlContext.Provider>
);

View file

@ -1411,6 +1411,14 @@
redux-thunk "^2.4.2"
reselect "^4.1.8"
"@selderee/plugin-htmlparser2@^0.11.0":
version "0.11.0"
resolved "https://registry.yarnpkg.com/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz#d5b5e29a7ba6d3958a1972c7be16f4b2c188c517"
integrity sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==
dependencies:
domhandler "^5.0.3"
selderee "^0.11.0"
"@tsconfig/node10@^1.0.7":
version "1.0.9"
resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.9.tgz#df4907fc07a886922637b15e02d4cebc4c0021b2"
@ -1439,6 +1447,11 @@
"@types/react" "*"
hoist-non-react-statics "^3.3.0"
"@types/html-to-text@^9.0.4":
version "9.0.4"
resolved "https://registry.yarnpkg.com/@types/html-to-text/-/html-to-text-9.0.4.tgz#4a83dd8ae8bfa91457d0b1ffc26f4d0537eff58c"
integrity sha512-pUY3cKH/Nm2yYrEmDlPR1mR7yszjGx4DrwPjQ702C4/D5CwHuZTgZdIdwPkRbcuhs7BAh2L5rg3CL5cbRiGTCQ==
"@types/http-proxy@^1.17.8":
version "1.17.12"
resolved "https://registry.yarnpkg.com/@types/http-proxy/-/http-proxy-1.17.12.tgz#86e849e9eeae0362548803c37a0a1afc616bd96b"
@ -3004,7 +3017,7 @@ deep-is@^0.1.3, deep-is@~0.1.3:
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==
deepmerge@^4.2.2:
deepmerge@^4.2.2, deepmerge@^4.3.1:
version "4.3.1"
resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a"
integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==
@ -4078,12 +4091,23 @@ hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2:
dependencies:
react-is "^16.7.0"
html-to-text@^9.0.5:
version "9.0.5"
resolved "https://registry.yarnpkg.com/html-to-text/-/html-to-text-9.0.5.tgz#6149a0f618ae7a0db8085dca9bbf96d32bb8368d"
integrity sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==
dependencies:
"@selderee/plugin-htmlparser2" "^0.11.0"
deepmerge "^4.3.1"
dom-serializer "^2.0.0"
htmlparser2 "^8.0.2"
selderee "^0.11.0"
htmlescape@^1.1.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/htmlescape/-/htmlescape-1.1.1.tgz#3a03edc2214bca3b66424a3e7959349509cb0351"
integrity sha512-eVcrzgbR4tim7c7soKQKtxa/kQM4TzjnlU83rcZ9bHU6t31ehfV7SktN6McWgwPWg+JYMA/O3qpGxBvFq1z2Jg==
htmlparser2@^8.0.0:
htmlparser2@^8.0.0, htmlparser2@^8.0.2:
version "8.0.2"
resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-8.0.2.tgz#f002151705b383e62433b5cf466f5b716edaec21"
integrity sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==
@ -4696,6 +4720,11 @@ langs@^2.0.0:
resolved "https://registry.yarnpkg.com/langs/-/langs-2.0.0.tgz#00c32ce48152a49a614450b9ba2632ab58a0a364"
integrity sha512-v4pxOBEQVN1WBTfB1crhTtxzNLZU9HPWgadlwzWKISJtt6Ku/CnpBrwVy+jFv8StjxsPfwPFzO0CMwdZLJ0/BA==
leac@^0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/leac/-/leac-0.6.0.tgz#dcf136e382e666bd2475f44a1096061b70dc0912"
integrity sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==
levn@^0.4.1:
version "0.4.1"
resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade"
@ -5261,6 +5290,14 @@ parse-srcset@^1.0.2:
resolved "https://registry.yarnpkg.com/parse-srcset/-/parse-srcset-1.0.2.tgz#f2bd221f6cc970a938d88556abc589caaaa2bde1"
integrity sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==
parseley@^0.12.0:
version "0.12.1"
resolved "https://registry.yarnpkg.com/parseley/-/parseley-0.12.1.tgz#4afd561d50215ebe259e3e7a853e62f600683aef"
integrity sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==
dependencies:
leac "^0.6.0"
peberminta "^0.9.0"
parseurl@~1.3.3:
version "1.3.3"
resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
@ -5317,6 +5354,11 @@ pbkdf2@^3.0.3:
safe-buffer "^5.0.1"
sha.js "^2.4.8"
peberminta@^0.9.0:
version "0.9.0"
resolved "https://registry.yarnpkg.com/peberminta/-/peberminta-0.9.0.tgz#8ec9bc0eb84b7d368126e71ce9033501dca2a352"
integrity sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==
photoswipe-dynamic-caption-plugin@^1.2.7:
version "1.2.7"
resolved "https://registry.yarnpkg.com/photoswipe-dynamic-caption-plugin/-/photoswipe-dynamic-caption-plugin-1.2.7.tgz#53aa5059f1c4dccc8aa36196ff3e09baa5e537c2"
@ -5966,6 +6008,13 @@ scope-analyzer@^2.0.1:
estree-is-function "^1.0.0"
get-assigned-identifiers "^1.1.0"
selderee@^0.11.0:
version "0.11.0"
resolved "https://registry.yarnpkg.com/selderee/-/selderee-0.11.0.tgz#6af0c7983e073ad3e35787ffe20cefd9daf0ec8a"
integrity sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==
dependencies:
parseley "^0.12.0"
semver@^6.1.0, semver@^6.3.1:
version "6.3.1"
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"