mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2024-11-21 19:26:40 +00:00
[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:
parent
8e5a72ac5c
commit
f23f04e0b1
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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),
|
||||
|
|
104
internal/api/client/interactionrequests/authorize.go
Normal file
104
internal/api/client/interactionrequests/authorize.go
Normal 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)
|
||||
}
|
96
internal/api/client/interactionrequests/get.go
Normal file
96
internal/api/client/interactionrequests/get.go
Normal 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)
|
||||
}
|
211
internal/api/client/interactionrequests/getpage.go
Normal file
211
internal/api/client/interactionrequests/getpage.go
Normal 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)
|
||||
}
|
|
@ -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)
|
||||
}
|
104
internal/api/client/interactionrequests/reject.go
Normal file
104
internal/api/client/interactionrequests/reject.go
Normal 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)
|
||||
}
|
46
internal/api/model/interaction.go
Normal file
46
internal/api/model/interaction.go
Normal 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"`
|
||||
}
|
|
@ -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.
|
||||
*/
|
||||
|
|
4
internal/cache/cache.go
vendored
4
internal/cache/cache.go
vendored
|
@ -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
23
internal/cache/db.go
vendored
|
@ -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,
|
||||
|
|
11
internal/cache/size.go
vendored
11
internal/cache/size.go
vendored
|
@ -190,7 +190,7 @@ func totalOfRatios() float64 {
|
|||
config.GetCacheFollowRequestMemRatio() +
|
||||
config.GetCacheFollowRequestIDsMemRatio() +
|
||||
config.GetCacheInstanceMemRatio() +
|
||||
config.GetCacheInteractionApprovalMemRatio() +
|
||||
config.GetCacheInteractionRequestMemRatio() +
|
||||
config.GetCacheInReplyToIDsMemRatio() +
|
||||
config.GetCacheListMemRatio() +
|
||||
config.GetCacheListEntryMemRatio() +
|
||||
|
@ -441,16 +441,17 @@ func sizeofInstance() uintptr {
|
|||
}))
|
||||
}
|
||||
|
||||
func sizeofInteractionApproval() uintptr {
|
||||
return uintptr(size.Of(>smodel.InteractionApproval{
|
||||
func sizeofInteractionRequest() uintptr {
|
||||
return uintptr(size.Of(>smodel.InteractionRequest{
|
||||
ID: exampleID,
|
||||
CreatedAt: exampleTime,
|
||||
UpdatedAt: exampleTime,
|
||||
AccountID: exampleID,
|
||||
StatusID: exampleID,
|
||||
TargetAccountID: exampleID,
|
||||
InteractingAccountID: exampleID,
|
||||
InteractionURI: exampleURI,
|
||||
InteractionType: gtsmodel.InteractionAnnounce,
|
||||
URI: exampleURI,
|
||||
AcceptedAt: exampleTime,
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
|
@ -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"`
|
||||
|
|
|
@ -181,7 +181,7 @@
|
|||
FollowRequestIDsMemRatio: 2,
|
||||
InReplyToIDsMemRatio: 3,
|
||||
InstanceMemRatio: 1,
|
||||
InteractionApprovalMemRatio: 1,
|
||||
InteractionRequestMemRatio: 1,
|
||||
ListMemRatio: 1,
|
||||
ListEntryMemRatio: 2,
|
||||
MarkerMemRatio: 0.5,
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
261
internal/db/bundb/interaction_test.go
Normal file
261
internal/db/bundb/interaction_test.go
Normal 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))
|
||||
}
|
|
@ -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(>smodel.InteractionApproval{}).
|
||||
IfNotExists().
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.
|
||||
NewCreateIndex().
|
||||
Table("interaction_approvals").
|
||||
Index("interaction_approvals_account_id_idx").
|
||||
Column("account_id").
|
||||
IfNotExists().
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.
|
||||
NewCreateIndex().
|
||||
Table("interaction_approvals").
|
||||
Index("interaction_approvals_interacting_account_id_idx").
|
||||
Column("interacting_account_id").
|
||||
IfNotExists().
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
|
|
@ -0,0 +1,154 @@
|
|||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
|
||||
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
func init() {
|
||||
up := func(ctx context.Context, db *bun.DB) error {
|
||||
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
|
||||
|
||||
// Drop interaction approvals table if it exists,
|
||||
// ie., if instance was running on main between now
|
||||
// and 2024-07-16.
|
||||
//
|
||||
// We might lose some interaction approvals this way,
|
||||
// but since they weren't *really* used much yet this
|
||||
// it's not a big deal, that's the running-on-main life!
|
||||
if _, err := tx.NewDropTable().
|
||||
Table("interaction_approvals").
|
||||
IfExists().
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add `interaction_requests`
|
||||
// table and new indexes.
|
||||
if _, err := tx.
|
||||
NewCreateTable().
|
||||
Model(>smodel.InteractionRequest{}).
|
||||
IfNotExists().
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for idx, col := range map[string]string{
|
||||
"interaction_requests_status_id_idx": "status_id",
|
||||
"interaction_requests_target_account_id_idx": "target_account_id",
|
||||
"interaction_requests_interacting_account_id_idx": "interacting_account_id",
|
||||
} {
|
||||
if _, err := tx.
|
||||
NewCreateIndex().
|
||||
Table("interaction_requests").
|
||||
Index(idx).
|
||||
Column(col).
|
||||
IfNotExists().
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Select all pending statuses (replies or boosts).
|
||||
pendingStatuses := []*gtsmodel.Status{}
|
||||
err := tx.
|
||||
NewSelect().
|
||||
Model(&pendingStatuses).
|
||||
Column(
|
||||
"created_at",
|
||||
"in_reply_to_id",
|
||||
"boost_of_id",
|
||||
"in_reply_to_account_id",
|
||||
"boost_of_account_id",
|
||||
"account_id",
|
||||
"uri",
|
||||
).
|
||||
Where("? = ?", bun.Ident("pending_approval"), true).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// For each currently pending status, check whether it's a reply or
|
||||
// a boost, and insert a corresponding interaction request into the db.
|
||||
for _, pendingStatus := range pendingStatuses {
|
||||
req, err := typeutils.StatusToInteractionRequest(ctx, pendingStatus)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.
|
||||
NewInsert().
|
||||
Model(req).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Now do the same thing for pending faves.
|
||||
pendingFaves := []*gtsmodel.StatusFave{}
|
||||
err = tx.
|
||||
NewSelect().
|
||||
Model(&pendingFaves).
|
||||
Column(
|
||||
"created_at",
|
||||
"status_id",
|
||||
"target_account_id",
|
||||
"account_id",
|
||||
"uri",
|
||||
).
|
||||
Where("? = ?", bun.Ident("pending_approval"), true).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, pendingFave := range pendingFaves {
|
||||
req, err := typeutils.StatusFaveToInteractionRequest(ctx, pendingFave)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.
|
||||
NewInsert().
|
||||
Model(req).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
down := func(ctx context.Context, db *bun.DB) error {
|
||||
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := Migrations.Register(up, down); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
93
internal/gtsmodel/interaction.go
Normal file
93
internal/gtsmodel/interaction.go
Normal 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()
|
||||
}
|
|
@ -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
|
||||
)
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
239
internal/processing/interactionrequests/accept.go
Normal file
239
internal/processing/interactionrequests/accept.go
Normal 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
|
||||
}
|
89
internal/processing/interactionrequests/accept_test.go
Normal file
89
internal/processing/interactionrequests/accept_test.go
Normal 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))
|
||||
}
|
141
internal/processing/interactionrequests/get.go
Normal file
141
internal/processing/interactionrequests/get.go
Normal 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
|
||||
}
|
|
@ -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,
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
133
internal/processing/interactionrequests/reject.go
Normal file
133
internal/processing/interactionrequests/reject.go
Normal 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
|
||||
}
|
78
internal/processing/interactionrequests/reject_test.go
Normal file
78
internal/processing/interactionrequests/reject_test.go
Normal 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))
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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 := >smodel.InteractionRequest{
|
||||
ID: id,
|
||||
StatusID: status.InReplyToID,
|
||||
TargetAccountID: status.InReplyToAccountID,
|
||||
TargetAccount: status.InReplyToAccount,
|
||||
InteractingAccountID: status.AccountID,
|
||||
InteractingAccount: status.Account,
|
||||
InteractionURI: status.URI,
|
||||
InteractionType: gtsmodel.InteractionLike,
|
||||
Reply: status,
|
||||
URI: uris.GenerateURIForAccept(status.InReplyToAccount.Username, id),
|
||||
AcceptedAt: time.Now(),
|
||||
}
|
||||
if err := p.state.DB.PutInteractionRequest(ctx, approval); err != nil {
|
||||
return gtserror.Newf("db error putting pre-approved interaction request: %w", err)
|
||||
}
|
||||
|
||||
// Mark the status as now approved.
|
||||
status.PendingApproval = util.Ptr(false)
|
||||
status.PreApproved = false
|
||||
status.ApprovedByURI = approval.URI
|
||||
if err := p.state.DB.UpdateStatus(
|
||||
ctx,
|
||||
status,
|
||||
"pending_approval",
|
||||
"approved_by_uri",
|
||||
); err != nil {
|
||||
return gtserror.Newf("db error updating status: %w", err)
|
||||
}
|
||||
|
||||
// Send out the approval as Accept.
|
||||
if err := p.federate.AcceptInteraction(ctx, approval); err != nil {
|
||||
return gtserror.Newf("error federating pre-approval of reply: %w", err)
|
||||
}
|
||||
|
@ -309,16 +349,16 @@ func (p *clientAPI) CreateStatus(ctx context.Context, cMsg *messages.FromClientA
|
|||
log.Errorf(ctx, "error timelining and notifying status: %v", err)
|
||||
}
|
||||
|
||||
if err := p.federate.CreateStatus(ctx, status); err != nil {
|
||||
log.Errorf(ctx, "error federating status: %v", err)
|
||||
}
|
||||
|
||||
if status.InReplyToID != "" {
|
||||
// Interaction counts changed on the replied status;
|
||||
// uncache the prepared version from all timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, status.InReplyToID)
|
||||
}
|
||||
|
||||
if err := p.federate.CreateStatus(ctx, status); err != nil {
|
||||
log.Errorf(ctx, "error federating status: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -344,9 +384,6 @@ func (p *clientAPI) CreatePollVote(ctx context.Context, cMsg *messages.FromClien
|
|||
status := vote.Poll.Status
|
||||
status.Poll = vote.Poll
|
||||
|
||||
// Interaction counts changed on the source status, uncache from timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, vote.Poll.StatusID)
|
||||
|
||||
if *status.Local {
|
||||
// These are poll votes in a local status, we only need to
|
||||
// federate the updated status model with latest vote counts.
|
||||
|
@ -360,6 +397,9 @@ func (p *clientAPI) CreatePollVote(ctx context.Context, cMsg *messages.FromClien
|
|||
}
|
||||
}
|
||||
|
||||
// Interaction counts changed on the source status, uncache from timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, vote.Poll.StatusID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -429,10 +469,7 @@ func (p *clientAPI) CreateLike(ctx context.Context, cMsg *messages.FromClientAPI
|
|||
// If pending approval is true then fave must
|
||||
// target a status (either one of ours or a
|
||||
// remote) that requires approval for the fave.
|
||||
pendingApproval := util.PtrOrValue(
|
||||
fave.PendingApproval,
|
||||
false,
|
||||
)
|
||||
pendingApproval := util.PtrOrZero(fave.PendingApproval)
|
||||
|
||||
switch {
|
||||
case pendingApproval && !fave.PreApproved:
|
||||
|
@ -442,15 +479,13 @@ func (p *clientAPI) CreateLike(ctx context.Context, cMsg *messages.FromClientAPI
|
|||
// and/or notify the account that's being
|
||||
// interacted with (if it's local): they can
|
||||
// approve or deny the interaction later.
|
||||
|
||||
// Notify *local* account of pending reply.
|
||||
if err := p.surface.notifyPendingFave(ctx, fave); err != nil {
|
||||
log.Errorf(ctx, "error notifying pending fave: %v", err)
|
||||
if err := p.utils.requestFave(ctx, fave); err != nil {
|
||||
return gtserror.Newf("error pending fave: %w", err)
|
||||
}
|
||||
|
||||
// Send Like to *remote* account inbox ONLY.
|
||||
if err := p.federate.Like(ctx, fave); err != nil {
|
||||
log.Errorf(ctx, "error federating pending Like: %v", err)
|
||||
return gtserror.Newf("error federating pending Like: %v", err)
|
||||
}
|
||||
|
||||
// Return early.
|
||||
|
@ -466,14 +501,38 @@ func (p *clientAPI) CreateLike(ctx context.Context, cMsg *messages.FromClientAPI
|
|||
// sending out the Like with the approval
|
||||
// URI attached.
|
||||
|
||||
// Put approval in the database and
|
||||
// update the fave with approvedBy URI.
|
||||
approval, err := p.utils.approveFave(ctx, fave)
|
||||
if err != nil {
|
||||
return gtserror.Newf("error pre-approving fave: %w", err)
|
||||
// Store an already-accepted interaction request.
|
||||
id := id.NewULID()
|
||||
approval := >smodel.InteractionRequest{
|
||||
ID: id,
|
||||
StatusID: fave.StatusID,
|
||||
TargetAccountID: fave.TargetAccountID,
|
||||
TargetAccount: fave.TargetAccount,
|
||||
InteractingAccountID: fave.AccountID,
|
||||
InteractingAccount: fave.Account,
|
||||
InteractionURI: fave.URI,
|
||||
InteractionType: gtsmodel.InteractionLike,
|
||||
Like: fave,
|
||||
URI: uris.GenerateURIForAccept(fave.TargetAccount.Username, id),
|
||||
AcceptedAt: time.Now(),
|
||||
}
|
||||
if err := p.state.DB.PutInteractionRequest(ctx, approval); err != nil {
|
||||
return gtserror.Newf("db error putting pre-approved interaction request: %w", err)
|
||||
}
|
||||
|
||||
// Mark the fave itself as now approved.
|
||||
fave.PendingApproval = util.Ptr(false)
|
||||
fave.PreApproved = false
|
||||
fave.ApprovedByURI = approval.URI
|
||||
if err := p.state.DB.UpdateStatusFave(
|
||||
ctx,
|
||||
fave,
|
||||
"pending_approval",
|
||||
"approved_by_uri",
|
||||
); err != nil {
|
||||
return gtserror.Newf("db error updating status fave: %w", err)
|
||||
}
|
||||
|
||||
// Send out the approval as Accept.
|
||||
if err := p.federate.AcceptInteraction(ctx, approval); err != nil {
|
||||
return gtserror.Newf("error federating pre-approval of fave: %w", err)
|
||||
}
|
||||
|
@ -485,14 +544,14 @@ func (p *clientAPI) CreateLike(ctx context.Context, cMsg *messages.FromClientAPI
|
|||
log.Errorf(ctx, "error notifying fave: %v", err)
|
||||
}
|
||||
|
||||
// Interaction counts changed on the faved status;
|
||||
// uncache the prepared version from all timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, fave.StatusID)
|
||||
|
||||
if err := p.federate.Like(ctx, fave); err != nil {
|
||||
log.Errorf(ctx, "error federating like: %v", err)
|
||||
}
|
||||
|
||||
// Interaction counts changed on the faved status;
|
||||
// uncache the prepared version from all timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, fave.StatusID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -505,10 +564,7 @@ func (p *clientAPI) CreateAnnounce(ctx context.Context, cMsg *messages.FromClien
|
|||
// If pending approval is true then status must
|
||||
// boost a status (either one of ours or a
|
||||
// remote) that requires approval for the boost.
|
||||
pendingApproval := util.PtrOrValue(
|
||||
boost.PendingApproval,
|
||||
false,
|
||||
)
|
||||
pendingApproval := util.PtrOrZero(boost.PendingApproval)
|
||||
|
||||
switch {
|
||||
case pendingApproval && !boost.PreApproved:
|
||||
|
@ -518,15 +574,13 @@ func (p *clientAPI) CreateAnnounce(ctx context.Context, cMsg *messages.FromClien
|
|||
// and/or notify the account that's being
|
||||
// interacted with (if it's local): they can
|
||||
// approve or deny the interaction later.
|
||||
|
||||
// Notify *local* account of pending announce.
|
||||
if err := p.surface.notifyPendingAnnounce(ctx, boost); err != nil {
|
||||
log.Errorf(ctx, "error notifying pending boost: %v", err)
|
||||
if err := p.utils.requestAnnounce(ctx, boost); err != nil {
|
||||
return gtserror.Newf("error pending boost: %w", err)
|
||||
}
|
||||
|
||||
// Send Announce to *remote* account inbox ONLY.
|
||||
if err := p.federate.Announce(ctx, boost); err != nil {
|
||||
log.Errorf(ctx, "error federating pending Announce: %v", err)
|
||||
return gtserror.Newf("error federating pending Announce: %v", err)
|
||||
}
|
||||
|
||||
// Return early.
|
||||
|
@ -542,14 +596,38 @@ func (p *clientAPI) CreateAnnounce(ctx context.Context, cMsg *messages.FromClien
|
|||
// sending out the Create with the approval
|
||||
// URI attached.
|
||||
|
||||
// Put approval in the database and
|
||||
// update the boost with approvedBy URI.
|
||||
approval, err := p.utils.approveAnnounce(ctx, boost)
|
||||
if err != nil {
|
||||
return gtserror.Newf("error pre-approving boost: %w", err)
|
||||
// Store an already-accepted interaction request.
|
||||
id := id.NewULID()
|
||||
approval := >smodel.InteractionRequest{
|
||||
ID: id,
|
||||
StatusID: boost.BoostOfID,
|
||||
TargetAccountID: boost.BoostOfAccountID,
|
||||
TargetAccount: boost.BoostOfAccount,
|
||||
InteractingAccountID: boost.AccountID,
|
||||
InteractingAccount: boost.Account,
|
||||
InteractionURI: boost.URI,
|
||||
InteractionType: gtsmodel.InteractionLike,
|
||||
Announce: boost,
|
||||
URI: uris.GenerateURIForAccept(boost.BoostOfAccount.Username, id),
|
||||
AcceptedAt: time.Now(),
|
||||
}
|
||||
if err := p.state.DB.PutInteractionRequest(ctx, approval); err != nil {
|
||||
return gtserror.Newf("db error putting pre-approved interaction request: %w", err)
|
||||
}
|
||||
|
||||
// Mark the boost itself as now approved.
|
||||
boost.PendingApproval = util.Ptr(false)
|
||||
boost.PreApproved = false
|
||||
boost.ApprovedByURI = approval.URI
|
||||
if err := p.state.DB.UpdateStatus(
|
||||
ctx,
|
||||
boost,
|
||||
"pending_approval",
|
||||
"approved_by_uri",
|
||||
); err != nil {
|
||||
return gtserror.Newf("db error updating status: %w", err)
|
||||
}
|
||||
|
||||
// Send out the approval as Accept.
|
||||
if err := p.federate.AcceptInteraction(ctx, approval); err != nil {
|
||||
return gtserror.Newf("error federating pre-approval of boost: %w", err)
|
||||
}
|
||||
|
@ -572,14 +650,14 @@ func (p *clientAPI) CreateAnnounce(ctx context.Context, cMsg *messages.FromClien
|
|||
log.Errorf(ctx, "error notifying boost: %v", err)
|
||||
}
|
||||
|
||||
// Interaction counts changed on the boosted status;
|
||||
// uncache the prepared version from all timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, boost.BoostOfID)
|
||||
|
||||
if err := p.federate.Announce(ctx, boost); err != nil {
|
||||
log.Errorf(ctx, "error federating announce: %v", err)
|
||||
}
|
||||
|
||||
// Interaction counts changed on the boosted status;
|
||||
// uncache the prepared version from all timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, boost.BoostOfID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -629,9 +707,6 @@ func (p *clientAPI) UpdateStatus(ctx context.Context, cMsg *messages.FromClientA
|
|||
log.Errorf(ctx, "error federating status update: %v", err)
|
||||
}
|
||||
|
||||
// Status representation has changed, invalidate from timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, status.ID)
|
||||
|
||||
if status.Poll != nil && status.Poll.Closing {
|
||||
|
||||
// If the latest status has a newly closed poll, at least compared
|
||||
|
@ -646,6 +721,9 @@ func (p *clientAPI) UpdateStatus(ctx context.Context, cMsg *messages.FromClientA
|
|||
log.Errorf(ctx, "error streaming status edit: %v", err)
|
||||
}
|
||||
|
||||
// Status representation has changed, invalidate from timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, status.ID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -791,14 +869,14 @@ func (p *clientAPI) UndoFave(ctx context.Context, cMsg *messages.FromClientAPI)
|
|||
return gtserror.Newf("%T not parseable as *gtsmodel.StatusFave", cMsg.GTSModel)
|
||||
}
|
||||
|
||||
// Interaction counts changed on the faved status;
|
||||
// uncache the prepared version from all timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, statusFave.StatusID)
|
||||
|
||||
if err := p.federate.UndoLike(ctx, statusFave); err != nil {
|
||||
log.Errorf(ctx, "error federating like undo: %v", err)
|
||||
}
|
||||
|
||||
// Interaction counts changed on the faved status;
|
||||
// uncache the prepared version from all timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, statusFave.StatusID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -821,14 +899,14 @@ func (p *clientAPI) UndoAnnounce(ctx context.Context, cMsg *messages.FromClientA
|
|||
log.Errorf(ctx, "error removing timelined status: %v", err)
|
||||
}
|
||||
|
||||
// Interaction counts changed on the boosted status;
|
||||
// uncache the prepared version from all timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, status.BoostOfID)
|
||||
|
||||
if err := p.federate.UndoAnnounce(ctx, status); err != nil {
|
||||
log.Errorf(ctx, "error federating announce undo: %v", err)
|
||||
}
|
||||
|
||||
// Interaction counts changed on the boosted status;
|
||||
// uncache the prepared version from all timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, status.BoostOfID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -874,16 +952,16 @@ func (p *clientAPI) DeleteStatus(ctx context.Context, cMsg *messages.FromClientA
|
|||
log.Errorf(ctx, "error updating account stats: %v", err)
|
||||
}
|
||||
|
||||
if err := p.federate.DeleteStatus(ctx, status); err != nil {
|
||||
log.Errorf(ctx, "error federating status delete: %v", err)
|
||||
}
|
||||
|
||||
if status.InReplyToID != "" {
|
||||
// Interaction counts changed on the replied status;
|
||||
// uncache the prepared version from all timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, status.InReplyToID)
|
||||
}
|
||||
|
||||
if err := p.federate.DeleteStatus(ctx, status); err != nil {
|
||||
log.Errorf(ctx, "error federating status delete: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -1050,16 +1128,188 @@ func (p *clientAPI) RejectUser(ctx context.Context, cMsg *messages.FromClientAPI
|
|||
}
|
||||
|
||||
func (p *clientAPI) AcceptLike(ctx context.Context, cMsg *messages.FromClientAPI) error {
|
||||
// TODO
|
||||
req, ok := cMsg.GTSModel.(*gtsmodel.InteractionRequest)
|
||||
if !ok {
|
||||
return gtserror.Newf("%T not parseable as *gtsmodel.InteractionRequest", cMsg.GTSModel)
|
||||
}
|
||||
|
||||
// Notify the fave (distinct from the notif for the pending fave).
|
||||
if err := p.surface.notifyFave(ctx, req.Like); err != nil {
|
||||
log.Errorf(ctx, "error notifying fave: %v", err)
|
||||
}
|
||||
|
||||
// Send out the Accept.
|
||||
if err := p.federate.AcceptInteraction(ctx, req); err != nil {
|
||||
log.Errorf(ctx, "error federating approval of like: %v", err)
|
||||
}
|
||||
|
||||
// Interaction counts changed on the faved status;
|
||||
// uncache the prepared version from all timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, req.Like.StatusID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *clientAPI) AcceptReply(ctx context.Context, cMsg *messages.FromClientAPI) error {
|
||||
// TODO
|
||||
req, ok := cMsg.GTSModel.(*gtsmodel.InteractionRequest)
|
||||
if !ok {
|
||||
return gtserror.Newf("%T not parseable as *gtsmodel.InteractionRequest", cMsg.GTSModel)
|
||||
}
|
||||
|
||||
var (
|
||||
interactingAcct = req.InteractingAccount
|
||||
reply = req.Reply
|
||||
)
|
||||
|
||||
// Update stats for the reply author account.
|
||||
if err := p.utils.incrementStatusesCount(ctx, interactingAcct, reply); err != nil {
|
||||
log.Errorf(ctx, "error updating account stats: %v", err)
|
||||
}
|
||||
|
||||
// Timeline the reply + notify relevant accounts.
|
||||
if err := p.surface.timelineAndNotifyStatus(ctx, reply); err != nil {
|
||||
log.Errorf(ctx, "error timelining and notifying status reply: %v", err)
|
||||
}
|
||||
|
||||
// Send out the Accept.
|
||||
if err := p.federate.AcceptInteraction(ctx, req); err != nil {
|
||||
log.Errorf(ctx, "error federating approval of reply: %v", err)
|
||||
}
|
||||
|
||||
// Interaction counts changed on the replied status;
|
||||
// uncache the prepared version from all timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, reply.InReplyToID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *clientAPI) AcceptAnnounce(ctx context.Context, cMsg *messages.FromClientAPI) error {
|
||||
// TODO
|
||||
req, ok := cMsg.GTSModel.(*gtsmodel.InteractionRequest)
|
||||
if !ok {
|
||||
return gtserror.Newf("%T not parseable as *gtsmodel.InteractionRequest", cMsg.GTSModel)
|
||||
}
|
||||
|
||||
var (
|
||||
interactingAcct = req.InteractingAccount
|
||||
boost = req.Announce
|
||||
)
|
||||
|
||||
// Update stats for the boost author account.
|
||||
if err := p.utils.incrementStatusesCount(ctx, interactingAcct, boost); err != nil {
|
||||
log.Errorf(ctx, "error updating account stats: %v", err)
|
||||
}
|
||||
|
||||
// Timeline and notify the announce.
|
||||
if err := p.surface.timelineAndNotifyStatus(ctx, boost); err != nil {
|
||||
log.Errorf(ctx, "error timelining and notifying status: %v", err)
|
||||
}
|
||||
|
||||
// Notify the announce (distinct from the notif for the pending announce).
|
||||
if err := p.surface.notifyAnnounce(ctx, boost); err != nil {
|
||||
log.Errorf(ctx, "error notifying announce: %v", err)
|
||||
}
|
||||
|
||||
// Send out the Accept.
|
||||
if err := p.federate.AcceptInteraction(ctx, req); err != nil {
|
||||
log.Errorf(ctx, "error federating approval of announce: %v", err)
|
||||
}
|
||||
|
||||
// Interaction counts changed on the original status;
|
||||
// uncache the prepared version from all timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, boost.BoostOfID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *clientAPI) RejectLike(ctx context.Context, cMsg *messages.FromClientAPI) error {
|
||||
req, ok := cMsg.GTSModel.(*gtsmodel.InteractionRequest)
|
||||
if !ok {
|
||||
return gtserror.Newf("%T not parseable as *gtsmodel.InteractionRequest", cMsg.GTSModel)
|
||||
}
|
||||
|
||||
// At this point the InteractionRequest should already
|
||||
// be in the database, we just need to do side effects.
|
||||
|
||||
// Send out the Reject.
|
||||
if err := p.federate.RejectInteraction(ctx, req); err != nil {
|
||||
log.Errorf(ctx, "error federating rejection of like: %v", err)
|
||||
}
|
||||
|
||||
// Get the rejected fave.
|
||||
fave, err := p.state.DB.GetStatusFaveByURI(
|
||||
gtscontext.SetBarebones(ctx),
|
||||
req.InteractionURI,
|
||||
)
|
||||
if err != nil {
|
||||
return gtserror.Newf("db error getting rejected fave: %w", err)
|
||||
}
|
||||
|
||||
// Delete the status fave.
|
||||
if err := p.state.DB.DeleteStatusFaveByID(ctx, fave.ID); err != nil {
|
||||
return gtserror.Newf("db error deleting status fave: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *clientAPI) RejectReply(ctx context.Context, cMsg *messages.FromClientAPI) error {
|
||||
req, ok := cMsg.GTSModel.(*gtsmodel.InteractionRequest)
|
||||
if !ok {
|
||||
return gtserror.Newf("%T not parseable as *gtsmodel.InteractionRequest", cMsg.GTSModel)
|
||||
}
|
||||
|
||||
// At this point the InteractionRequest should already
|
||||
// be in the database, we just need to do side effects.
|
||||
|
||||
// Send out the Reject.
|
||||
if err := p.federate.RejectInteraction(ctx, req); err != nil {
|
||||
log.Errorf(ctx, "error federating rejection of reply: %v", err)
|
||||
}
|
||||
|
||||
// Get the rejected status.
|
||||
status, err := p.state.DB.GetStatusByURI(
|
||||
gtscontext.SetBarebones(ctx),
|
||||
req.InteractionURI,
|
||||
)
|
||||
if err != nil {
|
||||
return gtserror.Newf("db error getting rejected reply: %w", err)
|
||||
}
|
||||
|
||||
// Totally wipe the status.
|
||||
if err := p.utils.wipeStatus(ctx, status, true); err != nil {
|
||||
return gtserror.Newf("error wiping status: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *clientAPI) RejectAnnounce(ctx context.Context, cMsg *messages.FromClientAPI) error {
|
||||
req, ok := cMsg.GTSModel.(*gtsmodel.InteractionRequest)
|
||||
if !ok {
|
||||
return gtserror.Newf("%T not parseable as *gtsmodel.InteractionRequest", cMsg.GTSModel)
|
||||
}
|
||||
|
||||
// At this point the InteractionRequest should already
|
||||
// be in the database, we just need to do side effects.
|
||||
|
||||
// Send out the Reject.
|
||||
if err := p.federate.RejectInteraction(ctx, req); err != nil {
|
||||
log.Errorf(ctx, "error federating rejection of announce: %v", err)
|
||||
}
|
||||
|
||||
// Get the rejected boost.
|
||||
boost, err := p.state.DB.GetStatusByURI(
|
||||
gtscontext.SetBarebones(ctx),
|
||||
req.InteractionURI,
|
||||
)
|
||||
if err != nil {
|
||||
return gtserror.Newf("db error getting rejected announce: %w", err)
|
||||
}
|
||||
|
||||
// Totally wipe the status.
|
||||
if err := p.utils.wipeStatus(ctx, boost, true); err != nil {
|
||||
return gtserror.Newf("error wiping status: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 := >smodel.InteractionRequest{
|
||||
ID: id,
|
||||
StatusID: status.InReplyToID,
|
||||
TargetAccountID: status.InReplyToAccountID,
|
||||
TargetAccount: status.InReplyToAccount,
|
||||
InteractingAccountID: status.AccountID,
|
||||
InteractingAccount: status.Account,
|
||||
InteractionURI: status.URI,
|
||||
InteractionType: gtsmodel.InteractionLike,
|
||||
Reply: status,
|
||||
URI: uris.GenerateURIForAccept(status.InReplyToAccount.Username, id),
|
||||
AcceptedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Mark the status as now approved.
|
||||
status.PendingApproval = util.Ptr(false)
|
||||
status.PreApproved = false
|
||||
status.ApprovedByURI = approval.URI
|
||||
if err := p.state.DB.UpdateStatus(
|
||||
ctx,
|
||||
status,
|
||||
"pending_approval",
|
||||
"approved_by_uri",
|
||||
); err != nil {
|
||||
return gtserror.Newf("db error updating status: %w", err)
|
||||
}
|
||||
|
||||
// Send out the approval as Accept.
|
||||
|
@ -279,6 +301,10 @@ func (p *fediAPI) CreateStatus(ctx context.Context, fMsg *messages.FromFediAPI)
|
|||
log.Errorf(ctx, "error updating account stats: %v", err)
|
||||
}
|
||||
|
||||
if err := p.surface.timelineAndNotifyStatus(ctx, status); err != nil {
|
||||
log.Errorf(ctx, "error timelining and notifying status: %v", err)
|
||||
}
|
||||
|
||||
if status.InReplyToID != "" {
|
||||
// Interaction counts changed on the replied status; uncache the
|
||||
// prepared version from all timelines. The status dereferencer
|
||||
|
@ -286,10 +312,6 @@ func (p *fediAPI) CreateStatus(ctx context.Context, fMsg *messages.FromFediAPI)
|
|||
p.surface.invalidateStatusFromTimelines(ctx, status.InReplyToID)
|
||||
}
|
||||
|
||||
if err := p.surface.timelineAndNotifyStatus(ctx, status); err != nil {
|
||||
log.Errorf(ctx, "error timelining and notifying status: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -320,9 +342,6 @@ func (p *fediAPI) CreatePollVote(ctx context.Context, fMsg *messages.FromFediAPI
|
|||
status := vote.Poll.Status
|
||||
status.Poll = vote.Poll
|
||||
|
||||
// Interaction counts changed on the source status, uncache from timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, vote.Poll.StatusID)
|
||||
|
||||
if *status.Local {
|
||||
// Before federating it, increment the
|
||||
// poll vote counts on our local copy.
|
||||
|
@ -335,6 +354,9 @@ func (p *fediAPI) CreatePollVote(ctx context.Context, fMsg *messages.FromFediAPI
|
|||
}
|
||||
}
|
||||
|
||||
// Interaction counts changed on the source status, uncache from timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, vote.Poll.StatusID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -409,10 +431,7 @@ func (p *fediAPI) CreateLike(ctx context.Context, fMsg *messages.FromFediAPI) er
|
|||
// If pending approval is true then
|
||||
// fave must target a LOCAL status
|
||||
// that requires approval for the fave.
|
||||
pendingApproval := util.PtrOrValue(
|
||||
fave.PendingApproval,
|
||||
false,
|
||||
)
|
||||
pendingApproval := util.PtrOrZero(fave.PendingApproval)
|
||||
|
||||
switch {
|
||||
case pendingApproval && !fave.PreApproved:
|
||||
|
@ -420,10 +439,8 @@ func (p *fediAPI) CreateLike(ctx context.Context, fMsg *messages.FromFediAPI) er
|
|||
// preapproved, then just notify the account
|
||||
// that's being interacted with: they can
|
||||
// approve or deny the interaction later.
|
||||
|
||||
// Notify *local* account of pending fave.
|
||||
if err := p.surface.notifyPendingFave(ctx, fave); err != nil {
|
||||
log.Errorf(ctx, "error notifying pending fave: %v", err)
|
||||
if err := p.utils.requestFave(ctx, fave); err != nil {
|
||||
return gtserror.Newf("error pending fave: %w", err)
|
||||
}
|
||||
|
||||
// Return early.
|
||||
|
@ -437,11 +454,33 @@ func (p *fediAPI) CreateLike(ctx context.Context, fMsg *messages.FromFediAPI) er
|
|||
// collection. Do the Accept immediately and
|
||||
// then process everything else as normal.
|
||||
|
||||
// Put approval in the database and
|
||||
// update the fave with approvedBy URI.
|
||||
approval, err := p.utils.approveFave(ctx, fave)
|
||||
if err != nil {
|
||||
return gtserror.Newf("error pre-approving fave: %w", err)
|
||||
// Store an already-accepted interaction request.
|
||||
id := id.NewULID()
|
||||
approval := >smodel.InteractionRequest{
|
||||
ID: id,
|
||||
StatusID: fave.StatusID,
|
||||
TargetAccountID: fave.TargetAccountID,
|
||||
TargetAccount: fave.TargetAccount,
|
||||
InteractingAccountID: fave.AccountID,
|
||||
InteractingAccount: fave.Account,
|
||||
InteractionURI: fave.URI,
|
||||
InteractionType: gtsmodel.InteractionLike,
|
||||
Like: fave,
|
||||
URI: uris.GenerateURIForAccept(fave.TargetAccount.Username, id),
|
||||
AcceptedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Mark the fave itself as now approved.
|
||||
fave.PendingApproval = util.Ptr(false)
|
||||
fave.PreApproved = false
|
||||
fave.ApprovedByURI = approval.URI
|
||||
if err := p.state.DB.UpdateStatusFave(
|
||||
ctx,
|
||||
fave,
|
||||
"pending_approval",
|
||||
"approved_by_uri",
|
||||
); err != nil {
|
||||
return gtserror.Newf("db error updating status fave: %w", err)
|
||||
}
|
||||
|
||||
// Send out the approval as Accept.
|
||||
|
@ -496,10 +535,7 @@ func (p *fediAPI) CreateAnnounce(ctx context.Context, fMsg *messages.FromFediAPI
|
|||
// If pending approval is true then
|
||||
// boost must target a LOCAL status
|
||||
// that requires approval for the boost.
|
||||
pendingApproval := util.PtrOrValue(
|
||||
boost.PendingApproval,
|
||||
false,
|
||||
)
|
||||
pendingApproval := util.PtrOrZero(boost.PendingApproval)
|
||||
|
||||
switch {
|
||||
case pendingApproval && !boost.PreApproved:
|
||||
|
@ -507,10 +543,8 @@ func (p *fediAPI) CreateAnnounce(ctx context.Context, fMsg *messages.FromFediAPI
|
|||
// preapproved, then just notify the account
|
||||
// that's being interacted with: they can
|
||||
// approve or deny the interaction later.
|
||||
|
||||
// Notify *local* account of pending announce.
|
||||
if err := p.surface.notifyPendingAnnounce(ctx, boost); err != nil {
|
||||
log.Errorf(ctx, "error notifying pending boost: %v", err)
|
||||
if err := p.utils.requestAnnounce(ctx, boost); err != nil {
|
||||
return gtserror.Newf("error pending boost: %w", err)
|
||||
}
|
||||
|
||||
// Return early.
|
||||
|
@ -524,11 +558,33 @@ func (p *fediAPI) CreateAnnounce(ctx context.Context, fMsg *messages.FromFediAPI
|
|||
// collection. Do the Accept immediately and
|
||||
// then process everything else as normal.
|
||||
|
||||
// Put approval in the database and
|
||||
// update the boost with approvedBy URI.
|
||||
approval, err := p.utils.approveAnnounce(ctx, boost)
|
||||
if err != nil {
|
||||
return gtserror.Newf("error pre-approving boost: %w", err)
|
||||
// Store an already-accepted interaction request.
|
||||
id := id.NewULID()
|
||||
approval := >smodel.InteractionRequest{
|
||||
ID: id,
|
||||
StatusID: boost.BoostOfID,
|
||||
TargetAccountID: boost.BoostOfAccountID,
|
||||
TargetAccount: boost.BoostOfAccount,
|
||||
InteractingAccountID: boost.AccountID,
|
||||
InteractingAccount: boost.Account,
|
||||
InteractionURI: boost.URI,
|
||||
InteractionType: gtsmodel.InteractionLike,
|
||||
Announce: boost,
|
||||
URI: uris.GenerateURIForAccept(boost.BoostOfAccount.Username, id),
|
||||
AcceptedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Mark the boost itself as now approved.
|
||||
boost.PendingApproval = util.Ptr(false)
|
||||
boost.PreApproved = false
|
||||
boost.ApprovedByURI = approval.URI
|
||||
if err := p.state.DB.UpdateStatus(
|
||||
ctx,
|
||||
boost,
|
||||
"pending_approval",
|
||||
"approved_by_uri",
|
||||
); err != nil {
|
||||
return gtserror.Newf("db error updating status: %w", err)
|
||||
}
|
||||
|
||||
// Send out the approval as Accept.
|
||||
|
@ -729,15 +785,15 @@ func (p *fediAPI) AcceptReply(ctx context.Context, fMsg *messages.FromFediAPI) e
|
|||
log.Errorf(ctx, "error timelining and notifying status: %v", err)
|
||||
}
|
||||
|
||||
// Interaction counts changed on the replied-to status;
|
||||
// uncache the prepared version from all timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, status.InReplyToID)
|
||||
|
||||
// Send out the reply again, fully this time.
|
||||
if err := p.federate.CreateStatus(ctx, status); err != nil {
|
||||
log.Errorf(ctx, "error federating announce: %v", err)
|
||||
}
|
||||
|
||||
// Interaction counts changed on the replied-to status;
|
||||
// uncache the prepared version from all timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, status.InReplyToID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -757,15 +813,15 @@ func (p *fediAPI) AcceptAnnounce(ctx context.Context, fMsg *messages.FromFediAPI
|
|||
log.Errorf(ctx, "error timelining and notifying status: %v", err)
|
||||
}
|
||||
|
||||
// Interaction counts changed on the boosted status;
|
||||
// uncache the prepared version from all timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, boost.BoostOfID)
|
||||
|
||||
// Send out the boost again, fully this time.
|
||||
if err := p.federate.Announce(ctx, boost); err != nil {
|
||||
log.Errorf(ctx, "error federating announce: %v", err)
|
||||
}
|
||||
|
||||
// Interaction counts changed on the boosted status;
|
||||
// uncache the prepared version from all timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, boost.BoostOfID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -792,9 +848,6 @@ func (p *fediAPI) UpdateStatus(ctx context.Context, fMsg *messages.FromFediAPI)
|
|||
log.Errorf(ctx, "error refreshing status: %v", err)
|
||||
}
|
||||
|
||||
// Status representation was refetched, uncache from timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, status.ID)
|
||||
|
||||
if status.Poll != nil && status.Poll.Closing {
|
||||
|
||||
// If the latest status has a newly closed poll, at least compared
|
||||
|
@ -809,6 +862,9 @@ func (p *fediAPI) UpdateStatus(ctx context.Context, fMsg *messages.FromFediAPI)
|
|||
log.Errorf(ctx, "error streaming status edit: %v", err)
|
||||
}
|
||||
|
||||
// Status representation was refetched, uncache from timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, status.ID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
@ -42,8 +42,8 @@ type FromFediAPITestSuite struct {
|
|||
|
||||
// remote_account_1 boosts the first status of local_account_1
|
||||
func (suite *FromFediAPITestSuite) TestProcessFederationAnnounce() {
|
||||
testStructs := suite.SetupTestStructs()
|
||||
defer suite.TearDownTestStructs(testStructs)
|
||||
testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath)
|
||||
defer testrig.TearDownTestStructs(testStructs)
|
||||
|
||||
boostedStatus := >smodel.Status{}
|
||||
*boostedStatus = *suite.testStatuses["local_account_1_status_1"]
|
||||
|
@ -106,8 +106,8 @@ func (suite *FromFediAPITestSuite) TestProcessFederationAnnounce() {
|
|||
}
|
||||
|
||||
func (suite *FromFediAPITestSuite) TestProcessReplyMention() {
|
||||
testStructs := suite.SetupTestStructs()
|
||||
defer suite.TearDownTestStructs(testStructs)
|
||||
testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath)
|
||||
defer testrig.TearDownTestStructs(testStructs)
|
||||
|
||||
repliedAccount := >smodel.Account{}
|
||||
*repliedAccount = *suite.testAccounts["local_account_1"]
|
||||
|
@ -190,8 +190,8 @@ func (suite *FromFediAPITestSuite) TestProcessReplyMention() {
|
|||
}
|
||||
|
||||
func (suite *FromFediAPITestSuite) TestProcessFave() {
|
||||
testStructs := suite.SetupTestStructs()
|
||||
defer suite.TearDownTestStructs(testStructs)
|
||||
testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath)
|
||||
defer testrig.TearDownTestStructs(testStructs)
|
||||
|
||||
favedAccount := suite.testAccounts["local_account_1"]
|
||||
favedStatus := suite.testStatuses["local_account_1_status_1"]
|
||||
|
@ -262,8 +262,8 @@ func (suite *FromFediAPITestSuite) TestProcessFave() {
|
|||
// This tests for an issue we were seeing where Misskey sends out faves to inboxes of people that don't own
|
||||
// the fave, but just follow the actor who received the fave.
|
||||
func (suite *FromFediAPITestSuite) TestProcessFaveWithDifferentReceivingAccount() {
|
||||
testStructs := suite.SetupTestStructs()
|
||||
defer suite.TearDownTestStructs(testStructs)
|
||||
testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath)
|
||||
defer testrig.TearDownTestStructs(testStructs)
|
||||
|
||||
receivingAccount := suite.testAccounts["local_account_2"]
|
||||
favedAccount := suite.testAccounts["local_account_1"]
|
||||
|
@ -327,8 +327,8 @@ func (suite *FromFediAPITestSuite) TestProcessFaveWithDifferentReceivingAccount(
|
|||
}
|
||||
|
||||
func (suite *FromFediAPITestSuite) TestProcessAccountDelete() {
|
||||
testStructs := suite.SetupTestStructs()
|
||||
defer suite.TearDownTestStructs(testStructs)
|
||||
testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath)
|
||||
defer testrig.TearDownTestStructs(testStructs)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
|
@ -421,8 +421,8 @@ func (suite *FromFediAPITestSuite) TestProcessAccountDelete() {
|
|||
}
|
||||
|
||||
func (suite *FromFediAPITestSuite) TestProcessFollowRequestLocked() {
|
||||
testStructs := suite.SetupTestStructs()
|
||||
defer suite.TearDownTestStructs(testStructs)
|
||||
testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath)
|
||||
defer testrig.TearDownTestStructs(testStructs)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
|
@ -478,8 +478,8 @@ func (suite *FromFediAPITestSuite) TestProcessFollowRequestLocked() {
|
|||
}
|
||||
|
||||
func (suite *FromFediAPITestSuite) TestProcessFollowRequestUnlocked() {
|
||||
testStructs := suite.SetupTestStructs()
|
||||
defer suite.TearDownTestStructs(testStructs)
|
||||
testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath)
|
||||
defer testrig.TearDownTestStructs(testStructs)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
|
@ -579,8 +579,8 @@ func (suite *FromFediAPITestSuite) TestProcessFollowRequestUnlocked() {
|
|||
|
||||
// TestCreateStatusFromIRI checks if a forwarded status can be dereferenced by the processor.
|
||||
func (suite *FromFediAPITestSuite) TestCreateStatusFromIRI() {
|
||||
testStructs := suite.SetupTestStructs()
|
||||
defer suite.TearDownTestStructs(testStructs)
|
||||
testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath)
|
||||
defer testrig.TearDownTestStructs(testStructs)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
|
@ -604,8 +604,8 @@ func (suite *FromFediAPITestSuite) TestCreateStatusFromIRI() {
|
|||
}
|
||||
|
||||
func (suite *FromFediAPITestSuite) TestMoveAccount() {
|
||||
testStructs := suite.SetupTestStructs()
|
||||
defer suite.TearDownTestStructs(testStructs)
|
||||
testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath)
|
||||
defer testrig.TearDownTestStructs(testStructs)
|
||||
|
||||
// We're gonna migrate foss_satan to our local admin account.
|
||||
ctx := context.Background()
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 := >smodel.InteractionApproval{
|
||||
ID: id,
|
||||
AccountID: fave.TargetAccountID,
|
||||
Account: fave.TargetAccount,
|
||||
InteractingAccountID: fave.AccountID,
|
||||
InteractingAccount: fave.Account,
|
||||
InteractionURI: fave.URI,
|
||||
InteractionType: gtsmodel.InteractionLike,
|
||||
URI: uris.GenerateURIForAccept(fave.TargetAccount.Username, id),
|
||||
) error {
|
||||
// Only create interaction request
|
||||
// if fave targets a local status.
|
||||
if fave.Status == nil ||
|
||||
!fave.Status.IsLocal() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := u.state.DB.PutInteractionApproval(ctx, approval); err != nil {
|
||||
err := gtserror.Newf("db error inserting interaction approval: %w", err)
|
||||
return nil, err
|
||||
// Lock on the interaction URI.
|
||||
unlock := u.state.ProcessingLocks.Lock(fave.URI)
|
||||
defer unlock()
|
||||
|
||||
// Ensure no req with this URI exists already.
|
||||
req, err := u.state.DB.GetInteractionRequestByInteractionURI(ctx, fave.URI)
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
return gtserror.Newf("db error checking for existing interaction request: %w", err)
|
||||
}
|
||||
|
||||
// Mark the fave itself as now approved.
|
||||
fave.PendingApproval = util.Ptr(false)
|
||||
fave.PreApproved = false
|
||||
fave.ApprovedByURI = approval.URI
|
||||
|
||||
if err := u.state.DB.UpdateStatusFave(
|
||||
ctx,
|
||||
fave,
|
||||
"pending_approval",
|
||||
"approved_by_uri",
|
||||
); err != nil {
|
||||
err := gtserror.Newf("db error updating status fave: %w", err)
|
||||
return nil, err
|
||||
if req != nil {
|
||||
// Interaction req already exists,
|
||||
// no need to do anything else.
|
||||
return nil
|
||||
}
|
||||
|
||||
return approval, nil
|
||||
// Create + store new interaction request.
|
||||
req, err = typeutils.StatusFaveToInteractionRequest(ctx, fave)
|
||||
if err != nil {
|
||||
return gtserror.Newf("error creating interaction request: %w", err)
|
||||
}
|
||||
|
||||
if err := u.state.DB.PutInteractionRequest(ctx, req); err != nil {
|
||||
return gtserror.Newf("db error storing interaction request: %w", err)
|
||||
}
|
||||
|
||||
// Notify *local* account of pending announce.
|
||||
if err := u.surface.notifyPendingFave(ctx, fave); err != nil {
|
||||
return gtserror.Newf("error notifying pending fave: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// approveReply stores + returns an
|
||||
// interactionApproval for a reply.
|
||||
func (u *utils) approveReply(
|
||||
// requestReply stores an interaction request
|
||||
// for the given reply, and notifies the interactee.
|
||||
func (u *utils) requestReply(
|
||||
ctx context.Context,
|
||||
status *gtsmodel.Status,
|
||||
) (*gtsmodel.InteractionApproval, error) {
|
||||
id := id.NewULID()
|
||||
|
||||
approval := >smodel.InteractionApproval{
|
||||
ID: id,
|
||||
AccountID: status.InReplyToAccountID,
|
||||
Account: status.InReplyToAccount,
|
||||
InteractingAccountID: status.AccountID,
|
||||
InteractingAccount: status.Account,
|
||||
InteractionURI: status.URI,
|
||||
InteractionType: gtsmodel.InteractionReply,
|
||||
URI: uris.GenerateURIForAccept(status.InReplyToAccount.Username, id),
|
||||
reply *gtsmodel.Status,
|
||||
) error {
|
||||
// Only create interaction request if
|
||||
// status replies to a local status.
|
||||
if reply.InReplyTo == nil ||
|
||||
!reply.InReplyTo.IsLocal() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := u.state.DB.PutInteractionApproval(ctx, approval); err != nil {
|
||||
err := gtserror.Newf("db error inserting interaction approval: %w", err)
|
||||
return nil, err
|
||||
// Lock on the interaction URI.
|
||||
unlock := u.state.ProcessingLocks.Lock(reply.URI)
|
||||
defer unlock()
|
||||
|
||||
// Ensure no req with this URI exists already.
|
||||
req, err := u.state.DB.GetInteractionRequestByInteractionURI(ctx, reply.URI)
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
return gtserror.Newf("db error checking for existing interaction request: %w", err)
|
||||
}
|
||||
|
||||
// Mark the status itself as now approved.
|
||||
status.PendingApproval = util.Ptr(false)
|
||||
status.PreApproved = false
|
||||
status.ApprovedByURI = approval.URI
|
||||
|
||||
if err := u.state.DB.UpdateStatus(
|
||||
ctx,
|
||||
status,
|
||||
"pending_approval",
|
||||
"approved_by_uri",
|
||||
); err != nil {
|
||||
err := gtserror.Newf("db error updating status: %w", err)
|
||||
return nil, err
|
||||
if req != nil {
|
||||
// Interaction req already exists,
|
||||
// no need to do anything else.
|
||||
return nil
|
||||
}
|
||||
|
||||
return approval, nil
|
||||
// Create + store interaction request.
|
||||
req, err = typeutils.StatusToInteractionRequest(ctx, reply)
|
||||
if err != nil {
|
||||
return gtserror.Newf("error creating interaction request: %w", err)
|
||||
}
|
||||
|
||||
if err := u.state.DB.PutInteractionRequest(ctx, req); err != nil {
|
||||
return gtserror.Newf("db error storing interaction request: %w", err)
|
||||
}
|
||||
|
||||
// Notify *local* account of pending reply.
|
||||
if err := u.surface.notifyPendingReply(ctx, reply); err != nil {
|
||||
return gtserror.Newf("error notifying pending reply: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// approveAnnounce stores + returns an
|
||||
// interactionApproval for an announce.
|
||||
func (u *utils) approveAnnounce(
|
||||
// requestAnnounce stores an interaction request
|
||||
// for the given announce, and notifies the interactee.
|
||||
func (u *utils) requestAnnounce(
|
||||
ctx context.Context,
|
||||
boost *gtsmodel.Status,
|
||||
) (*gtsmodel.InteractionApproval, error) {
|
||||
id := id.NewULID()
|
||||
|
||||
approval := >smodel.InteractionApproval{
|
||||
ID: id,
|
||||
AccountID: boost.BoostOfAccountID,
|
||||
Account: boost.BoostOfAccount,
|
||||
InteractingAccountID: boost.AccountID,
|
||||
InteractingAccount: boost.Account,
|
||||
InteractionURI: boost.URI,
|
||||
InteractionType: gtsmodel.InteractionReply,
|
||||
URI: uris.GenerateURIForAccept(boost.BoostOfAccount.Username, id),
|
||||
) error {
|
||||
// Only create interaction request if
|
||||
// status announces a local status.
|
||||
if boost.BoostOf == nil ||
|
||||
!boost.BoostOf.IsLocal() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := u.state.DB.PutInteractionApproval(ctx, approval); err != nil {
|
||||
err := gtserror.Newf("db error inserting interaction approval: %w", err)
|
||||
return nil, err
|
||||
// Lock on the interaction URI.
|
||||
unlock := u.state.ProcessingLocks.Lock(boost.URI)
|
||||
defer unlock()
|
||||
|
||||
// Ensure no req with this URI exists already.
|
||||
req, err := u.state.DB.GetInteractionRequestByInteractionURI(ctx, boost.URI)
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
return gtserror.Newf("db error checking for existing interaction request: %w", err)
|
||||
}
|
||||
|
||||
// Mark the status itself as now approved.
|
||||
boost.PendingApproval = util.Ptr(false)
|
||||
boost.PreApproved = false
|
||||
boost.ApprovedByURI = approval.URI
|
||||
|
||||
if err := u.state.DB.UpdateStatus(
|
||||
ctx,
|
||||
boost,
|
||||
"pending_approval",
|
||||
"approved_by_uri",
|
||||
); err != nil {
|
||||
err := gtserror.Newf("db error updating boost wrapper status: %w", err)
|
||||
return nil, err
|
||||
if req != nil {
|
||||
// Interaction req already exists,
|
||||
// no need to do anything else.
|
||||
return nil
|
||||
}
|
||||
|
||||
return approval, nil
|
||||
// Create + store interaction request.
|
||||
req, err = typeutils.StatusToInteractionRequest(ctx, boost)
|
||||
if err != nil {
|
||||
return gtserror.Newf("error creating interaction request: %w", err)
|
||||
}
|
||||
|
||||
if err := u.state.DB.PutInteractionRequest(ctx, req); err != nil {
|
||||
return gtserror.Newf("db error storing interaction request: %w", err)
|
||||
}
|
||||
|
||||
// Notify *local* account of pending announce.
|
||||
if err := u.surface.notifyPendingAnnounce(ctx, boost); err != nil {
|
||||
return gtserror.Newf("error notifying pending announce: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 >smodel.InteractionRequest{
|
||||
ID: reqID,
|
||||
CreatedAt: status.CreatedAt,
|
||||
StatusID: targetID,
|
||||
Status: target,
|
||||
TargetAccountID: targetAccountID,
|
||||
TargetAccount: targetAccount,
|
||||
InteractingAccountID: status.AccountID,
|
||||
InteractingAccount: status.Account,
|
||||
InteractionURI: status.URI,
|
||||
InteractionType: interactionType,
|
||||
Reply: reply,
|
||||
Announce: announce,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func StatusFaveToInteractionRequest(
|
||||
ctx context.Context,
|
||||
fave *gtsmodel.StatusFave,
|
||||
) (*gtsmodel.InteractionRequest, error) {
|
||||
reqID, err := id.NewULIDFromTime(fave.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, gtserror.Newf("error generating ID: %w", err)
|
||||
}
|
||||
|
||||
return >smodel.InteractionRequest{
|
||||
ID: reqID,
|
||||
CreatedAt: fave.CreatedAt,
|
||||
StatusID: fave.StatusID,
|
||||
Status: fave.Status,
|
||||
TargetAccountID: fave.TargetAccountID,
|
||||
TargetAccount: fave.TargetAccount,
|
||||
InteractingAccountID: fave.AccountID,
|
||||
InteractingAccount: fave.Account,
|
||||
InteractionURI: fave.URI,
|
||||
InteractionType: gtsmodel.InteractionLike,
|
||||
Like: fave,
|
||||
}, nil
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1057,26 +1057,26 @@ func (suite *InternalToASTestSuite) TestPollVoteToASCreate() {
|
|||
}`, string(bytes))
|
||||
}
|
||||
|
||||
func (suite *InternalToASTestSuite) TestInteractionApprovalToASAccept() {
|
||||
func (suite *InternalToASTestSuite) TestInteractionReqToASAccept() {
|
||||
acceptingAccount := suite.testAccounts["local_account_1"]
|
||||
interactingAccount := suite.testAccounts["remote_account_1"]
|
||||
|
||||
interactionApproval := >smodel.InteractionApproval{
|
||||
req := >smodel.InteractionRequest{
|
||||
ID: "01J1AKMZ8JE5NW0ZSFTRC1JJNE",
|
||||
CreatedAt: testrig.TimeMustParse("2022-06-09T13:12:00Z"),
|
||||
UpdatedAt: testrig.TimeMustParse("2022-06-09T13:12:00Z"),
|
||||
AccountID: acceptingAccount.ID,
|
||||
Account: acceptingAccount,
|
||||
TargetAccountID: acceptingAccount.ID,
|
||||
TargetAccount: acceptingAccount,
|
||||
InteractingAccountID: interactingAccount.ID,
|
||||
InteractingAccount: interactingAccount,
|
||||
InteractionURI: "https://fossbros-anonymous.io/users/foss_satan/statuses/01J1AKRRHQ6MDDQHV0TP716T2K",
|
||||
InteractionType: gtsmodel.InteractionAnnounce,
|
||||
URI: "http://localhost:8080/users/the_mighty_zork/accepts/01J1AKMZ8JE5NW0ZSFTRC1JJNE",
|
||||
AcceptedAt: testrig.TimeMustParse("2022-06-09T13:12:00Z"),
|
||||
}
|
||||
|
||||
accept, err := suite.typeconverter.InteractionApprovalToASAccept(
|
||||
accept, err := suite.typeconverter.InteractionReqToASAccept(
|
||||
context.Background(),
|
||||
interactionApproval,
|
||||
req,
|
||||
)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -41,6 +41,7 @@
|
|||
>smodel.FilterStatus{},
|
||||
>smodel.Follow{},
|
||||
>smodel.FollowRequest{},
|
||||
>smodel.InteractionRequest{},
|
||||
>smodel.List{},
|
||||
>smodel.ListEntry{},
|
||||
>smodel.Marker{},
|
||||
|
@ -346,6 +347,12 @@ func StandardDBSetup(db db.DB, accounts map[string]*gtsmodel.Account) {
|
|||
}
|
||||
}
|
||||
|
||||
for _, v := range NewTestInteractionRequests() {
|
||||
if err := db.Put(ctx, v); err != nil {
|
||||
log.Panic(nil, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.CreateInstanceAccount(ctx); err != nil {
|
||||
log.Panic(nil, err)
|
||||
}
|
||||
|
|
|
@ -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
121
testrig/teststructs.go
Normal 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)
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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" }]
|
||||
})
|
||||
})
|
||||
});
|
||||
|
|
|
@ -168,6 +168,7 @@ export const gtsApi = createApi({
|
|||
"HTTPHeaderAllows",
|
||||
"HTTPHeaderBlocks",
|
||||
"DefaultInteractionPolicies",
|
||||
"InteractionRequest",
|
||||
],
|
||||
endpoints: (build) => ({
|
||||
instanceV1: build.query<InstanceV1, void>({
|
||||
|
|
97
web/source/settings/lib/query/user/interactions.ts
Normal file
97
web/source/settings/lib/query/user/interactions.ts
Normal 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;
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
117
web/source/settings/views/user/interactions/detail.tsx
Normal file
117
web/source/settings/views/user/interactions/detail.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
36
web/source/settings/views/user/interactions/index.tsx
Normal file
36
web/source/settings/views/user/interactions/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
251
web/source/settings/views/user/interactions/search.tsx
Normal file
251
web/source/settings/views/user/interactions/search.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
98
web/source/settings/views/user/interactions/util.tsx
Normal file
98
web/source/settings/views/user/interactions/util.tsx
Normal file
|
@ -0,0 +1,98 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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]);
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue