summaryrefslogtreecommitdiff
path: root/internal/federation/federatingdb
diff options
context:
space:
mode:
Diffstat (limited to 'internal/federation/federatingdb')
-rw-r--r--internal/federation/federatingdb/accept.go369
-rw-r--r--internal/federation/federatingdb/accept_test.go120
-rw-r--r--internal/federation/federatingdb/block.go2
-rw-r--r--internal/federation/federatingdb/create.go45
-rw-r--r--internal/federation/federatingdb/interactionrequest.go572
-rw-r--r--internal/federation/federatingdb/interactionrequest_test.go289
-rw-r--r--internal/federation/federatingdb/like.go2
-rw-r--r--internal/federation/federatingdb/reject.go52
-rw-r--r--internal/federation/federatingdb/util.go59
9 files changed, 1426 insertions, 84 deletions
diff --git a/internal/federation/federatingdb/accept.go b/internal/federation/federatingdb/accept.go
index 2e4948a0e..d7606c4fa 100644
--- a/internal/federation/federatingdb/accept.go
+++ b/internal/federation/federatingdb/accept.go
@@ -22,6 +22,7 @@ import (
"errors"
"fmt"
"net/url"
+ "time"
"code.superseriousbusiness.org/activity/streams/vocab"
"code.superseriousbusiness.org/gotosocial/internal/ap"
@@ -39,7 +40,7 @@ func (f *DB) GetAccept(
ctx context.Context,
acceptIRI *url.URL,
) (vocab.ActivityStreamsAccept, error) {
- approval, err := f.state.DB.GetInteractionRequestByURI(ctx, acceptIRI.String())
+ approval, err := f.state.DB.GetInteractionRequestByResponseURI(ctx, acceptIRI.String())
if err != nil {
return nil, err
}
@@ -63,9 +64,9 @@ func (f *DB) Accept(ctx context.Context, accept vocab.ActivityStreamsAccept) err
return nil
}
+ // Ensure an activity ID is given.
acceptID := ap.GetJSONLDId(accept)
if acceptID == nil {
- // We need an ID.
const text = "Accept had no id property"
return gtserror.NewErrorBadRequest(errors.New(text), text)
}
@@ -109,7 +110,7 @@ func (f *DB) Accept(ctx context.Context, accept vocab.ActivityStreamsAccept) err
case name == ap.ActivityLike:
objIRI := ap.GetJSONLDId(asType)
if objIRI == nil {
- log.Debugf(ctx, "could not retrieve id of inlined Accept object %s", name)
+ log.Warnf(ctx, "missing id for inlined object %s: %s", name, acceptID)
continue
}
@@ -131,7 +132,7 @@ func (f *DB) Accept(ctx context.Context, accept vocab.ActivityStreamsAccept) err
case name == ap.ActivityAnnounce || ap.IsStatusable(name):
objIRI := ap.GetJSONLDId(asType)
if objIRI == nil {
- log.Debugf(ctx, "could not retrieve id of inlined Accept object %s", name)
+ log.Warnf(ctx, "missing id for inlined object %s: %s", name, acceptID)
continue
}
@@ -146,9 +147,38 @@ func (f *DB) Accept(ctx context.Context, accept vocab.ActivityStreamsAccept) err
return err
}
+ // Todo: ACCEPT POLITE INLINED LIKE REQUEST.
+ //
+ // Implement this when we start
+ // sending out polite LikeRequests.
+
+ // ACCEPT POLITE INLINED REPLY REQUEST
+ case name == ap.ActivityReplyRequest:
+ replyReq, ok := asType.(vocab.GoToSocialReplyRequest)
+ if !ok {
+ const text = "malformed ReplyRequest as object of Accept"
+ return gtserror.NewErrorBadRequest(errors.New(text), text)
+ }
+
+ if err := f.acceptPoliteReplyRequest(
+ ctx,
+ acceptID,
+ accept,
+ replyReq,
+ receivingAcct,
+ requestingAcct,
+ ); err != nil {
+ return err
+ }
+
+ // Todo: ACCEPT POLITE INLINED ANNOUNCE REQUEST
+ //
+ // Implement this when we start
+ // sending out polite AnnounceRequests.
+
// UNHANDLED
default:
- log.Debugf(ctx, "unhandled object type: %s", name)
+ log.Debugf(ctx, "unhandled object type %s: %s", name, acceptID)
}
} else if object.IsIRI() {
@@ -445,8 +475,7 @@ func (f *DB) acceptStoredStatus(
// Mark the status as approved by this URI.
status.PendingApproval = util.Ptr(false)
status.ApprovedByURI = approvedByURI.String()
- if err := f.state.DB.UpdateStatus(
- ctx,
+ if err := f.state.DB.UpdateStatus(ctx,
status,
"pending_approval",
"approved_by_uri",
@@ -543,8 +572,7 @@ func (f *DB) acceptLikeIRI(
// Mark the fave as approved by this URI.
fave.PendingApproval = util.Ptr(false)
fave.ApprovedByURI = approvedByURI.String()
- if err := f.state.DB.UpdateStatusFave(
- ctx,
+ if err := f.state.DB.UpdateStatusFave(ctx,
fave,
"pending_approval",
"approved_by_uri",
@@ -566,6 +594,316 @@ func (f *DB) acceptLikeIRI(
return nil
}
+// partialAcceptInteractionRequest represents a
+// partially-parsed accept of an interaction request
+// returned from parseAcceptInteractionRequestable.
+type partialAcceptInteractionRequest struct {
+ intReqURI *url.URL
+ actorURI *url.URL
+ parentURI *url.URL
+ instrumentURI *url.URL
+ authURI *url.URL
+ intReq *gtsmodel.InteractionRequest // May be nil.
+}
+
+// parseAcceptInteractionRequestable does some initial parsing
+// and validation of the given Accept with inlined polite
+// interaction request (LikeRequest, ReplyRequest, AnnounceRequest).
+//
+// Will return nil, nil if there's no need for further processing.
+func (f *DB) parseAcceptInteractionRequestable(
+ ctx context.Context,
+ accept vocab.ActivityStreamsAccept,
+ intRequestable ap.InteractionRequestable,
+ receivingAcct *gtsmodel.Account,
+ requestingAcct *gtsmodel.Account,
+) (*partialAcceptInteractionRequest, error) {
+ intReqURI := ap.GetJSONLDId(intRequestable)
+ if intReqURI == nil {
+ const text = "no id set on embedded interaction request"
+ return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
+ }
+
+ // Ensure we have actor IRI on
+ // the interaction requestable.
+ actors := ap.GetActorIRIs(intRequestable)
+ if len(actors) != 1 {
+ const text = "invalid or missing actor property on embedded interaction request"
+ return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
+ }
+ actorURI := actors[0]
+
+ // Ensure we have an object URI, which
+ // should point to the statusable being
+ // interacted with, ie., the parent status.
+ objects := ap.GetObjectIRIs(intRequestable)
+ if len(objects) != 1 {
+ const text = "invalid or missing object property on embedded interaction request"
+ return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
+ }
+ parentURI := objects[0]
+
+ // Ensure we have instrument, which should
+ // be or point to the activity/object that
+ // interacts with the parent status.
+ instruments := ap.ExtractInstruments(intRequestable)
+ if len(instruments) != 1 {
+ const text = "invalid or missing instrument property on embedded interaction request"
+ return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
+ }
+ instrument := instruments[0]
+
+ // We just need the URI for the instrument,
+ // not the whole type, which we can either
+ // fetch from remote or get locally.
+ var instrumentURI *url.URL
+ if instrument.IsIRI() {
+ instrumentURI = instrument.GetIRI()
+ } else {
+ t := instrument.GetType()
+ if t == nil {
+ const text = "nil instrument type on embedded interaction request"
+ return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
+ }
+ instrumentURI = ap.GetJSONLDId(t)
+ }
+
+ // Ensure we have result URI, which should
+ // point to an authorization for this interaction.
+ results := ap.GetResultIRIs(accept)
+ if len(results) != 1 {
+ const text = "invalid or missing result property on embedded interaction request"
+ return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
+ }
+ authURI := results[0]
+
+ // Check if we have a gtsmodel interaction
+ // request already stored for this interaction.
+ intReq, err := f.state.DB.GetInteractionRequestByInteractionURI(ctx, instrumentURI.String())
+ if err != nil && !errors.Is(err, db.ErrNoEntries) {
+ // Real db error.
+ return nil, gtserror.Newf("db error getting interaction request: %w", err)
+ }
+
+ if intReq == nil {
+
+ // No request stored for this interaction.
+ // Means this is *probably* a remote interaction
+ // with a remote status. Double check this.
+ host := config.GetHost()
+ acctDomain := config.GetAccountDomain()
+ if instrumentURI.Host == host ||
+ instrumentURI.Host == acctDomain ||
+ intReqURI.Host == host ||
+ intReqURI.Host == acctDomain {
+ // Claims to be Accepting something of ours,
+ // but we don't have an interaction request
+ // stored. Most likely it's been deleted in
+ // the meantime, or this is a mistake. Bail.
+ return nil, nil
+ }
+
+ // This must be an Accept of a remote interaction
+ // request. Ensure relevance of this message by
+ // checking that receiver follows requester.
+ following, err := f.state.DB.IsFollowing(
+ ctx,
+ receivingAcct.ID,
+ requestingAcct.ID,
+ )
+ if err != nil {
+ err := gtserror.Newf("db error checking following: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ if !following {
+ // If we don't follow this person, and
+ // they're not Accepting something we
+ // created, then we don't care.
+ return nil, nil
+ }
+
+ } else {
+
+ // Request stored for this interaction URI.
+ //
+ // Note: this path is not actually possible until v0.21.0,
+ // because we don't send out polite requests yet in v0.20.0.
+
+ // If the request is already accepted,
+ // we don't need to do anything at all.
+ if intReq.IsAccepted() {
+ return nil, nil
+ }
+
+ // The person doing the Accept must be the
+ // same as the target of the interaction request.
+ if intReq.TargetAccountID != requestingAcct.ID {
+ const text = "cannot Accept interaction request on another actor's behalf"
+ return nil, gtserror.NewErrorForbidden(errors.New(text), text)
+ }
+
+ // The stored interaction request and the inlined
+ // interaction request must have the same target status.
+ if intReq.TargetStatus.URI != parentURI.String() {
+ const text = "Accept interaction request mismatched object URI"
+ return nil, gtserror.NewErrorForbidden(errors.New(text), text)
+ }
+
+ // The stored interaction request and the inlined
+ // interaction request must have the same URI.
+ if intReq.InteractionRequestURI != intReqURI.String() {
+ const text = "Accept interaction request mismatched id"
+ return nil, gtserror.NewErrorForbidden(errors.New(text), text)
+ }
+ }
+
+ // Return the things.
+ return &partialAcceptInteractionRequest{
+ intReqURI: intReqURI,
+ actorURI: actorURI,
+ parentURI: parentURI,
+ instrumentURI: instrumentURI,
+ authURI: authURI,
+ intReq: intReq, // May be nil.
+ }, nil
+}
+
+// acceptPoliteReplyRequest handles the Accept of a polite ReplyRequest,
+// ie., something that looks like this:
+//
+// {
+// "@context": [
+// "https://www.w3.org/ns/activitystreams",
+// "https://gotosocial.org/ns"
+// ],
+// "type": "Accept",
+// "to": "https://example.com/users/bob",
+// "id": "https://example.com/users/alice/activities/1234",
+// "actor": "https://example.com/users/alice",
+// "object": {
+// "type": "ReplyRequest",
+// "id": "https://example.com/users/bob/interaction_requests/12345",
+// "actor": "https://example.com/users/bob",
+// "object": "https://example.com/users/alice/statuses/1",
+// "instrument": "https://example.org/users/bob/statuses/12345"
+// },
+// "result": "https://example.com/users/alice/authorizations/1"
+// }
+func (f *DB) acceptPoliteReplyRequest(
+ ctx context.Context,
+ acceptID *url.URL,
+ accept vocab.ActivityStreamsAccept,
+ replyRequest vocab.GoToSocialReplyRequest,
+ receivingAcct *gtsmodel.Account,
+ requestingAcct *gtsmodel.Account,
+) error {
+ // Parse out the Accept and
+ // embedded interaction requestable.
+ partial, err := f.parseAcceptInteractionRequestable(
+ ctx,
+ accept,
+ replyRequest,
+ receivingAcct,
+ requestingAcct,
+ )
+ if err != nil {
+ return err
+ }
+
+ if partial == nil {
+ // Nothing to do!
+ return nil
+ }
+
+ if partial.intReq == nil {
+ // This is a remote accept of a remote reply.
+ //
+ // Process dereferencing etc asynchronously, leaving
+ // the interaction request as nil. We don't need to
+ // create an int req for remote accepts of remote
+ // replies, we can just validate + store the auth URI.
+ f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{
+ APObjectType: ap.ActivityReplyRequest,
+ APActivityType: ap.ActivityAccept,
+ APIRI: partial.authURI,
+ APObject: partial.instrumentURI,
+ Receiving: receivingAcct,
+ Requesting: requestingAcct,
+ })
+
+ return nil
+ }
+
+ // We already have a request stored for this interaction.
+ //
+ // Note: this path is not actually possible until v0.21.0,
+ // because we don't send out polite requests yet in v0.20.0.
+
+ // Make sure the stored interaction request
+ // lines up with the Accept ReplyRequest.
+ if partial.intReq.InteractionType != gtsmodel.InteractionReply {
+ const text = "Accept ReplyRequest targets interaction request that isn't of type Reply"
+ return gtserror.NewErrorBadRequest(errors.New(text), text)
+ }
+
+ // The stored reply must be the same as
+ // the instrument of the ReplyRequest.
+ reply := partial.intReq.Reply
+ if reply.URI != partial.instrumentURI.String() {
+ const text = "Accept ReplyRequest mismatched instrument URI"
+ return gtserror.NewErrorForbidden(errors.New(text), text)
+ }
+
+ // The actor of the stored reply must be the
+ // same as the actor of the ReplyRequest.
+ if reply.AccountURI != partial.actorURI.String() {
+ const text = "Accept ReplyRequest mismatched actor URI"
+ return gtserror.NewErrorForbidden(errors.New(text), text)
+ }
+
+ // This all looks good, we can update the
+ // interaction request and stored reply.
+ unlock := f.state.FedLocks.Lock(partial.intReq.InteractionURI)
+ defer unlock()
+
+ authURIStr := partial.authURI.String()
+ partial.intReq.AcceptedAt = time.Now()
+ partial.intReq.AuthorizationURI = authURIStr
+ partial.intReq.ResponseURI = acceptID.String()
+ if err := f.state.DB.UpdateInteractionRequest(
+ ctx, partial.intReq,
+ "accepted_at",
+ "authorization_uri",
+ "response_uri",
+ ); err != nil {
+ return gtserror.Newf("db error updating interaction request: %w", err)
+ }
+
+ reply.ApprovedByURI = authURIStr
+ reply.PendingApproval = util.Ptr(false)
+ if err := f.state.DB.UpdateStatus(
+ ctx, reply,
+ "approved_by_uri",
+ "pending_approval",
+ ); err != nil {
+ return gtserror.Newf("db error updating status: %w", err)
+ }
+
+ // Handle any remaining side effects in the processor.
+ f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{
+ APObjectType: ap.ActivityReplyRequest,
+ APActivityType: ap.ActivityAccept,
+ APIRI: partial.authURI,
+ APObject: partial.instrumentURI,
+ GTSModel: partial.intReq,
+ Receiving: receivingAcct,
+ Requesting: requestingAcct,
+ })
+
+ return nil
+}
+
// approvedByURI extracts the appropriate *url.URL
// to use as an interaction's approvedBy value by
// checking to see if the Accept has a result URL set.
@@ -577,11 +915,8 @@ func (f *DB) acceptLikeIRI(
// Error is only returned if the result URI is set
// but the host differs from the Accept ID host.
//
-// TODO: This function should be updated at some point
-// to check for inlined result type, and see if type is
-// a LikeApproval, ReplyApproval, or AnnounceApproval,
-// and check the attributedTo, object, and target of
-// the approval as well. But this'll do for now.
+// TODO: This function could be updated at some
+// point to check for inlined result type.
func approvedByURI(
acceptID *url.URL,
accept vocab.ActivityStreamsAccept,
@@ -623,11 +958,7 @@ func approvedByURI(
if resultIRI.Host != acceptID.Host {
// What the boobs is this?
- err := fmt.Errorf(
- "host of result %s differed from host of Accept %s",
- resultIRI, accept,
- )
- return nil, err
+ return nil, fmt.Errorf("host of result %s differed from host of Accept %s", resultIRI, accept)
}
// Use the result IRI we've been
diff --git a/internal/federation/federatingdb/accept_test.go b/internal/federation/federatingdb/accept_test.go
new file mode 100644
index 000000000..2651c6a5f
--- /dev/null
+++ b/internal/federation/federatingdb/accept_test.go
@@ -0,0 +1,120 @@
+// 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 federatingdb_test
+
+import (
+ "bytes"
+ "io"
+ "testing"
+
+ "code.superseriousbusiness.org/activity/streams/vocab"
+ "code.superseriousbusiness.org/gotosocial/internal/ap"
+ "code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
+ "code.superseriousbusiness.org/gotosocial/internal/messages"
+ "code.superseriousbusiness.org/gotosocial/testrig"
+ "github.com/stretchr/testify/suite"
+)
+
+const (
+ rMediaPath = "../../../testrig/media"
+ rTemplatePath = "../../../web/template"
+)
+
+type AcceptTestSuite struct {
+ FederatingDBTestSuite
+}
+
+func (suite *AcceptTestSuite) TestAcceptRemoteReplyRequest() {
+ // Accept of a reply by
+ // brand_new_person to foss_satan.
+ const acceptJSON = `{
+ "@context": [
+ "https://www.w3.org/ns/activitystreams",
+ "https://gotosocial.org/ns"
+ ],
+ "type": "Accept",
+ "to": "https://unknown-instance.com/users/brand_new_person",
+ "cc": "https://www.w3.org/ns/activitystreams#Public",
+ "id": "http://fossbros-anonymous.io/users/foss_satan/accepts/1234",
+ "actor": "http://fossbros-anonymous.io/users/foss_satan",
+ "object": {
+ "type": "ReplyRequest",
+ "id": "https://unknown-instance.com/users/brand_new_person/statuses/01H641QSRS3TCXSVC10X4GPKW7/replyRequest",
+ "actor": "https://unknown-instance.com/users/brand_new_person",
+ "object": "http://fossbros-anonymous.io/users/foss_satan/statuses/01FVW7JHQFSFK166WWKR8CBA6M",
+ "instrument": "https://unknown-instance.com/users/brand_new_person/statuses/01H641QSRS3TCXSVC10X4GPKW7"
+ },
+ "result": "http://fossbros-anonymous.io/users/foss_satan/authorizations/1234"
+}`
+
+ // The accept will be delivered by foss_satan to zork.
+ ctx := createTestContext(
+ suite.T(),
+ suite.testAccounts["local_account_1"],
+ suite.testAccounts["remote_account_1"],
+ )
+
+ // Have zork follow foss_satan for this test,
+ // else the message will be scattered unto the four winds.
+ follow := &gtsmodel.Follow{
+ ID: "01K4STEH5NWAXBZ4TFNGQQQ984",
+ CreatedAt: testrig.TimeMustParse("2022-05-14T13:21:09+02:00"),
+ UpdatedAt: testrig.TimeMustParse("2022-05-14T13:21:09+02:00"),
+ AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF",
+ TargetAccountID: "01F8MH5ZK5VRH73AKHQM6Y9VNX",
+ URI: "http://localhost:8080/users/the_mighty_zork/follow/01G1TK3PQKFW1BQZ9WVYRTFECK",
+ }
+ if err := suite.state.DB.PutFollow(ctx, follow); err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ // Parse accept into vocab.Type.
+ t, err := ap.DecodeType(ctx, io.NopCloser(bytes.NewBufferString(acceptJSON)))
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ accept := t.(vocab.ActivityStreamsAccept)
+
+ // Process the accept.
+ if err := suite.federatingDB.Accept(ctx, accept); err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ // There should be an accept msg
+ // heading to the processor now.
+ msg, ok := suite.state.Workers.Federator.Queue.PopCtx(ctx)
+ if !ok {
+ suite.FailNow("no message in queue")
+ }
+
+ suite.EqualValues(
+ &messages.FromFediAPI{
+ APObjectType: "ReplyRequest",
+ APActivityType: "Accept",
+ APIRI: testrig.URLMustParse("http://fossbros-anonymous.io/users/foss_satan/authorizations/1234"),
+ APObject: testrig.URLMustParse("https://unknown-instance.com/users/brand_new_person/statuses/01H641QSRS3TCXSVC10X4GPKW7"),
+ Requesting: suite.testAccounts["remote_account_1"],
+ Receiving: suite.testAccounts["local_account_1"],
+ },
+ msg,
+ )
+}
+
+func TestAcceptTestSuite(t *testing.T) {
+ suite.Run(t, new(AcceptTestSuite))
+}
diff --git a/internal/federation/federatingdb/block.go b/internal/federation/federatingdb/block.go
index 2950aef3b..e44081a56 100644
--- a/internal/federation/federatingdb/block.go
+++ b/internal/federation/federatingdb/block.go
@@ -41,7 +41,7 @@ func (f *DB) Block(ctx context.Context, blockable vocab.ActivityStreamsBlock) er
requesting := activityContext.requestingAcct
receiving := activityContext.receivingAcct
- if receiving.IsMoving() {
+ if requesting.IsMoving() {
// A Moving account
// can't do this.
return nil
diff --git a/internal/federation/federatingdb/create.go b/internal/federation/federatingdb/create.go
index 8de137d6c..d3ce2bfa5 100644
--- a/internal/federation/federatingdb/create.go
+++ b/internal/federation/federatingdb/create.go
@@ -281,43 +281,18 @@ func (f *DB) createStatusable(
statusable ap.Statusable,
forwarded bool,
) error {
- // Check whether this status is both
- // relevant, and doesn't look like spam.
- err := f.spamFilter.StatusableOK(ctx,
- receiver,
- requester,
- statusable,
- )
-
- switch {
- case err == nil:
- // No problem!
-
- case gtserror.IsNotRelevant(err):
- // This case is quite common if a remote (Mastodon)
- // instance forwards a message to us which is a reply
- // from someone else to a status we've also replied to.
- //
- // It does this to try to ensure thread completion, but
- // we have our own thread fetching mechanism anyway.
- log.Debugf(ctx, "status %s is not relevant to receiver (%v); dropping it",
- ap.GetJSONLDId(statusable), err,
- )
- return nil
+ // Check for spam / relevance.
+ ok, err := f.statusableOK(ctx, receiver, requester, statusable)
+ if err != nil {
+ // Error already
+ // wrapped.
+ return err
+ }
- case gtserror.IsSpam(err):
- // Log this at a higher level so admins can
- // gauge how much spam is being sent to them.
- //
- // TODO: add Prometheus metrics for this.
- log.Infof(ctx, "status %s looked like spam (%v); dropping it",
- ap.GetJSONLDId(statusable), err,
- )
+ if !ok {
+ // Not relevant / spam.
+ // Already logged.
return nil
-
- default:
- // A real error has occurred.
- return gtserror.Newf("error checking relevancy/spam: %w", err)
}
// If we do have a forward, we should ignore the content
diff --git a/internal/federation/federatingdb/interactionrequest.go b/internal/federation/federatingdb/interactionrequest.go
new file mode 100644
index 000000000..0026337d0
--- /dev/null
+++ b/internal/federation/federatingdb/interactionrequest.go
@@ -0,0 +1,572 @@
+// 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 federatingdb
+
+import (
+ "context"
+ "errors"
+ "net/http"
+
+ "code.superseriousbusiness.org/activity/streams/vocab"
+ "code.superseriousbusiness.org/gotosocial/internal/ap"
+ "code.superseriousbusiness.org/gotosocial/internal/db"
+ "code.superseriousbusiness.org/gotosocial/internal/gtserror"
+ "code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
+ "code.superseriousbusiness.org/gotosocial/internal/id"
+ "code.superseriousbusiness.org/gotosocial/internal/log"
+ "code.superseriousbusiness.org/gotosocial/internal/messages"
+ "code.superseriousbusiness.org/gotosocial/internal/util"
+)
+
+/*
+ The code in this file handles the three types of "polite"
+ interaction requests currently recognized by GoToSocial:
+ LikeRequest, ReplyRequest, and AnnounceRequest.
+
+ A request looks a bit like this, note the requested
+ interaction itself is nested in the "instrument" property:
+
+ {
+ "@context": [
+ "https://www.w3.org/ns/activitystreams",
+ "https://gotosocial.org/ns"
+ ],
+ "type": "LikeRequest",
+ "id": "https://example.com/users/bob/interaction_requests/likes/12345",
+ "actor": "https://example.com/users/bob",
+ "object": "https://example.com/users/alice/statuses/1",
+ "to": "https://example.com/users/alice",
+ "instrument": {
+ "type": "Like",
+ "id": "https://example.com/users/bob/likes/12345",
+ "object": "https://example.com/users/alice/statuses/1",
+ "attributedTo": "https://example.com/users/bob",
+ "to": [
+ "https://www.w3.org/ns/activitystreams#Public",
+ "https://example.com/users/alice"
+ ]
+ }
+ }
+
+ Because each of the interaction types are a bit different,
+ they're unfortunately also parsed and stored differently:
+ LikeRequests have the Like checked first, here, against
+ the interaction policy of the target status, whereas
+ AnnounceRequests and ReplyRequests have the interaction
+ checked against the interaction policy of the target
+ status asynchronously, in the FromFediAPI processor.
+
+ It may be possible to dick about with the logic a bit and
+ shuffle the checks all here, or all in the processor, but
+ that's a job for future refactoring by future tobi/kimbe.
+*/
+
+// partialInteractionRequest represents a
+// partially-parsed interaction request
+// returned from the util function parseInteractionReq.
+type partialInteractionRequest struct {
+ intRequestURI string
+ requesting *gtsmodel.Account
+ receiving *gtsmodel.Account
+ object *gtsmodel.Status
+ instrument vocab.Type
+}
+
+// parseIntReq does some first-pass parsing
+// of the given InteractionRequestable (LikeRequest,
+// ReplyRequest, AnnounceRequest), checking stuff like:
+//
+// - interaction request has a single object
+// - interaction request object is a status
+// - object status belongs to receiving account
+// - interaction request has a single instrument
+//
+// It returns a partialInteractionRequest struct,
+// or an error if something goes wrong.
+func (f *DB) parseInteractionRequest(ctx context.Context, intRequest ap.InteractionRequestable) (*partialInteractionRequest, error) {
+
+ // Get and stringify the ID/URI of interaction request once,
+ // and mark this particular activity as handled in ID cache.
+ intRequestURI := ap.GetJSONLDId(intRequest).String()
+ f.activityIDs.Set(intRequestURI, struct{}{})
+
+ // Extract relevant values from passed ctx.
+ activityContext := getActivityContext(ctx)
+ if activityContext.internal {
+ // Already processed.
+ return nil, nil
+ }
+
+ requesting := activityContext.requestingAcct
+ receiving := activityContext.receivingAcct
+
+ if requesting.IsMoving() {
+ // A Moving account
+ // can't do this.
+ return nil, nil
+ }
+
+ if receiving.IsMoving() {
+ // Moving accounts can't
+ // do anything with interaction
+ // requests, so ignore it.
+ return nil, nil
+ }
+
+ // Make sure we have a single
+ // object of the interaction request.
+ objectIRIs := ap.GetObjectIRIs(intRequest)
+ if l := len(objectIRIs); l != 1 {
+ return nil, gtserror.NewfWithCode(
+ http.StatusBadRequest,
+ "invalid object len %d, wanted 1", l,
+ )
+ }
+
+ // Extract the status URI str.
+ statusIRI := objectIRIs[0]
+ statusIRIStr := statusIRI.String()
+
+ // Fetch status by given URI from the database.
+ status, err := f.state.DB.GetStatusByURI(ctx, statusIRIStr)
+ if err != nil && !errors.Is(err, db.ErrNoEntries) {
+ return nil, gtserror.Newf("db error getting object status %s: %w", statusIRIStr, err)
+ }
+
+ // Ensure received by correct account.
+ if status.AccountID != receiving.ID {
+ return nil, gtserror.NewfWithCode(
+ http.StatusForbidden,
+ "receiver %s is not owner of interaction-requested status",
+ receiving.URI,
+ )
+ }
+
+ // Ensure we have the expected one instrument.
+ instruments := ap.ExtractInstruments(intRequest)
+ if l := len(instruments); l != 1 {
+ return nil, gtserror.NewfWithCode(
+ http.StatusBadRequest,
+ "invalid instrument len %d, wanted 1", l,
+ )
+ }
+
+ // Instrument should be a
+ // type and not just an IRI.
+ instrument := instruments[0].GetType()
+ if instrument == nil {
+ return nil, gtserror.NewWithCode(
+ http.StatusBadRequest,
+ "instrument was not vocab.Type",
+ )
+ }
+
+ // Check the instrument is an approveable type.
+ approvable, ok := instrument.(ap.WithApprovedBy)
+ if !ok {
+ return nil, gtserror.NewWithCode(
+ http.StatusBadRequest,
+ "instrument was not Approvable",
+ )
+ }
+
+ // Ensure that `approvedBy` isn't already set.
+ if u := ap.GetApprovedBy(approvable); u != nil {
+ return nil, gtserror.NewfWithCode(
+ http.StatusBadRequest,
+ "instrument claims to already be approvedBy %s",
+ u.String(),
+ )
+ }
+
+ return &partialInteractionRequest{
+ intRequestURI: intRequestURI,
+ requesting: requesting,
+ receiving: receiving,
+ object: status,
+ instrument: instrument,
+ }, nil
+}
+
+func (f *DB) LikeRequest(ctx context.Context, likeReq vocab.GoToSocialLikeRequest) error {
+ log.DebugKV(ctx, "LikeRequest", serialize{likeReq})
+
+ // Parse out base level interaction request information.
+ partial, err := f.parseInteractionRequest(ctx, likeReq)
+ if err != nil {
+ return err
+ }
+
+ // Ensure the instrument vocab.Type is Likeable.
+ likeable, ok := ap.ToLikeable(partial.instrument)
+ if !ok {
+ return gtserror.NewWithCode(
+ http.StatusBadRequest,
+ "could not parse instrument to Likeable",
+ )
+ }
+
+ // Convert received AS like type to internal fave model.
+ fave, err := f.converter.ASLikeToFave(ctx, likeable)
+ if err != nil {
+ err := gtserror.Newf("error converting from AS type: %w", err)
+ return gtserror.WrapWithCode(http.StatusBadRequest, err)
+ }
+
+ // Ensure fave enacted by correct account.
+ if fave.AccountID != partial.requesting.ID {
+ return gtserror.NewfWithCode(
+ http.StatusForbidden,
+ "requester %s is not expected actor %s",
+ partial.requesting.URI, fave.Account.URI,
+ )
+ }
+
+ // Ensure fave received by correct account.
+ if fave.TargetAccountID != partial.receiving.ID {
+ return gtserror.NewfWithCode(
+ http.StatusForbidden,
+ "receiver %s is not expected %s",
+ partial.receiving.URI, fave.TargetAccount.URI,
+ )
+ }
+
+ // Ensure this is a valid Like target for requester.
+ policyResult, err := f.intFilter.StatusLikeable(ctx,
+ partial.requesting,
+ fave.Status,
+ )
+ if err != nil {
+ return gtserror.Newf(
+ "error seeing if status %s is likeable: %w",
+ fave.Status.URI, err,
+ )
+ } else if policyResult.Forbidden() {
+ return gtserror.NewWithCode(
+ http.StatusForbidden,
+ "requester does not have permission to Like status",
+ )
+ }
+
+ // Policy result is either automatic or manual
+ // approval, so store the interaction request.
+ intReq := &gtsmodel.InteractionRequest{
+ ID: id.NewULID(),
+ TargetStatusID: fave.StatusID,
+ TargetStatus: fave.Status,
+ TargetAccountID: fave.TargetAccountID,
+ TargetAccount: fave.TargetAccount,
+ InteractingAccountID: fave.AccountID,
+ InteractingAccount: fave.Account,
+ InteractionRequestURI: partial.intRequestURI,
+ InteractionURI: fave.URI,
+ InteractionType: gtsmodel.InteractionLike,
+ Polite: util.Ptr(true),
+ Like: fave,
+ }
+ switch err := f.state.DB.PutInteractionRequest(ctx, intReq); {
+ case err == nil:
+ // No problem.
+
+ case errors.Is(err, db.ErrAlreadyExists):
+ // Already processed this, race condition? Just warn + return.
+ log.Warnf(ctx, "received duplicate interaction request: %s", partial.intRequestURI)
+ return nil
+
+ default:
+ // Proper DB error.
+ return gtserror.Newf(
+ "db error storing interaction request %s",
+ partial.intRequestURI,
+ )
+ }
+
+ // Int req is now stored.
+ //
+ // Set some fields on the
+ // pending fave and store it.
+ fave.ID = id.NewULID()
+ fave.PendingApproval = util.Ptr(true)
+ fave.PreApproved = policyResult.AutomaticApproval()
+ if err := f.state.DB.PutStatusFave(ctx, fave); err != nil {
+ if errors.Is(err, db.ErrAlreadyExists) {
+ // The fave already exists in the
+ // database, which means we've already
+ // handled side effects. We can just
+ // return nil here and be done with it.
+ return nil
+ }
+
+ return gtserror.Newf("error inserting %s into db: %w", fave.URI, err)
+ }
+
+ // Further processing will be carried out
+ // asynchronously, and our caller will return 202 Accepted.
+ f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{
+ APActivityType: ap.ActivityCreate,
+ APObjectType: ap.ActivityLikeRequest,
+ GTSModel: intReq,
+ Receiving: partial.receiving,
+ Requesting: partial.requesting,
+ })
+
+ return nil
+}
+
+func (f *DB) ReplyRequest(ctx context.Context, replyReq vocab.GoToSocialReplyRequest) error {
+ log.DebugKV(ctx, "ReplyRequest", serialize{replyReq})
+
+ // Parse out base level interaction request information.
+ partial, err := f.parseInteractionRequest(ctx, replyReq)
+ if err != nil {
+ return err
+ }
+
+ // Ensure the instrument vocab.Type is Statusable.
+ statusable, ok := ap.ToStatusable(partial.instrument)
+ if !ok {
+ return gtserror.NewWithCode(
+ http.StatusBadRequest,
+ "could not parse instrument to Statusable",
+ )
+ }
+
+ // Check for spam / relevance.
+ ok, err = f.statusableOK(
+ ctx,
+ partial.receiving,
+ partial.requesting,
+ statusable,
+ )
+ if err != nil {
+ // Error already
+ // wrapped.
+ return err
+ }
+
+ if !ok {
+ // Not relevant / spam.
+ // Already logged.
+ return nil
+ }
+
+ // Statusable must reply to something.
+ inReplyToURIs := ap.GetInReplyTo(statusable)
+ if l := len(inReplyToURIs); l != 1 {
+ return gtserror.NewfWithCode(
+ http.StatusBadRequest,
+ "expected inReplyTo length 1, got %d", l,
+ )
+ }
+ inReplyToURI := inReplyToURIs[0]
+ inReplyToURIStr := inReplyToURI.String()
+
+ // Make sure we have the status this interaction reply encompasses.
+ inReplyTo, err := f.state.DB.GetStatusByURI(ctx, inReplyToURIStr)
+ if err != nil && !errors.Is(err, db.ErrNoEntries) {
+ return gtserror.Newf("db error getting inReplyTo status %s: %w", inReplyToURIStr, err)
+ }
+
+ // Check status exists.
+ if inReplyTo == nil {
+ log.Warnf(ctx, "received ReplyRequest for non-existent status: %s", inReplyToURIStr)
+ return nil
+ }
+
+ // Make sure the parent status is owned by receiver.
+ if inReplyTo.AccountURI != partial.receiving.URI {
+ return gtserror.NewfWithCode(
+ http.StatusBadRequest,
+ "inReplyTo status %s not owned by receiving account %s",
+ inReplyToURIStr, partial.receiving.URI,
+ )
+ }
+
+ // Extract the attributed to (i.e. author) URI of status.
+ attributedToURI, err := ap.ExtractAttributedToURI(statusable)
+ if err != nil {
+ err := gtserror.Newf("invalid status attributedTo value: %w", err)
+ return gtserror.WrapWithCode(http.StatusBadRequest, err)
+ }
+
+ // Ensure status author is account of requester.
+ attributedToURIStr := attributedToURI.String()
+ if attributedToURIStr != partial.requesting.URI {
+ return gtserror.NewfWithCode(
+ http.StatusBadRequest,
+ "status attributedTo %s not requesting account %s",
+ inReplyToURIStr, partial.requesting.URI,
+ )
+ }
+
+ // Create a pending interaction request in the database.
+ // This request will be handled further by the processor.
+ intReq := &gtsmodel.InteractionRequest{
+ ID: id.NewULID(),
+ TargetStatusID: inReplyTo.ID,
+ TargetStatus: inReplyTo,
+ TargetAccountID: inReplyTo.AccountID,
+ TargetAccount: inReplyTo.Account,
+ InteractingAccountID: partial.requesting.ID,
+ InteractingAccount: partial.requesting,
+ InteractionRequestURI: partial.intRequestURI,
+ InteractionURI: ap.GetJSONLDId(statusable).String(),
+ InteractionType: gtsmodel.InteractionReply,
+ Polite: util.Ptr(true),
+ Reply: nil, // Not settable yet.
+ }
+ switch err := f.state.DB.PutInteractionRequest(ctx, intReq); {
+ case err == nil:
+ // No problem.
+
+ case errors.Is(err, db.ErrAlreadyExists):
+ // Already processed this, race condition? Just warn + return.
+ log.Warnf(ctx, "received duplicate interaction request: %s", partial.intRequestURI)
+ return nil
+
+ default:
+ // Proper DB error.
+ return gtserror.Newf(
+ "db error storing interaction request %s",
+ partial.intRequestURI,
+ )
+ }
+
+ // Further processing will be carried out
+ // asynchronously, return 202 Accepted.
+ f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{
+ APActivityType: ap.ActivityCreate,
+ APObjectType: ap.ActivityReplyRequest,
+ GTSModel: intReq,
+ APObject: statusable,
+ Receiving: partial.receiving,
+ Requesting: partial.requesting,
+ })
+
+ return nil
+}
+
+func (f *DB) AnnounceRequest(ctx context.Context, announceReq vocab.GoToSocialAnnounceRequest) error {
+ log.DebugKV(ctx, "AnnounceRequest", serialize{announceReq})
+
+ // Parse out base level interaction request information.
+ partial, err := f.parseInteractionRequest(ctx, announceReq)
+ if err != nil {
+ return err
+ }
+
+ // Ensure the instrument vocab.Type is Announceable.
+ announceable, ok := ap.ToAnnounceable(partial.instrument)
+ if !ok {
+ return gtserror.NewWithCode(
+ http.StatusBadRequest,
+ "could not parse instrument to Announceable",
+ )
+ }
+
+ // Convert received AS Announce type to internal boost wrapper model.
+ boost, new, err := f.converter.ASAnnounceToStatus(ctx, announceable)
+ if err != nil {
+ err := gtserror.Newf("error converting from AS type: %w", err)
+ return gtserror.WrapWithCode(http.StatusBadRequest, err)
+ }
+
+ if !new {
+ // We already have this announce, just return.
+ log.Warnf(ctx, "received AnnounceRequest for existing announce: %s", boost.URI)
+ return nil
+ }
+
+ // Fetch origin status that this boost is targetting from database.
+ targetStatus, err := f.state.DB.GetStatusByURI(ctx, boost.BoostOfURI)
+ if err != nil && !errors.Is(err, db.ErrNoEntries) {
+ return gtserror.Newf(
+ "db error getting announce object %s: %w",
+ boost.BoostOfURI, err,
+ )
+ }
+
+ if targetStatus == nil {
+ // Status doesn't seem to exist, just drop this AnnounceRequest.
+ log.Warnf(ctx, "received AnnounceRequest for non-existent status %s", boost.BoostOfURI)
+ return nil
+ }
+
+ // Ensure target status is owned by receiving account.
+ if targetStatus.AccountID != partial.receiving.ID {
+ return gtserror.NewfWithCode(
+ http.StatusBadRequest,
+ "announce object %s not owned by receiving account %s",
+ boost.BoostOfURI, partial.receiving.URI,
+ )
+ }
+
+ // Ensure announce enacted by correct account.
+ if boost.AccountID != partial.requesting.ID {
+ return gtserror.NewfWithCode(
+ http.StatusForbidden,
+ "requester %s is not expected actor %s",
+ partial.requesting.URI, boost.Account.URI,
+ )
+ }
+
+ // Create a pending interaction request in the database.
+ // This request will be handled further by the processor.
+ intReq := &gtsmodel.InteractionRequest{
+ ID: id.NewULID(),
+ TargetStatusID: targetStatus.ID,
+ TargetStatus: targetStatus,
+ TargetAccountID: targetStatus.AccountID,
+ TargetAccount: targetStatus.Account,
+ InteractingAccountID: boost.AccountID,
+ InteractingAccount: boost.Account,
+ InteractionRequestURI: partial.intRequestURI,
+ InteractionURI: boost.URI,
+ InteractionType: gtsmodel.InteractionAnnounce,
+ Polite: util.Ptr(true),
+ Announce: boost,
+ }
+ switch err := f.state.DB.PutInteractionRequest(ctx, intReq); {
+ case err == nil:
+ // No problem.
+
+ case errors.Is(err, db.ErrAlreadyExists):
+ // Already processed this, race condition? Just warn + return.
+ log.Warnf(ctx, "received duplicate interaction request: %s", partial.intRequestURI)
+ return nil
+
+ default:
+ // Proper DB error.
+ return gtserror.Newf(
+ "db error storing interaction request %s",
+ partial.intRequestURI,
+ )
+ }
+
+ // Further processing will be carried out
+ // asynchronously, return 202 Accepted.
+ f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{
+ APActivityType: ap.ActivityCreate,
+ APObjectType: ap.ActivityAnnounceRequest,
+ GTSModel: intReq,
+ Receiving: partial.receiving,
+ Requesting: partial.requesting,
+ })
+
+ return nil
+}
diff --git a/internal/federation/federatingdb/interactionrequest_test.go b/internal/federation/federatingdb/interactionrequest_test.go
new file mode 100644
index 000000000..81c0848ed
--- /dev/null
+++ b/internal/federation/federatingdb/interactionrequest_test.go
@@ -0,0 +1,289 @@
+// 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 federatingdb_test
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+ "time"
+
+ "code.superseriousbusiness.org/activity/streams"
+ "code.superseriousbusiness.org/activity/streams/vocab"
+ "code.superseriousbusiness.org/gotosocial/internal/ap"
+ "code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
+ "code.superseriousbusiness.org/gotosocial/testrig"
+ "github.com/stretchr/testify/suite"
+)
+
+type InteractionRequestTestSuite struct {
+ FederatingDBTestSuite
+}
+
+func (suite *InteractionRequestTestSuite) intReq(
+ receiving *gtsmodel.Account,
+ requesting *gtsmodel.Account,
+ jsonStr string,
+ dbF func(ctx context.Context, req vocab.Type) error,
+) error {
+ ctx := createTestContext(suite.T(), receiving, requesting)
+
+ raw := make(map[string]interface{})
+ if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ t, err := streams.ToType(ctx, raw)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ return dbF(ctx, t)
+}
+
+func (suite *InteractionRequestTestSuite) TestReplyRequest() {
+ var (
+ ctx = suite.T().Context()
+ receiving = suite.testAccounts["admin_account"]
+ requesting = suite.testAccounts["remote_account_1"]
+ testStatus = suite.testStatuses["admin_account_status_1"]
+ intReqURI = "http://fossbros-anonymous.io/requests/87fb1478-ac46-406a-8463-96ce05645219"
+ intURI = "http://fossbros-anonymous.io/users/foss_satan/statuses/87fb1478-ac46-406a-8463-96ce05645219"
+ jsonStr = `{
+ "@context": [
+ "https://www.w3.org/ns/activitystreams",
+ "https://gotosocial.org/ns"
+ ],
+ "type": "ReplyRequest",
+ "id": "` + intReqURI + `",
+ "actor": "` + requesting.URI + `",
+ "object": "` + testStatus.URI + `",
+ "to": "` + receiving.URI + `",
+ "instrument": {
+ "attributedTo": "` + requesting.URI + `",
+ "cc": "` + requesting.FollowersURI + `",
+ "content": "\u003cp\u003ethis is a reply!\u003c/p\u003e",
+ "id": "` + intURI + `",
+ "inReplyTo": "` + testStatus.URI + `",
+ "tag": {
+ "href": "` + receiving.URI + `",
+ "name": "@` + receiving.Username + `@localhost:8080",
+ "type": "Mention"
+ },
+ "to": "https://www.w3.org/ns/activitystreams#Public",
+ "type": "Note"
+ }
+}`
+ )
+
+ suite.T().Logf("testing reply request:\n\n%s", jsonStr)
+
+ // Call the federatingDB function.
+ err := suite.intReq(
+ receiving,
+ requesting,
+ jsonStr,
+ func(ctx context.Context, req vocab.Type) error {
+ replyReq := req.(vocab.GoToSocialReplyRequest)
+ return suite.federatingDB.ReplyRequest(ctx, replyReq)
+ },
+ )
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ // There should be an interaction request in the DB now.
+ var intReq *gtsmodel.InteractionRequest
+ if !testrig.WaitFor(func() bool {
+ intReq, err = suite.state.DB.GetInteractionRequestByInteractionURI(ctx, intURI)
+ return err == nil && intReq != nil
+ }) {
+ suite.FailNow("timed out waiting for int req to appear in the db")
+ }
+ suite.Equal(testStatus.ID, intReq.TargetStatusID)
+ suite.Equal(receiving.ID, intReq.TargetAccountID)
+ suite.Equal(requesting.ID, intReq.InteractingAccountID)
+ suite.Equal(intReqURI, intReq.InteractionRequestURI)
+ suite.Equal(intURI, intReq.InteractionURI)
+ suite.Equal(gtsmodel.InteractionReply, intReq.InteractionType)
+
+ // Should be a message heading to the processor.
+ msg, _ := suite.getFederatorMsg(5 * time.Second)
+ suite.Equal(ap.ActivityCreate, msg.APActivityType)
+ suite.Equal(ap.ActivityReplyRequest, msg.APObjectType)
+ suite.NotNil(msg.GTSModel)
+ suite.NotNil(msg.APObject)
+ suite.NotNil(msg.Receiving)
+ suite.NotNil(msg.Requesting)
+}
+
+func (suite *InteractionRequestTestSuite) TestLikeRequest() {
+ var (
+ ctx = suite.T().Context()
+ receiving = suite.testAccounts["admin_account"]
+ requesting = suite.testAccounts["remote_account_1"]
+ testStatus = suite.testStatuses["admin_account_status_1"]
+ intReqURI = "http://fossbros-anonymous.io/requests/87fb1478-ac46-406a-8463-96ce05645219"
+ intURI = "http://fossbros-anonymous.io/users/foss_satan/statuses/87fb1478-ac46-406a-8463-96ce05645219"
+ jsonStr = `{
+ "@context": [
+ "https://www.w3.org/ns/activitystreams",
+ "https://gotosocial.org/ns"
+ ],
+ "type": "LikeRequest",
+ "id": "` + intReqURI + `",
+ "actor": "` + requesting.URI + `",
+ "object": "` + testStatus.URI + `",
+ "to": "` + receiving.URI + `",
+ "instrument": {
+ "id": "` + intURI + `",
+ "object": "` + testStatus.URI + `",
+ "actor": "` + requesting.URI + `",
+ "to": "` + receiving.URI + `",
+ "type": "Like"
+ }
+}`
+ )
+
+ suite.T().Logf("testing like request:\n\n%s", jsonStr)
+
+ // Call the federatingDB function.
+ err := suite.intReq(
+ receiving,
+ requesting,
+ jsonStr,
+ func(ctx context.Context, req vocab.Type) error {
+ likeReq := req.(vocab.GoToSocialLikeRequest)
+ return suite.federatingDB.LikeRequest(ctx, likeReq)
+ },
+ )
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ // There should be an interaction request in the DB now.
+ var intReq *gtsmodel.InteractionRequest
+ if !testrig.WaitFor(func() bool {
+ intReq, err = suite.state.DB.GetInteractionRequestByInteractionURI(ctx, intURI)
+ return err == nil && intReq != nil
+ }) {
+ suite.FailNow("timed out waiting for int req to appear in the db")
+ }
+ suite.Equal(testStatus.ID, intReq.TargetStatusID)
+ suite.Equal(receiving.ID, intReq.TargetAccountID)
+ suite.Equal(requesting.ID, intReq.InteractingAccountID)
+ suite.Equal(intReqURI, intReq.InteractionRequestURI)
+ suite.Equal(intURI, intReq.InteractionURI)
+ suite.Equal(gtsmodel.InteractionLike, intReq.InteractionType)
+
+ // The like should be in the DB now (unapproved).
+ var statusFave *gtsmodel.StatusFave
+ if !testrig.WaitFor(func() bool {
+ statusFave, err = suite.state.DB.GetStatusFaveByURI(ctx, intURI)
+ return err == nil && intReq != nil
+ }) {
+ suite.FailNow("timed out waiting for fave to appear in the db")
+ }
+ suite.Equal(requesting.ID, statusFave.AccountID)
+ suite.Equal(receiving.ID, statusFave.TargetAccountID)
+ suite.Equal(testStatus.ID, statusFave.StatusID)
+ suite.Equal(intURI, statusFave.URI)
+ suite.True(*statusFave.PendingApproval)
+ suite.Empty(statusFave.ApprovedByURI)
+
+ // Should be a message heading to the processor.
+ msg, _ := suite.getFederatorMsg(5 * time.Second)
+ suite.Equal(ap.ActivityCreate, msg.APActivityType)
+ suite.Equal(ap.ActivityLikeRequest, msg.APObjectType)
+ suite.NotNil(msg.GTSModel)
+ suite.NotNil(msg.Receiving)
+ suite.NotNil(msg.Requesting)
+}
+
+func (suite *InteractionRequestTestSuite) TestAnnounceRequest() {
+ var (
+ ctx = suite.T().Context()
+ receiving = suite.testAccounts["admin_account"]
+ requesting = suite.testAccounts["remote_account_1"]
+ testStatus = suite.testStatuses["admin_account_status_1"]
+ intReqURI = "http://fossbros-anonymous.io/requests/87fb1478-ac46-406a-8463-96ce05645219"
+ intURI = "http://fossbros-anonymous.io/users/foss_satan/statuses/87fb1478-ac46-406a-8463-96ce05645219"
+ jsonStr = `{
+ "@context": [
+ "https://www.w3.org/ns/activitystreams",
+ "https://gotosocial.org/ns"
+ ],
+ "type": "AnnounceRequest",
+ "id": "` + intReqURI + `",
+ "actor": "` + requesting.URI + `",
+ "object": "` + testStatus.URI + `",
+ "to": "` + receiving.URI + `",
+ "instrument": {
+ "id": "` + intURI + `",
+ "object": "` + testStatus.URI + `",
+ "actor": "` + requesting.URI + `",
+ "to": "` + requesting.FollowersURI + `",
+ "cc": "` + receiving.URI + `",
+ "type": "Announce"
+ }
+}`
+ )
+
+ suite.T().Logf("testing announce request:\n\n%s", jsonStr)
+
+ // Call the federatingDB function.
+ err := suite.intReq(
+ receiving,
+ requesting,
+ jsonStr,
+ func(ctx context.Context, req vocab.Type) error {
+ announceReq := req.(vocab.GoToSocialAnnounceRequest)
+ return suite.federatingDB.AnnounceRequest(ctx, announceReq)
+ },
+ )
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ // There should be an interaction request in the DB now.
+ var intReq *gtsmodel.InteractionRequest
+ if !testrig.WaitFor(func() bool {
+ intReq, err = suite.state.DB.GetInteractionRequestByInteractionURI(ctx, intURI)
+ return err == nil && intReq != nil
+ }) {
+ suite.FailNow("timed out waiting for int req to appear in the db")
+ }
+ suite.Equal(testStatus.ID, intReq.TargetStatusID)
+ suite.Equal(receiving.ID, intReq.TargetAccountID)
+ suite.Equal(requesting.ID, intReq.InteractingAccountID)
+ suite.Equal(intReqURI, intReq.InteractionRequestURI)
+ suite.Equal(intURI, intReq.InteractionURI)
+ suite.Equal(gtsmodel.InteractionAnnounce, intReq.InteractionType)
+
+ // Should be a message heading to the processor.
+ msg, _ := suite.getFederatorMsg(5 * time.Second)
+ suite.Equal(ap.ActivityCreate, msg.APActivityType)
+ suite.Equal(ap.ActivityAnnounceRequest, msg.APObjectType)
+ suite.NotNil(msg.GTSModel)
+ suite.NotNil(msg.Receiving)
+ suite.NotNil(msg.Requesting)
+}
+
+func TestInteractionRequestTestSuite(t *testing.T) {
+ suite.Run(t, &InteractionRequestTestSuite{})
+}
diff --git a/internal/federation/federatingdb/like.go b/internal/federation/federatingdb/like.go
index debc343cf..ca26c0692 100644
--- a/internal/federation/federatingdb/like.go
+++ b/internal/federation/federatingdb/like.go
@@ -46,7 +46,7 @@ func (f *DB) Like(ctx context.Context, likeable vocab.ActivityStreamsLike) error
requesting := activityContext.requestingAcct
receiving := activityContext.receivingAcct
- if receiving.IsMoving() {
+ if requesting.IsMoving() {
// A Moving account
// can't do this.
return nil
diff --git a/internal/federation/federatingdb/reject.go b/internal/federation/federatingdb/reject.go
index 5ec3b1a27..88e5ca975 100644
--- a/internal/federation/federatingdb/reject.go
+++ b/internal/federation/federatingdb/reject.go
@@ -31,6 +31,7 @@ import (
"code.superseriousbusiness.org/gotosocial/internal/log"
"code.superseriousbusiness.org/gotosocial/internal/messages"
"code.superseriousbusiness.org/gotosocial/internal/uris"
+ "code.superseriousbusiness.org/gotosocial/internal/util"
)
func (f *DB) Reject(ctx context.Context, reject vocab.ActivityStreamsReject) error {
@@ -305,21 +306,24 @@ func (f *DB) rejectStatusIRI(
InteractingAccountID: receivingAcct.ID,
InteractingAccount: receivingAcct,
InteractionURI: status.URI,
- URI: activityID,
+ Polite: util.Ptr(false),
+ ResponseURI: activityID,
RejectedAt: time.Now(),
}
if apObjectType == ap.ObjectNote {
// Reply.
+ req.InteractionRequestURI = gtsmodel.ForwardCompatibleInteractionRequestURI(status.URI, gtsmodel.ReplyRequestSuffix)
req.InteractionType = gtsmodel.InteractionReply
- req.StatusID = status.InReplyToID
- req.Status = status.InReplyTo
+ req.TargetStatusID = status.InReplyToID
+ req.TargetStatus = status.InReplyTo
req.Reply = status
} else {
// Announce.
+ req.InteractionRequestURI = gtsmodel.ForwardCompatibleInteractionRequestURI(status.URI, gtsmodel.AnnounceRequestSuffix)
req.InteractionType = gtsmodel.InteractionAnnounce
- req.StatusID = status.BoostOfID
- req.Status = status.BoostOf
+ req.TargetStatusID = status.BoostOfID
+ req.TargetStatus = status.BoostOf
req.Announce = status
}
@@ -331,8 +335,8 @@ func (f *DB) rejectStatusIRI(
case req.IsRejected():
// Interaction has already been rejected. Just
// update to this Reject URI and then return early.
- req.URI = activityID
- if err := f.state.DB.UpdateInteractionRequest(ctx, req, "uri"); err != nil {
+ req.ResponseURI = activityID
+ if err := f.state.DB.UpdateInteractionRequest(ctx, req, "response_uri"); err != nil {
err := gtserror.Newf("db error updating interaction request: %w", err)
return gtserror.NewErrorInternalError(err)
}
@@ -343,11 +347,11 @@ func (f *DB) rejectStatusIRI(
// Rejected, even if previously Accepted.
req.AcceptedAt = time.Time{}
req.RejectedAt = time.Now()
- req.URI = activityID
+ req.ResponseURI = activityID
if err := f.state.DB.UpdateInteractionRequest(ctx, req,
"accepted_at",
"rejected_at",
- "uri",
+ "response_uri",
); err != nil {
err := gtserror.Newf("db error updating interaction request: %w", err)
return gtserror.NewErrorInternalError(err)
@@ -430,16 +434,18 @@ func (f *DB) rejectLikeIRI(
// No interaction request existed yet for this
// fave, create a pre-rejected request now.
req = &gtsmodel.InteractionRequest{
- ID: id.NewULID(),
- TargetAccountID: requestingAcct.ID,
- TargetAccount: requestingAcct,
- InteractingAccountID: receivingAcct.ID,
- InteractingAccount: receivingAcct,
- InteractionURI: fave.URI,
- InteractionType: gtsmodel.InteractionLike,
- Like: fave,
- URI: activityID,
- RejectedAt: time.Now(),
+ ID: id.NewULID(),
+ TargetAccountID: requestingAcct.ID,
+ TargetAccount: requestingAcct,
+ InteractingAccountID: receivingAcct.ID,
+ InteractingAccount: receivingAcct,
+ InteractionRequestURI: gtsmodel.ForwardCompatibleInteractionRequestURI(fave.URI, gtsmodel.LikeRequestSuffix),
+ InteractionURI: fave.URI,
+ InteractionType: gtsmodel.InteractionLike,
+ Polite: util.Ptr(false),
+ Like: fave,
+ ResponseURI: activityID,
+ RejectedAt: time.Now(),
}
if err := f.state.DB.PutInteractionRequest(ctx, req); err != nil {
@@ -450,8 +456,8 @@ func (f *DB) rejectLikeIRI(
case req.IsRejected():
// Interaction has already been rejected. Just
// update to this Reject URI and then return early.
- req.URI = activityID
- if err := f.state.DB.UpdateInteractionRequest(ctx, req, "uri"); err != nil {
+ req.ResponseURI = activityID
+ if err := f.state.DB.UpdateInteractionRequest(ctx, req, "response_uri"); err != nil {
err := gtserror.Newf("db error updating interaction request: %w", err)
return gtserror.NewErrorInternalError(err)
}
@@ -462,11 +468,11 @@ func (f *DB) rejectLikeIRI(
// Rejected, even if previously Accepted.
req.AcceptedAt = time.Time{}
req.RejectedAt = time.Now()
- req.URI = activityID
+ req.ResponseURI = activityID
if err := f.state.DB.UpdateInteractionRequest(ctx, req,
"accepted_at",
"rejected_at",
- "uri",
+ "response_uri",
); err != nil {
err := gtserror.Newf("db error updating interaction request: %w", err)
return gtserror.NewErrorInternalError(err)
diff --git a/internal/federation/federatingdb/util.go b/internal/federation/federatingdb/util.go
index 8124c8dc5..7e92707e7 100644
--- a/internal/federation/federatingdb/util.go
+++ b/internal/federation/federatingdb/util.go
@@ -28,6 +28,7 @@ import (
"code.superseriousbusiness.org/gotosocial/internal/ap"
"code.superseriousbusiness.org/gotosocial/internal/config"
"code.superseriousbusiness.org/gotosocial/internal/gtscontext"
+ "code.superseriousbusiness.org/gotosocial/internal/gtserror"
"code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
"code.superseriousbusiness.org/gotosocial/internal/id"
"code.superseriousbusiness.org/gotosocial/internal/log"
@@ -110,11 +111,9 @@ func (f *DB) NewID(ctx context.Context, t vocab.Type) (idURL *url.URL, err error
// based on actor (i.e. followER not the followEE).
if uri := ap.GetActorIRIs(follow); len(uri) == 1 {
if actorAccount, err := f.state.DB.GetAccountByURI(ctx, uri[0].String()); err == nil {
- newID, err := id.NewRandomULID()
- if err != nil {
- return nil, err
- }
- return url.Parse(uris.GenerateURIForFollow(actorAccount.Username, newID))
+ newID := id.NewRandomULID()
+ uri := uris.GenerateURIForFollow(actorAccount.Username, newID)
+ return url.Parse(uri)
}
}
}
@@ -234,3 +233,53 @@ func (s serialize) String() string {
return byteutil.B2S(b)
}
+
+// statusableOK is a util function to check if
+// the given statusable is "ok" in terms of being
+// relevant to the receiver, and passing spam checks.
+func (f *DB) statusableOK(
+ ctx context.Context,
+ receiver *gtsmodel.Account,
+ requester *gtsmodel.Account,
+ statusable ap.Statusable,
+) (bool, error) {
+ // Check whether this status is both
+ // relevant, and doesn't look like spam.
+ err := f.spamFilter.StatusableOK(ctx,
+ receiver,
+ requester,
+ statusable,
+ )
+
+ switch {
+ case err == nil:
+ // No problem!
+ return true, nil
+
+ case gtserror.IsNotRelevant(err):
+ // This case is quite common if a remote (Mastodon)
+ // instance forwards a message to us which is a reply
+ // from someone else to a status we've also replied to.
+ //
+ // It does this to try to ensure thread completion, but
+ // we have our own thread fetching mechanism anyway.
+ log.Debugf(ctx, "status %s is not relevant to receiver (%v); dropping it",
+ ap.GetJSONLDId(statusable), err,
+ )
+ return false, nil
+
+ case gtserror.IsSpam(err):
+ // Log this at a higher level so admins can
+ // gauge how much spam is being sent to them.
+ //
+ // TODO: add Prometheus metrics for this.
+ log.Infof(ctx, "status %s looked like spam (%v); dropping it",
+ ap.GetJSONLDId(statusable), err,
+ )
+ return false, nil
+
+ default:
+ // A real error has occurred.
+ return false, gtserror.Newf("error checking relevancy/spam: %w", err)
+ }
+}