diff options
Diffstat (limited to 'internal/federation')
| -rw-r--r-- | internal/federation/dereferencing/status_permitted.go | 220 | ||||
| -rw-r--r-- | internal/federation/dereferencing/status_test.go | 2 | ||||
| -rw-r--r-- | internal/federation/federatingactor_test.go | 40 | ||||
| -rw-r--r-- | internal/federation/federatingdb/accept.go | 369 | ||||
| -rw-r--r-- | internal/federation/federatingdb/accept_test.go | 120 | ||||
| -rw-r--r-- | internal/federation/federatingdb/block.go | 2 | ||||
| -rw-r--r-- | internal/federation/federatingdb/create.go | 45 | ||||
| -rw-r--r-- | internal/federation/federatingdb/interactionrequest.go | 572 | ||||
| -rw-r--r-- | internal/federation/federatingdb/interactionrequest_test.go | 289 | ||||
| -rw-r--r-- | internal/federation/federatingdb/like.go | 2 | ||||
| -rw-r--r-- | internal/federation/federatingdb/reject.go | 52 | ||||
| -rw-r--r-- | internal/federation/federatingdb/util.go | 59 | ||||
| -rw-r--r-- | internal/federation/federator.go | 3 |
13 files changed, 1557 insertions, 218 deletions
diff --git a/internal/federation/dereferencing/status_permitted.go b/internal/federation/dereferencing/status_permitted.go index 848a821d1..5e53d56bc 100644 --- a/internal/federation/dereferencing/status_permitted.go +++ b/internal/federation/dereferencing/status_permitted.go @@ -70,7 +70,7 @@ func (d *Dereferencer) isPermittedStatus( switch { case status.Account.IsSuspended(): // we shouldn't reach this point, log to poke devs to investigate. - log.Warnf(ctx, "status author suspended: %s", status.AccountURI) + log.Warnf(ctx, "should not have reached here, author suspended: %s", status.AccountURI) permitted = false case status.InReplyToURI != "": @@ -111,7 +111,8 @@ func (d *Dereferencer) isPermittedStatus( return } -// isPermittedReply ... +// isPermittedReply checks whether the given status +// is a permitted reply to its referenced inReplyTo. func (d *Dereferencer) isPermittedReply( ctx context.Context, requestUser string, @@ -119,20 +120,21 @@ func (d *Dereferencer) isPermittedReply( ) (bool, error) { var ( - replyURI = reply.URI // Definitely set. - inReplyToURI = reply.InReplyToURI // Definitely set. - inReplyTo = reply.InReplyTo // Might not be set. + replyURI = reply.URI // Definitely set. + + parentURI = reply.InReplyToURI // Definitely set. + parent = reply.InReplyTo // Might not be set. + approvedByURI = reply.ApprovedByURI // Might not be set. ) // Check if we have a stored interaction request for parent status. parentReq, err := d.state.DB.GetInteractionRequestByInteractionURI( gtscontext.SetBarebones(ctx), - inReplyToURI, + parentURI, ) if err != nil && !errors.Is(err, db.ErrNoEntries) { - err := gtserror.Newf("db error getting interaction request: %w", err) - return false, err + return false, gtserror.Newf("db error getting interaction request: %w", err) } // Check if we have a stored interaction request for this reply. @@ -141,8 +143,7 @@ func (d *Dereferencer) isPermittedReply( replyURI, ) if err != nil && !errors.Is(err, db.ErrNoEntries) { - err := gtserror.Newf("db error getting interaction request: %w", err) - return false, err + return false, gtserror.Newf("db error getting interaction request: %w", err) } parentRejected := (parentReq != nil && parentReq.IsRejected()) @@ -176,8 +177,7 @@ func (d *Dereferencer) isPermittedReply( // it was rejected previously and now claims // to be approved. Continue permission checks. - if inReplyTo == nil { - + if parent == nil { // If we didn't have the replied-to status // in our database (yet), we can't check // right now if this reply is permitted. @@ -191,24 +191,23 @@ func (d *Dereferencer) isPermittedReply( } // We have the replied-to status; ensure it's fully populated. - if err := d.state.DB.PopulateStatus(ctx, inReplyTo); err != nil { + if err := d.state.DB.PopulateStatus(ctx, parent); err != nil { return false, gtserror.Newf("error populating status %s: %w", reply.ID, err) } - // Make sure replied-to status is not - // a boost wrapper, and make sure it's - // actually visible to the requester. - if inReplyTo.BoostOfID != "" { - // We do not permit replies - // to boost wrapper statuses. - log.Info(ctx, "rejecting reply to boost wrapper status") + // Boost wrapper statuses + // cannot receive replies. + if parent.BoostOfID != "" { + log.Warn(ctx, "received reply to boost wrapper status: %s", parent.URI) return false, nil } - if inReplyTo.IsLocal() { + // If parent is a local status + // check visibility to replyer. + if parent.IsLocal() { visible, err := d.visFilter.StatusVisible(ctx, reply.Account, - inReplyTo, + parent, ) if err != nil { err := gtserror.Newf("error checking inReplyTo visibility: %w", err) @@ -227,12 +226,12 @@ func (d *Dereferencer) isPermittedReply( // approval and checking the return value. // No further checks are required. if approvedByURI != "" { - return d.isPermittedByApprovedByIRI( + return d.isPermittedByAuthURI( ctx, gtsmodel.InteractionReply, requestUser, reply, - inReplyTo, + parent, thisReq, approvedByURI, ) @@ -243,7 +242,7 @@ func (d *Dereferencer) isPermittedReply( // to see what we need to do with it. replyable, err := d.intFilter.StatusReplyable(ctx, reply.Account, - inReplyTo, + parent, ) if err != nil { err := gtserror.Newf("error checking status replyability: %w", err) @@ -260,7 +259,7 @@ func (d *Dereferencer) isPermittedReply( return false, d.rejectedByPolicy( ctx, reply, - inReplyTo, + parent, thisReq, ) } @@ -279,7 +278,7 @@ func (d *Dereferencer) isPermittedReply( // pending approval, though we know at this point // that the status did not include an approvedBy URI. - if !inReplyTo.IsLocal() { + if !parent.IsLocal() { // If the replied-to status is remote, we should just // drop this reply at this point, as we can't verify // that the remote replied-to account approves it, and @@ -359,7 +358,7 @@ func (d *Dereferencer) unpermittedByParent( // This collapses the chain beyond the first // rejected reply and allows us to avoid derefing // further replies we already know we don't want. - inReplyToID := parentReq.StatusID + inReplyToID := parentReq.TargetStatusID targetAccountID := parentReq.TargetAccountID // As nobody is actually Rejecting the reply @@ -369,14 +368,16 @@ func (d *Dereferencer) unpermittedByParent( uri := "" rejection := >smodel.InteractionRequest{ - ID: rejectID, - StatusID: inReplyToID, - TargetAccountID: targetAccountID, - InteractingAccountID: reply.AccountID, - InteractionURI: reply.URI, - InteractionType: gtsmodel.InteractionReply, - URI: uri, - RejectedAt: time.Now(), + ID: rejectID, + TargetStatusID: inReplyToID, + TargetAccountID: targetAccountID, + InteractingAccountID: reply.AccountID, + InteractionRequestURI: gtsmodel.ForwardCompatibleInteractionRequestURI(reply.URI, gtsmodel.ReplyRequestSuffix), + InteractionURI: reply.URI, + InteractionType: gtsmodel.InteractionReply, + Polite: util.Ptr(false), + ResponseURI: uri, + RejectedAt: time.Now(), } err := d.state.DB.PutInteractionRequest(ctx, rejection) if err != nil && !errors.Is(err, db.ErrAlreadyExists) { @@ -386,14 +387,14 @@ func (d *Dereferencer) unpermittedByParent( return nil } -// isPermittedByApprovedByIRI checks whether the given URI +// isPermittedByAuthURI checks whether the given URI // can be dereferenced, and whether it returns either an -// Accept activity or an approval object which permits the -// given reply to the given inReplyTo status. +// Accept activity or an authorization object that permits +// the given reply to the given inReplyTo status. // // If yes, then thisReq will be updated to // reflect the approval, if it's not nil. -func (d *Dereferencer) isPermittedByApprovedByIRI( +func (d *Dereferencer) isPermittedByAuthURI( ctx context.Context, interactionType gtsmodel.InteractionType, requestUser string, @@ -402,7 +403,7 @@ func (d *Dereferencer) isPermittedByApprovedByIRI( thisReq *gtsmodel.InteractionRequest, approvedByIRI string, ) (bool, error) { - permitted, err := d.isValidApprovedByIRI( + permitted, err := d.isValidAuthURI( ctx, interactionType, requestUser, @@ -430,13 +431,13 @@ func (d *Dereferencer) isPermittedByApprovedByIRI( // pending approval, clear that now. reply.PendingApproval = util.Ptr(false) if thisReq != nil { - thisReq.URI = approvedByIRI + thisReq.ResponseURI = approvedByIRI thisReq.AcceptedAt = time.Now() thisReq.RejectedAt = time.Time{} err := d.state.DB.UpdateInteractionRequest( ctx, thisReq, - "uri", + "response_uri", "accepted_at", "rejected_at", ) @@ -483,13 +484,13 @@ func (d *Dereferencer) rejectedByPolicy( // request is marked as rejected. thisReq.RejectedAt = time.Now() thisReq.AcceptedAt = time.Time{} - thisReq.URI = rejectURI + thisReq.ResponseURI = rejectURI err := d.state.DB.UpdateInteractionRequest( ctx, thisReq, "rejected_at", "accepted_at", - "uri", + "response_uri", ) if err != nil { return gtserror.Newf("db error updating interaction request: %w", err) @@ -501,14 +502,16 @@ func (d *Dereferencer) rejectedByPolicy( // We haven't stored a rejected interaction // request for this status yet, do it now. rejection := >smodel.InteractionRequest{ - ID: rejectID, - StatusID: inReplyTo.ID, - TargetAccountID: inReplyTo.AccountID, - InteractingAccountID: reply.AccountID, - InteractionURI: reply.URI, - InteractionType: gtsmodel.InteractionReply, - URI: rejectURI, - RejectedAt: time.Now(), + ID: rejectID, + TargetStatusID: inReplyTo.ID, + TargetAccountID: inReplyTo.AccountID, + InteractingAccountID: reply.AccountID, + InteractionRequestURI: gtsmodel.ForwardCompatibleInteractionRequestURI(reply.URI, gtsmodel.ReplyRequestSuffix), + InteractionURI: reply.URI, + InteractionType: gtsmodel.InteractionReply, + Polite: util.Ptr(false), + ResponseURI: rejectURI, + RejectedAt: time.Now(), } err := d.state.DB.PutInteractionRequest(ctx, rejection) if err != nil && !errors.Is(err, db.ErrAlreadyExists) { @@ -611,7 +614,7 @@ func (d *Dereferencer) isPermittedBoost( // Boost claims to be approved, check // this by dereferencing the approvedBy // and inspecting the return value. - permitted, err := d.isValidApprovedByIRI( + permitted, err := d.isValidAuthURI( ctx, gtsmodel.InteractionAnnounce, requestUser, @@ -637,36 +640,36 @@ func (d *Dereferencer) isPermittedBoost( return true, nil } -// isValidApprovedByIRI dereferences the activitystreams Accept or approval -// at the specified IRI, and checks the Accept or approval for validity -// against the provided expectedActor, expectedObject, and expectedTarget. +// isValidAuthURI dereferences the activitystreams Accept or authorization +// at the specified IRI, and checks it for validity against the provided +// expectedActor, expectedObject, and expectedTarget. // // Will return either (true, nil) if everything looked OK, an error // if something went wrong internally during deref, or (false, nil) // if the dereferenced Accept/Approval did not meet expectations. -func (d *Dereferencer) isValidApprovedByIRI( +func (d *Dereferencer) isValidAuthURI( ctx context.Context, interactionType gtsmodel.InteractionType, requestUser string, - approvedByIRIStr string, // approval uri Eg., "https://example.org/users/someone/accepts/01J2736AWWJ3411CPR833F6D03" + authIRIStr string, // authorization uri Eg., "https://example.org/users/someone/accepts/01J2736AWWJ3411CPR833F6D03" expectActorURIStr string, // actor Eg., "https://example.org/users/someone" expectObjectURIStr string, // object Eg., "https://some.instance.example.org/users/someone_else/statuses/01J27414TWV9F7DC39FN8ABB5R" expectTargetURIStr string, // target Eg., "https://example.org/users/someone/statuses/01JM4REQTJ1BZ1R4BPYP1W4R9E" ) (bool, error) { l := log. WithContext(ctx). - WithField("approvedByIRI", approvedByIRIStr) + WithField("authIRI", authIRIStr) - approvedByIRI, err := url.Parse(approvedByIRIStr) + authIRI, err := url.Parse(authIRIStr) if err != nil { // Real returnable error. - err := gtserror.Newf("error parsing approvedByIRI: %w", err) + err := gtserror.Newf("error parsing authIRI: %w", err) return false, err } // Don't make calls to the IRI if its // domain is blocked, just return false. - blocked, err := d.state.DB.IsDomainBlocked(ctx, approvedByIRI.Host) + blocked, err := d.state.DB.IsDomainBlocked(ctx, authIRI.Host) if err != nil { // Real returnable error. err := gtserror.Newf("error checking domain block: %w", err) @@ -674,7 +677,7 @@ func (d *Dereferencer) isValidApprovedByIRI( } if blocked { - l.Info("approvedByIRI host is blocked") + l.Info("authIRI host is blocked") return false, nil } @@ -685,12 +688,12 @@ func (d *Dereferencer) isValidApprovedByIRI( return false, err } - // Make the call to the approvedByURI. + // Make the call to the authIRI. // Log any error encountered here but don't // return it as it's not *our* error. - rsp, err := tsport.Dereference(ctx, approvedByIRI) + rsp, err := tsport.Dereference(ctx, authIRI) if err != nil { - l.Errorf("error dereferencing approvedByIRI: %v", err) + l.Errorf("error dereferencing authIRI: %v", err) return false, nil } @@ -706,31 +709,31 @@ func (d *Dereferencer) isValidApprovedByIRI( } // Extract the URI/ID of the type. - approvedByID := ap.GetJSONLDId(t) - approvedByIDStr := approvedByID.String() + authID := ap.GetJSONLDId(t) + authIDStr := authID.String() // Check whether input URI and final returned URI // have changed (i.e. we followed some redirects). rspURL := rsp.Request.URL rspURLStr := rspURL.String() - if rspURLStr != approvedByIRIStr { + if rspURLStr != authIRIStr { // If rspURLStr != approvedByIRI, make sure final // response URL is at least on the same host as // what we expected (ie., we weren't redirected // across domains), and make sure it's the same // as the ID of the Accept we were returned. switch { - case rspURL.Host != approvedByIRI.Host: + case rspURL.Host != authIRI.Host: l.Errorf( - "final deref host %s did not match approvedByIRI host", + "final deref host %s did not match authIRI host", rspURL.Host, ) return false, nil - case approvedByIDStr != rspURLStr: + case authIDStr != rspURLStr: l.Errorf( "final deref uri %s did not match returned ID %s", - rspURLStr, approvedByIDStr, + rspURLStr, authIDStr, ) return false, nil } @@ -739,13 +742,13 @@ func (d *Dereferencer) isValidApprovedByIRI( // Response is superficially OK, // check in more detail now. - // First try to parse type as Approval stamp. - if approvable, ok := ap.ToApprovable(t); ok { - return isValidApprovable( + // First try to parse type as Authorization stamp. + if authable, ok := ap.ToAuthorizationable(t); ok { + return isValidAuthorization( ctx, interactionType, - approvable, - approvedByID, + authable, + authID, expectActorURIStr, // actor expectObjectURIStr, // object expectTargetURIStr, // target @@ -757,7 +760,7 @@ func (d *Dereferencer) isValidApprovedByIRI( return isValidAcceptable( ctx, acceptable, - approvedByID, + authID, expectActorURIStr, // actor expectObjectURIStr, // object expectTargetURIStr, // target @@ -767,8 +770,8 @@ func (d *Dereferencer) isValidApprovedByIRI( // Type wasn't something we // could do anything with! l.Errorf( - "%T at %s not approvable or acceptable", - t, approvedByIRIStr, + "%T at %s not authorization or accept", + t, authIRIStr, ) return false, nil } @@ -850,55 +853,55 @@ func isValidAcceptable( return true, nil } -func isValidApprovable( +func isValidAuthorization( ctx context.Context, interactionType gtsmodel.InteractionType, - approvable ap.Approvable, - approvalID *url.URL, + auth ap.Authorizationable, + authID *url.URL, expectActorURIStr string, // actor Eg., "https://example.org/users/someone" expectObjectURIStr string, // object Eg., "https://some.instance.example.org/users/someone_else/statuses/01J27414TWV9F7DC39FN8ABB5R" expectTargetURIStr string, // target Eg., "https://example.org/users/someone/statuses/01JM4REQTJ1BZ1R4BPYP1W4R9E" ) (bool, error) { l := log. WithContext(ctx). - WithField("approval", approvalID.String()) + WithField("auth", authID.String()) - // Check that the type of the Approval + // Check that the type of the Authorization // matches the interaction it's approving. - switch tn := approvable.GetTypeName(); { - case (tn == ap.ObjectLikeApproval && interactionType == gtsmodel.InteractionLike), - (tn == ap.ObjectReplyApproval && interactionType == gtsmodel.InteractionReply), - (tn == ap.ObjectAnnounceApproval && interactionType == gtsmodel.InteractionAnnounce): + switch tn := auth.GetTypeName(); { + case (tn == ap.ObjectLikeAuthorization && interactionType == gtsmodel.InteractionLike), + (tn == ap.ObjectReplyAuthorization && interactionType == gtsmodel.InteractionReply), + (tn == ap.ObjectAnnounceAuthorization && interactionType == gtsmodel.InteractionAnnounce): // All good baby! default: // There's a mismatch. l.Errorf( - "approval type %s cannot approve %s", + "authorization type %s cannot approve %s", tn, interactionType.String(), ) return false, nil } // Extract the actor IRI and string from Approval. - actorIRIs := ap.GetAttributedTo(approvable) + actorIRIs := ap.GetAttributedTo(auth) actorIRI, actorIRIStr := extractIRI(actorIRIs) switch { case actorIRIStr == "": - l.Error("Approval missing attributedTo IRI") + l.Error("authorization missing attributedTo IRI") return false, nil - // Ensure the Approval actor is on + // Ensure the authorization actor is on // the instance hosting the Approval. - case actorIRI.Host != approvalID.Host: + case actorIRI.Host != authID.Host: l.Errorf( - "actor %s not on the same host as Approval", + "actor %s not on the same host as authorization", actorIRIStr, ) return false, nil - // Ensure the Approval actor is who we expect + // Ensure the auth actor is who we expect // it to be, and not someone else trying to - // do an Approval for an interaction with a + // do an auth for an interaction with a // statusable they don't own. case actorIRIStr != expectActorURIStr: l.Errorf( @@ -908,33 +911,32 @@ func isValidApprovable( return false, nil } - // Extract the object IRI string from Approval. - objectIRIs := ap.GetObjectIRIs(approvable) + // Extract the object IRI string from authorization. + objectIRIs := ap.GetInteractingObject(auth) _, objectIRIStr := extractIRI(objectIRIs) switch { case objectIRIStr == "": - l.Error("missing Approval object IRI") + l.Error("missing authorization interactingObject IRI") return false, nil - // Ensure the Approval Object is what we expect + // Ensure the authorization object is what we expect // it to be, ie., it's approving the interaction // we need it to approve, and not something else. case objectIRIStr != expectObjectURIStr: l.Errorf( - "resolved Approval object IRI %s was not the same as expected object %s", + "resolved authorization interactingObject IRI %s was not the same as expected object %s", objectIRIStr, expectObjectURIStr, ) return false, nil } - // If there's a Target set then verify it's - // what we expect it to be, ie., it should point - // back to the post that's being interacted with. - targetIRIs := ap.GetTargetIRIs(approvable) + // Ensure the authorization target is what we expect, + // ie., it should be the status being interacted with. + targetIRIs := ap.GetInteractionTarget(auth) _, targetIRIStr := extractIRI(targetIRIs) if targetIRIStr != "" && targetIRIStr != expectTargetURIStr { l.Errorf( - "resolved Approval target IRI %s was not the same as expected target %s", + "resolved authorization interactionTarget IRI %s was not the same as expected target %s", targetIRIStr, expectTargetURIStr, ) return false, nil diff --git a/internal/federation/dereferencing/status_test.go b/internal/federation/dereferencing/status_test.go index e44babbfc..62b38f188 100644 --- a/internal/federation/dereferencing/status_test.go +++ b/internal/federation/dereferencing/status_test.go @@ -136,7 +136,7 @@ func (suite *StatusTestSuite) TestDereferenceStatusWithTag() { // status values should be set suite.Equal("https://unknown-instance.com/users/brand_new_person/statuses/01H641QSRS3TCXSVC10X4GPKW7", status.URI) suite.Equal("https://unknown-instance.com/users/@brand_new_person/01H641QSRS3TCXSVC10X4GPKW7", status.URL) - suite.Equal("<p>Babe are you okay, you've hardly touched your <a href=\"https://unknown-instance.com/tags/piss\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>piss</span></a></p>", status.Content) + suite.Equal("<p><span class=\"h-card\"><a href=\"http://fossbros-anonymous.io/@foss_satan\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>foss_satan</span></a></span>Babe are you okay, you've hardly touched your <a href=\"https://unknown-instance.com/tags/piss\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>piss</span></a></p>", status.Content) suite.Equal("https://unknown-instance.com/users/brand_new_person", status.AccountURI) suite.False(*status.Local) suite.Empty(status.ContentWarning) diff --git a/internal/federation/federatingactor_test.go b/internal/federation/federatingactor_test.go index 51ead5f31..6de4aa203 100644 --- a/internal/federation/federatingactor_test.go +++ b/internal/federation/federatingactor_test.go @@ -42,18 +42,14 @@ func (suite *FederatingActorTestSuite) TestSendNoRemoteFollowers() { ctx := suite.T().Context() testAccount := suite.testAccounts["local_account_1"] testNote := testrig.NewAPNote( - testrig.URLMustParse("http://localhost:8080/users/the_mighty_zork/statuses/01G1TR6BADACCZWQMNF9X21TV5"), - testrig.URLMustParse("http://localhost:8080/@the_mighty_zork/statuses/01G1TR6BADACCZWQMNF9X21TV5"), - time.Now(), - "boobies", - "", - testrig.URLMustParse(testAccount.URI), - []*url.URL{testrig.URLMustParse(testAccount.FollowersURI)}, - nil, - false, - nil, - nil, - nil, + &testrig.NewAPNoteParams{ + ID: testrig.URLMustParse("http://localhost:8080/users/the_mighty_zork/statuses/01G1TR6BADACCZWQMNF9X21TV5"), + URL: testrig.URLMustParse("http://localhost:8080/@the_mighty_zork/statuses/01G1TR6BADACCZWQMNF9X21TV5"), + CreatedAt: time.Now(), + Content: "boobies", + AttributedTo: testrig.URLMustParse(testAccount.URI), + To: []*url.URL{testrig.URLMustParse(testAccount.FollowersURI)}, + }, ) testActivity := testrig.WrapAPNoteInCreate(testrig.URLMustParse("http://localhost:8080/whatever_some_create"), testrig.URLMustParse(testAccount.URI), time.Now(), testNote) @@ -98,18 +94,14 @@ func (suite *FederatingActorTestSuite) TestSendRemoteFollower() { suite.NoError(err) testNote := testrig.NewAPNote( - testrig.URLMustParse("http://localhost:8080/users/the_mighty_zork/statuses/01G1TR6BADACCZWQMNF9X21TV5"), - testrig.URLMustParse("http://localhost:8080/@the_mighty_zork/statuses/01G1TR6BADACCZWQMNF9X21TV5"), - testrig.TimeMustParse("2022-06-02T12:22:21+02:00"), - "boobies", - "", - testrig.URLMustParse(testAccount.URI), - []*url.URL{testrig.URLMustParse(testAccount.FollowersURI)}, - nil, - false, - nil, - nil, - nil, + &testrig.NewAPNoteParams{ + ID: testrig.URLMustParse("http://localhost:8080/users/the_mighty_zork/statuses/01G1TR6BADACCZWQMNF9X21TV5"), + URL: testrig.URLMustParse("http://localhost:8080/@the_mighty_zork/statuses/01G1TR6BADACCZWQMNF9X21TV5"), + CreatedAt: testrig.TimeMustParse("2022-06-02T12:22:21+02:00"), + Content: "boobies", + AttributedTo: testrig.URLMustParse(testAccount.URI), + To: []*url.URL{testrig.URLMustParse(testAccount.FollowersURI)}, + }, ) testActivity := testrig.WrapAPNoteInCreate(testrig.URLMustParse("http://localhost:8080/whatever_some_create"), testrig.URLMustParse(testAccount.URI), testrig.TimeMustParse("2022-06-02T12:22:21+02:00"), testNote) 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 := >smodel.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 := >smodel.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 := >smodel.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 := >smodel.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 = >smodel.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) + } +} diff --git a/internal/federation/federator.go b/internal/federation/federator.go index 5f8324da2..3f5c96002 100644 --- a/internal/federation/federator.go +++ b/internal/federation/federator.go @@ -99,6 +99,9 @@ func NewFederator( federatingDB.Announce, federatingDB.Move, federatingDB.Flag, + federatingDB.LikeRequest, + federatingDB.ReplyRequest, + federatingDB.AnnounceRequest, }, } actor := newFederatingActor(f, f, federatingDB, clock) |
