From 027f5e6275be65235326cd08a97902f9aa697c38 Mon Sep 17 00:00:00 2001 From: tobi Date: Fri, 4 Oct 2024 17:22:25 +0200 Subject: [PATCH] [feature] Distribute + ingest Accepts to followers --- docs/federation/posts.md | 9 + internal/ap/activitystreams.go | 4 + internal/federation/dereferencing/status.go | 5 +- .../dereferencing/status_permitted.go | 598 ++++++++++++------ internal/federation/federatingdb/accept.go | 140 ++-- internal/gtsmodel/interaction.go | 12 +- internal/processing/workers/fromfediapi.go | 59 ++ internal/typeutils/internaltoas.go | 26 + internal/typeutils/internaltoas_test.go | 4 + 9 files changed, 607 insertions(+), 250 deletions(-) diff --git a/docs/federation/posts.md b/docs/federation/posts.md index 1330476a9..c599587a7 100644 --- a/docs/federation/posts.md +++ b/docs/federation/posts.md @@ -569,6 +569,7 @@ For example, the following json object `Reject`s the attempt of `@someone@somewh ```json { + "@context": "https://www.w3.org/ns/activitystreams", "actor": "https://example.org/users/post_author", "to": "https://somewhere.else.example.org/users/someone", "id": "https://example.org/users/post_author/activities/reject/01J0K2YXP9QCT5BE1JWQSAM3B6", @@ -591,7 +592,12 @@ For example, the following json object `Accept`s the attempt of `@someone@somewh ```json { + "@context": "https://www.w3.org/ns/activitystreams", "actor": "https://example.org/users/post_author", + "cc": [ + "https://www.w3.org/ns/activitystreams#Public", + "https://example.org/users/post_author/followers" + ], "to": "https://somewhere.else.example.org/users/someone", "id": "https://example.org/users/post_author/activities/reject/01J0K2YXP9QCT5BE1JWQSAM3B6", "object": "https://somewhere.else.example.org/users/someone/statuses/01J17XY2VXGMNNPH1XR7BG2524", @@ -601,6 +607,9 @@ For example, the following json object `Accept`s the attempt of `@someone@somewh If this happens, `@someone@somewhere.else.example.org` (and their instance) should consider the interaction as having been approved / accepted. The instance can then feel free to distribute the interaction `Activity` to all of the recipients targed by `to`, `cc`, etc, with the additional property `approvedBy` ([see below](#approvedby)). +!!! Note + In the above example, actor `https://example.org/users/post_author` addresses the `Accept` activity not just to the interacting actor `https://somewhere.else.example.org/users/someone`, but to their followers collection as well (and, implicitly, to the public). This allows followers of `https://example.org/users/post_author` on other servers to also mark the interaction as accepted, and to show the interaction alongside the interacted-with post. + ### Validating presence in a Followers / Following collection If an `Actor` interacting with an `Object` (via `Like`, `inReplyTo`, or `Announce`) is permitted to do that interaction based on their presence in a `Followers` or `Following` collection in the `always` field of an interaction policy, then their server should *still* wait for an `Accept` to be received from the server of the target account, before distributing the interaction more widely with the `approvedBy` property set to the URI of the `Accept`. diff --git a/internal/ap/activitystreams.go b/internal/ap/activitystreams.go index a78b0b61d..8c53ae501 100644 --- a/internal/ap/activitystreams.go +++ b/internal/ap/activitystreams.go @@ -77,6 +77,10 @@ const ( // See https://www.w3.org/TR/activitystreams-vocabulary/#microsyntaxes // and https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tag TagHashtag = "Hashtag" + + // Not in the AS spec, just used internally to indicate + // that we don't *yet* know what type of Object something is. + ObjectUnknown = "Unknown" ) // isActivity returns whether AS type name is of an Activity (NOT IntransitiveActivity). diff --git a/internal/federation/dereferencing/status.go b/internal/federation/dereferencing/status.go index 8ca5418f2..c90730826 100644 --- a/internal/federation/dereferencing/status.go +++ b/internal/federation/dereferencing/status.go @@ -527,8 +527,9 @@ func (d *Dereferencer) enrichStatus( // serve statuses with the `approved_by` field, but we // might have marked a status as pre-approved on our side // based on the author's inclusion in a followers/following - // collection. By carrying over previously-set values we - // can avoid marking such statuses as "pending" again. + // collection, or by providing pre-approval URI on the bare + // status passed to RefreshStatus. By carrying over previously + // set values we can avoid marking such statuses as "pending". // // If a remote has in the meantime retracted its approval, // the next call to 'isPermittedStatus' will catch that. diff --git a/internal/federation/dereferencing/status_permitted.go b/internal/federation/dereferencing/status_permitted.go index 2aecfc9b7..4b246653c 100644 --- a/internal/federation/dereferencing/status_permitted.go +++ b/internal/federation/dereferencing/status_permitted.go @@ -113,33 +113,17 @@ func (d *Dereferencer) isPermittedStatus( func (d *Dereferencer) isPermittedReply( ctx context.Context, requestUser string, - status *gtsmodel.Status, + reply *gtsmodel.Status, ) (bool, error) { var ( - statusURI = status.URI // Definitely set. - inReplyToURI = status.InReplyToURI // Definitely set. - inReplyTo = status.InReplyTo // Might not yet be set. + replyURI = reply.URI // Definitely set. + inReplyToURI = reply.InReplyToURI // Definitely set. + inReplyTo = reply.InReplyTo // Might not be set. + acceptIRI = reply.ApprovedByURI // Might not be set. ) - // Check if status with this URI has previously been rejected. - req, err := d.state.DB.GetInteractionRequestByInteractionURI( - gtscontext.SetBarebones(ctx), - statusURI, - ) - if err != nil && !errors.Is(err, db.ErrNoEntries) { - err := gtserror.Newf("db error getting interaction request: %w", err) - return false, err - } - - if req != nil && req.IsRejected() { - // This status has been - // rejected reviously, so - // it's not permitted now. - return false, nil - } - - // Check if replied-to status has previously been rejected. - req, err = d.state.DB.GetInteractionRequestByInteractionURI( + // Check if we have a stored interaction request for parent status. + parentReq, err := d.state.DB.GetInteractionRequestByInteractionURI( gtscontext.SetBarebones(ctx), inReplyToURI, ) @@ -148,71 +132,78 @@ func (d *Dereferencer) isPermittedReply( return false, err } - if req != nil && req.IsRejected() { - // This status's parent was rejected, so - // implicitly this reply should be rejected too. - // - // We know already that we haven't inserted - // a rejected interaction request for this - // status yet so do it before returning. - id := id.NewULID() + // Check if we have a stored interaction request for this reply. + thisReq, err := d.state.DB.GetInteractionRequestByInteractionURI( + gtscontext.SetBarebones(ctx), + replyURI, + ) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting interaction request: %w", err) + return false, err + } - // To ensure the Reject chain stays coherent, - // borrow fields from the up-thread rejection. - // This collapses the chain beyond the first - // rejected reply and allows us to avoid derefing - // further replies we already know we don't want. - statusID := req.StatusID - targetAccountID := req.TargetAccountID + parentRejected := (parentReq != nil && parentReq.IsRejected()) + thisRejected := (thisReq != nil && thisReq.IsRejected()) - // As nobody is actually Rejecting the reply - // directly, but it's an implicit Reject coming - // from our internal logic, don't bother setting - // a URI (it's not a required field anyway). - uri := "" - - rejection := >smodel.InteractionRequest{ - ID: id, - StatusID: statusID, - TargetAccountID: targetAccountID, - InteractingAccountID: status.AccountID, - InteractionURI: statusURI, - InteractionType: gtsmodel.InteractionReply, - URI: uri, - RejectedAt: time.Now(), - } - err := d.state.DB.PutInteractionRequest(ctx, rejection) - if err != nil && !errors.Is(err, db.ErrAlreadyExists) { - return false, gtserror.Newf("db error putting pre-rejected interaction request: %w", err) - } + if parentRejected { + // If this status's parent was rejected, + // implicitly this reply should be too; + // there's nothing more to check here. + return false, d.unpermittedByParent( + ctx, + reply, + thisReq, + parentReq, + ) + } + // Parent wasn't rejected. Check if this + // reply itself was rejected previously. + // + // If it was, and it doesn't now claim to + // be approved, then we should just reject it + // again, as nothing's changed since last time. + if thisRejected && acceptIRI == "" { + // Nothing changed, + // still rejected. return false, nil } + // This reply wasn't rejected previously, or + // it was rejected previously and now claims + // to be approved. Continue permission checks. + if inReplyTo == nil { - // We didn't have the replied-to status in - // our database (yet) so we can't know if - // this reply is permitted or not. For now - // just return true; worst-case, the status - // sticks around on the instance for a couple - // hours until we try to dereference it again - // and realize it should be forbidden. - return true, 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. + // + // For now, just return permitted if reply + // was not explicitly rejected before; worst- + // case, the reply stays on the instance for + // a couple hours until we try to deref it + // again and realize it should be forbidden. + return !thisRejected, nil } + // We have the replied-to status; ensure it's fully populated. + if err := d.state.DB.PopulateStatus(ctx, inReplyTo); 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. (this - // shouldn't be able to happen). + // We do not permit replies + // to boost wrapper statuses. log.Info(ctx, "rejecting reply to boost wrapper status") return false, nil } - // Check visibility of local - // inReplyTo to replying account. if inReplyTo.IsLocal() { visible, err := d.visFilter.StatusVisible(ctx, - status.Account, + reply.Account, inReplyTo, ) if err != nil { @@ -227,9 +218,26 @@ func (d *Dereferencer) isPermittedReply( } } - // Check interaction policy of inReplyTo. + // If this reply claims to be approved, + // validate this by dereferencing the + // Accept and checking the return value. + // No further checks are required. + if acceptIRI != "" { + return d.isPermittedByAcceptIRI( + ctx, + requestUser, + reply, + inReplyTo, + thisReq, + acceptIRI, + ) + } + + // Status doesn't claim to be approved. + // Check interaction policy of inReplyTo + // to see if it doesn't require approval. replyable, err := d.intFilter.StatusReplyable(ctx, - status.Account, + reply.Account, inReplyTo, ) if err != nil { @@ -238,93 +246,250 @@ func (d *Dereferencer) isPermittedReply( } if replyable.Forbidden() { - // Reply is not permitted. + // Reply is not permitted according to policy. // - // Insert a pre-rejected interaction request - // into the db and return. This ensures that - // replies to this now-rejected status aren't - // inadvertently permitted. - id := id.NewULID() - rejection := >smodel.InteractionRequest{ - ID: id, - StatusID: inReplyTo.ID, - TargetAccountID: inReplyTo.AccountID, - InteractingAccountID: status.AccountID, - InteractionURI: statusURI, - InteractionType: gtsmodel.InteractionReply, - URI: uris.GenerateURIForReject(inReplyTo.Account.Username, id), - RejectedAt: time.Now(), - } - err := d.state.DB.PutInteractionRequest(ctx, rejection) - if err != nil && !errors.Is(err, db.ErrAlreadyExists) { - return false, gtserror.Newf("db error putting pre-rejected interaction request: %w", err) - } - - return false, nil + // Either insert a pre-rejected interaction + // req into the db, or update the existing + // one, and return. This ensures that replies + // to this rejected reply also aren't permitted. + return false, d.rejectedByPolicy( + ctx, + reply, + inReplyTo, + thisReq, + ) } - if replyable.Permitted() && - !replyable.MatchedOnCollection() { - // Replier is permitted to do this - // interaction, and didn't match on - // a collection so we don't need to - // do further checking. + // Reply is permitted according to the interaction + // policy set on the replied-to status (if any). + + if !replyable.MatchedOnCollection() { + // If we didn't match on a collection, + // then we don't require an acceptIRI, + // and we don't need to send an Accept; + // just permit the reply full stop. return true, nil } - // Replier is permitted to do this - // interaction pending approval, or - // permitted but matched on a collection. + // Reply is permitted, but match was made based + // on inclusion in a followers/following collection. // - // Check if we can dereference - // an Accept that grants approval. - - if status.ApprovedByURI == "" { - // Status doesn't claim to be approved. - // - // For replies to local statuses that's - // fine, we can put it in the DB pending - // approval, and continue processing it. - // - // If permission was granted based on a match - // with a followers or following collection, - // we can mark it as PreApproved so the processor - // sends an accept out for it immediately. - // - // For replies to remote statuses, though - // we should be polite and just drop it. - if inReplyTo.IsLocal() { - status.PendingApproval = util.Ptr(true) - status.PreApproved = replyable.MatchedOnCollection() - return true, nil - } - - return false, nil + // If the status is ours, mark it as PreApproved + // so the processor knows to create and send out + // an Accept for it immediately. + if inReplyTo.IsLocal() { + reply.PendingApproval = util.Ptr(true) + reply.PreApproved = true + return true, nil } - // Status claims to be approved, check - // this by dereferencing the Accept and - // inspecting the return value. - if err := d.validateApprovedBy( + // For replies to remote statuses, which matched + // on a followers/following collection, but did not + // include an acceptIRI, we should just drop it. + // It's possible we'll get an Accept for it later + // and we can check everything again. + return false, nil +} + +// unpermittedByParent marks the given reply as rejected +// based on the fact that its parent was rejected. +// +// This will create a rejected interaction request for +// the status in the db, if one didn't exist already, +// or update an existing interaction request instead. +func (d *Dereferencer) unpermittedByParent( + ctx context.Context, + reply *gtsmodel.Status, + thisReq *gtsmodel.InteractionRequest, + parentReq *gtsmodel.InteractionRequest, +) error { + if thisReq != nil && thisReq.IsRejected() { + // This interaction request is + // already marked as rejected, + // there's nothing more to do. + return nil + } + + if thisReq != nil { + // Before we return, ensure interaction + // request is marked as rejected. + thisReq.RejectedAt = time.Now() + thisReq.AcceptedAt = time.Time{} + err := d.state.DB.UpdateInteractionRequest( + ctx, + thisReq, + "rejected_at", + "accepted_at", + ) + if err != nil { + return gtserror.Newf("db error updating interaction request: %w", err) + } + + return nil + } + + // We haven't stored a rejected interaction + // request for this status yet, do it now. + rejectID := id.NewULID() + + // To ensure the Reject chain stays coherent, + // borrow fields from the up-thread rejection. + // 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 + targetAccountID := parentReq.TargetAccountID + + // As nobody is actually Rejecting the reply + // directly, but it's an implicit Reject coming + // from our internal logic, don't bother setting + // a URI (it's not a required field anyway). + uri := "" + + rejection := >smodel.InteractionRequest{ + ID: rejectID, + StatusID: inReplyToID, + TargetAccountID: targetAccountID, + InteractingAccountID: reply.AccountID, + InteractionURI: reply.URI, + InteractionType: gtsmodel.InteractionReply, + URI: uri, + RejectedAt: time.Now(), + } + err := d.state.DB.PutInteractionRequest(ctx, rejection) + if err != nil && !errors.Is(err, db.ErrAlreadyExists) { + return gtserror.Newf("db error putting pre-rejected interaction request: %w", err) + } + + return nil +} + +// isPermittedByAcceptIRI checks whether the given acceptIRI +// permits the given reply to the given inReplyTo status. +// If yes, then thisReq will be updated to reflect the +// acceptance, if it's not nil. +func (d *Dereferencer) isPermittedByAcceptIRI( + ctx context.Context, + requestUser string, + reply *gtsmodel.Status, + inReplyTo *gtsmodel.Status, + thisReq *gtsmodel.InteractionRequest, + acceptIRI string, +) (bool, error) { + permitted, err := d.isValidAccept( ctx, requestUser, - status.ApprovedByURI, - statusURI, + acceptIRI, + reply.URI, inReplyTo.AccountURI, - ); err != nil { - + ) + if err != nil { // Error dereferencing means we couldn't // get the Accept right now or it wasn't // valid, so we shouldn't store this status. - log.Errorf(ctx, "undereferencable ApprovedByURI: %v", err) + err := gtserror.Newf("undereferencable ApprovedByURI: %w", err) + return false, err + } + + if !permitted { + // It's a no from + // us, squirt. return false, nil } - // Status has been approved. - status.PendingApproval = util.Ptr(false) + // Reply is permitted by this Accept. + // If it was previously rejected or + // pending approval, clear that now. + reply.PendingApproval = util.Ptr(false) + if thisReq != nil { + thisReq.URI = acceptIRI + thisReq.AcceptedAt = time.Now() + thisReq.RejectedAt = time.Time{} + err := d.state.DB.UpdateInteractionRequest( + ctx, + thisReq, + "uri", + "accepted_at", + "rejected_at", + ) + if err != nil { + return false, gtserror.Newf("db error updating interaction request: %w", err) + } + } + + // All good! return true, nil } +func (d *Dereferencer) rejectedByPolicy( + ctx context.Context, + reply *gtsmodel.Status, + inReplyTo *gtsmodel.Status, + thisReq *gtsmodel.InteractionRequest, +) error { + var ( + rejectID string + rejectURI string + ) + + if thisReq != nil { + // Reuse existing ID. + rejectID = thisReq.ID + } else { + // Generate new ID. + rejectID = id.NewULID() + } + + if inReplyTo.IsLocal() { + // If this a reply to one of our statuses + // we should generate a URI for the Reject, + // else just use an implicit (empty) URI. + rejectURI = uris.GenerateURIForReject( + inReplyTo.Account.Username, + rejectID, + ) + } + + if thisReq != nil { + // Before we return, ensure interaction + // request is marked as rejected. + thisReq.RejectedAt = time.Now() + thisReq.AcceptedAt = time.Time{} + thisReq.URI = rejectURI + err := d.state.DB.UpdateInteractionRequest( + ctx, + thisReq, + "rejected_at", + "accepted_at", + "uri", + ) + if err != nil { + return gtserror.Newf("db error updating interaction request: %w", err) + } + + return nil + } + + // 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(), + } + err := d.state.DB.PutInteractionRequest(ctx, rejection) + if err != nil && !errors.Is(err, db.ErrAlreadyExists) { + return gtserror.Newf("db error putting pre-rejected interaction request: %w", err) + } + + return nil +} + func (d *Dereferencer) isPermittedBoost( ctx context.Context, requestUser string, @@ -418,18 +583,22 @@ func (d *Dereferencer) isPermittedBoost( // Boost claims to be approved, check // this by dereferencing the Accept and // inspecting the return value. - if err := d.validateApprovedBy( + permitted, err := d.isValidAccept( ctx, requestUser, status.ApprovedByURI, status.URI, boostOf.AccountURI, - ); err != nil { - + ) + if err != nil { // Error dereferencing means we couldn't // get the Accept right now or it wasn't // valid, so we shouldn't store this status. - log.Errorf(ctx, "undereferencable ApprovedByURI: %v", err) + err := gtserror.Newf("undereferencable ApprovedByURI: %w", err) + return false, err + } + + if !permitted { return false, nil } @@ -438,43 +607,59 @@ func (d *Dereferencer) isPermittedBoost( return true, nil } -// validateApprovedBy dereferences the activitystreams Accept at -// the specified IRI, and checks the Accept for validity against -// the provided expectedObject and expectedActor. +// isValidAccept dereferences the activitystreams Accept at the +// specified IRI, and checks the Accept for validity against the +// provided expectedObject and expectedActor. // -// Will return either nil if everything looked OK, or an error if -// something went wrong during deref, or if the dereffed Accept -// did not meet expectations. -func (d *Dereferencer) validateApprovedBy( +// 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 did not meet expectations. +func (d *Dereferencer) isValidAccept( ctx context.Context, requestUser string, - approvedByURIStr string, // Eg., "https://example.org/users/someone/accepts/01J2736AWWJ3411CPR833F6D03" + acceptIRIStr string, // Eg., "https://example.org/users/someone/accepts/01J2736AWWJ3411CPR833F6D03" expectObjectURIStr string, // Eg., "https://some.instance.example.org/users/someone_else/statuses/01J27414TWV9F7DC39FN8ABB5R" expectActorURIStr string, // Eg., "https://example.org/users/someone" -) error { - approvedByURI, err := url.Parse(approvedByURIStr) +) (bool, error) { + l := log. + WithContext(ctx). + WithField("acceptIRI", acceptIRIStr) + + acceptIRI, err := url.Parse(acceptIRIStr) if err != nil { - err := gtserror.Newf("error parsing approvedByURI: %w", err) - return err + // Real returnable error. + err := gtserror.Newf("error parsing acceptIRI: %w", err) + return false, err } - // Don't make calls to the remote if it's blocked. - if blocked, err := d.state.DB.IsDomainBlocked(ctx, approvedByURI.Host); blocked || err != nil { - err := gtserror.Newf("domain %s is blocked", approvedByURI.Host) - return err + // Don't make calls to the Accept IRI + // if it's blocked, just return false. + blocked, err := d.state.DB.IsDomainBlocked(ctx, acceptIRI.Host) + if err != nil { + // Real returnable error. + err := gtserror.Newf("error checking domain block: %w", err) + return false, err + } + + if blocked { + l.Info("Accept host is blocked") + return false, nil } tsport, err := d.transportController.NewTransportForUsername(ctx, requestUser) if err != nil { + // Real returnable error. err := gtserror.Newf("error creating transport: %w", err) - return err + return false, err } // Make the call to resolve into an Acceptable. - rsp, err := tsport.Dereference(ctx, approvedByURI) + // Log any error encountered here but don't + // return it as it's not *our* error. + rsp, err := tsport.Dereference(ctx, acceptIRI) if err != nil { - err := gtserror.Newf("error dereferencing %s: %w", approvedByURIStr, err) - return err + l.Errorf("error dereferencing Accept: %v", err) + return false, nil } acceptable, err := ap.ResolveAcceptable(ctx, rsp.Body) @@ -483,66 +668,71 @@ func (d *Dereferencer) validateApprovedBy( _ = rsp.Body.Close() if err != nil { - err := gtserror.Newf("error resolving Accept %s: %w", approvedByURIStr, err) - return err + l.Errorf("error resolving to Accept: %v", err) + return false, err } // Extract the URI/ID of the Accept. - acceptURI := ap.GetJSONLDId(acceptable) - acceptURIStr := acceptURI.String() + acceptID := ap.GetJSONLDId(acceptable) + acceptIDStr := acceptID.String() // Check whether input URI and final returned URI // have changed (i.e. we followed some redirects). rspURL := rsp.Request.URL rspURLStr := rspURL.String() - switch { - case rspURLStr == approvedByURIStr: + if rspURLStr != acceptIRIStr { + // If rspURLStr != acceptIRIStr, 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 != acceptIRI.Host: + l.Errorf( + "final deref host %s did not match acceptIRI host", + rspURL.Host, + ) + return false, nil - // i.e. from here, rspURLStr != approvedByURIStr. - // - // Make sure it's 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. - case rspURL.Host != approvedByURI.Host: - return gtserror.Newf( - "final dereference host %s did not match approvedByURI host %s", - rspURL.Host, approvedByURI.Host, - ) - case acceptURIStr != rspURLStr: - return gtserror.Newf( - "final dereference uri %s did not match returned Accept ID/URI %s", - rspURLStr, acceptURIStr, - ) + case acceptIDStr != rspURLStr: + l.Errorf( + "final deref uri %s did not match returned Accept ID %s", + rspURLStr, acceptIDStr, + ) + return false, nil + } } + // Response is superficially OK, + // check in more detail now. + // Extract the actor IRI and string from Accept. actorIRIs := ap.GetActorIRIs(acceptable) actorIRI, actorIRIStr := extractIRI(actorIRIs) switch { case actorIRIStr == "": - err := gtserror.New("missing Accept actor IRI") - return gtserror.SetMalformed(err) + l.Error("Accept missing actor IRI") + return false, nil - // Ensure the Accept Actor is who we expect - // it to be, and not someone else trying to - // do an Accept for an interaction with a - // statusable they don't own. - case actorIRI.Host != acceptURI.Host: - return gtserror.Newf( - "Accept Actor %s was not the same host as Accept %s", - actorIRIStr, acceptURIStr, + // Ensure the Accept Actor is on + // the instance hosting the Accept. + case actorIRI.Host != acceptID.Host: + l.Errorf( + "actor %s not on the same host as Accept", + actorIRIStr, ) + return false, nil // Ensure the Accept Actor is who we expect // it to be, and not someone else trying to // do an Accept for an interaction with a // statusable they don't own. case actorIRIStr != expectActorURIStr: - return gtserror.Newf( - "Accept Actor %s was not the same as expected actor %s", + l.Errorf( + "actor %s was not the same as expected actor %s", actorIRIStr, expectActorURIStr, ) + return false, nil } // Extract the object IRI string from Accept. @@ -550,20 +740,22 @@ func (d *Dereferencer) validateApprovedBy( _, objectIRIStr := extractIRI(objectIRIs) switch { case objectIRIStr == "": - err := gtserror.New("missing Accept object IRI") - return gtserror.SetMalformed(err) + l.Error("missing Accept object IRI") + return false, nil // Ensure the Accept Object is what we expect // it to be, ie., it's Accepting the interaction // we need it to Accept, and not something else. case objectIRIStr != expectObjectURIStr: - return gtserror.Newf( - "resolved Accept Object uri %s was not the same as expected object %s", + l.Errorf( + "resolved Accept object IRI %s was not the same as expected object %s", objectIRIStr, expectObjectURIStr, ) + return false, nil } - return nil + // Everything looks OK. + return true, nil } // extractIRI is shorthand to extract the first IRI diff --git a/internal/federation/federatingdb/accept.go b/internal/federation/federatingdb/accept.go index 0592e6b9b..0274fd9d7 100644 --- a/internal/federation/federatingdb/accept.go +++ b/internal/federation/federatingdb/accept.go @@ -24,6 +24,7 @@ import ( "github.com/superseriousbusiness/activity/streams/vocab" "github.com/superseriousbusiness/gotosocial/internal/ap" + "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" @@ -68,6 +69,20 @@ func (f *federatingDB) Accept(ctx context.Context, accept vocab.ActivityStreamsA return gtserror.NewErrorBadRequest(errors.New(text), text) } + // Ensure requester is the same as the + // Actor of the Accept; you can't Accept + // something on someone else's behalf. + actorURI, err := ap.ExtractActorURI(accept) + if err != nil { + const text = "Accept had empty or invalid actor property" + return gtserror.NewErrorBadRequest(errors.New(text), text) + } + + if requestingAcct.URI != actorURI.String() { + const text = "Accept actor and requesting account were not the same" + return gtserror.NewErrorBadRequest(errors.New(text), text) + } + // Iterate all provided objects in the activity, // handling the ones we know how to handle. for _, object := range ap.ExtractObjects(accept) { @@ -108,18 +123,6 @@ func (f *federatingDB) Accept(ctx context.Context, accept vocab.ActivityStreamsA return err } - // ACCEPT STATUS (reply/boost) - case uris.IsStatusesPath(objIRI): - if err := f.acceptStatusIRI( - ctx, - activityID.String(), - objIRI.String(), - receivingAcct, - requestingAcct, - ); err != nil { - return err - } - // ACCEPT LIKE case uris.IsLikePath(objIRI): if err := f.acceptLikeIRI( @@ -132,9 +135,20 @@ func (f *federatingDB) Accept(ctx context.Context, accept vocab.ActivityStreamsA return err } - // UNHANDLED + // ACCEPT OTHER (reply? boost?) + // + // Don't check on IsStatusesPath + // as this may be a remote status. default: - log.Debugf(ctx, "unhandled iri type: %s", objIRI) + if err := f.acceptOtherIRI( + ctx, + activityID, + objIRI, + receivingAcct, + requestingAcct, + ); err != nil { + return err + } } } } @@ -276,39 +290,91 @@ func (f *federatingDB) acceptFollowIRI( return nil } -func (f *federatingDB) acceptStatusIRI( +func (f *federatingDB) acceptOtherIRI( ctx context.Context, - activityID string, - objectIRI string, + activityID *url.URL, + objectIRI *url.URL, receivingAcct *gtsmodel.Account, requestingAcct *gtsmodel.Account, ) error { - // Lock on this potential status - // URI as we may be updating it. - unlock := f.state.FedLocks.Lock(objectIRI) - defer unlock() - - // Get the status from the db. - status, err := f.state.DB.GetStatusByURI(ctx, objectIRI) + // See if we can get a status from the db. + status, err := f.state.DB.GetStatusByURI(ctx, objectIRI.String()) if err != nil && !errors.Is(err, db.ErrNoEntries) { err := gtserror.Newf("db error getting status: %w", err) return gtserror.NewErrorInternalError(err) } - if status == nil { - // We didn't have a status with - // this URI, so nothing to do. - // Just return. + if status != nil { + // We had a status stored with this + // objectIRI, proceed to accept it. + return f.acceptStoredStatus( + ctx, + activityID, + status, + receivingAcct, + requestingAcct, + ) + } + + if objectIRI.Host == config.GetHost() || + objectIRI.Host == config.GetAccountDomain() { + // Claims to be Accepting something of ours, + // but we don't have a status stored for this + // URI, so most likely it's been deleted in + // the meantime, just bail. return nil } - if !status.IsLocal() { - // We don't process Accepts of statuses - // that weren't created on our instance. - // Just return. + // This must be an Accept of a remote Activity + // or Object. 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 gtserror.NewErrorInternalError(err) + } + + if !following { + // If we don't follow this person, and + // they're not Accepting something we know + // about, then we don't give a good goddamn. return nil } + // This may be a reply, or it may be a boost, + // we can't know yet without dereferencing it, + // but let the processor worry about that. + apObjectType := ap.ObjectUnknown + + // Pass to the processor and let them handle side effects. + f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{ + APObjectType: apObjectType, + APActivityType: ap.ActivityAccept, + APIRI: activityID, + APObject: objectIRI, + Receiving: receivingAcct, + Requesting: requestingAcct, + }) + + return nil +} + +func (f *federatingDB) acceptStoredStatus( + ctx context.Context, + activityID *url.URL, + status *gtsmodel.Status, + receivingAcct *gtsmodel.Account, + requestingAcct *gtsmodel.Account, +) error { + // Lock on this status URI + // as we may be updating it. + unlock := f.state.FedLocks.Lock(status.URI) + defer unlock() + pendingApproval := util.PtrOrValue(status.PendingApproval, false) if !pendingApproval { // Status doesn't need approval or it's @@ -317,14 +383,6 @@ func (f *federatingDB) acceptStatusIRI( return nil } - // Make sure the creator of the original status - // is the same as the inbox processing the Accept; - // this also ensures the status is local. - if status.AccountID != receivingAcct.ID { - const text = "status author account and inbox account were not the same" - return gtserror.NewErrorUnprocessableEntity(errors.New(text), text) - } - // Make sure the target of the interaction (reply/boost) // is the same as the account doing the Accept. if status.BoostOfAccountID != requestingAcct.ID && @@ -335,7 +393,7 @@ func (f *federatingDB) acceptStatusIRI( // Mark the status as approved by this Accept URI. status.PendingApproval = util.Ptr(false) - status.ApprovedByURI = activityID + status.ApprovedByURI = activityID.String() if err := f.state.DB.UpdateStatus( ctx, status, diff --git a/internal/gtsmodel/interaction.go b/internal/gtsmodel/interaction.go index 562b752eb..92dd1a4e0 100644 --- a/internal/gtsmodel/interaction.go +++ b/internal/gtsmodel/interaction.go @@ -69,25 +69,29 @@ type InteractionRequest struct { Like *StatusFave `bun:"-"` // Not stored in DB. Only set if InteractionType = InteractionLike. Reply *Status `bun:"-"` // Not stored in DB. Only set if InteractionType = InteractionReply. Announce *Status `bun:"-"` // Not stored in DB. Only set if InteractionType = InteractionAnnounce. - URI string `bun:",nullzero,unique"` // ActivityPub URI of the Accept (if accepted) or Reject (if rejected). Null/empty if currently neither accepted not rejected. AcceptedAt time.Time `bun:"type:timestamptz,nullzero"` // If interaction request was accepted, time at which this occurred. RejectedAt time.Time `bun:"type:timestamptz,nullzero"` // If interaction request was rejected, time at which this occurred. + + // ActivityPub URI of the Accept (if accepted) or Reject (if rejected). + // Field may be empty if currently neither accepted not rejected, or if + // acceptance/rejection was implicit (ie., not resulting from an Activity). + URI string `bun:",nullzero,unique"` } // IsHandled returns true if interaction // request has been neither accepted or rejected. func (ir *InteractionRequest) IsPending() bool { - return ir.URI == "" && ir.AcceptedAt.IsZero() && ir.RejectedAt.IsZero() + return !ir.IsAccepted() && !ir.IsRejected() } // IsAccepted returns true if this // interaction request has been accepted. func (ir *InteractionRequest) IsAccepted() bool { - return ir.URI != "" && !ir.AcceptedAt.IsZero() + return !ir.AcceptedAt.IsZero() } // IsRejected returns true if this // interaction request has been rejected. func (ir *InteractionRequest) IsRejected() bool { - return ir.URI != "" && !ir.RejectedAt.IsZero() + return !ir.RejectedAt.IsZero() } diff --git a/internal/processing/workers/fromfediapi.go b/internal/processing/workers/fromfediapi.go index d3e714674..0d6ec1836 100644 --- a/internal/processing/workers/fromfediapi.go +++ b/internal/processing/workers/fromfediapi.go @@ -20,6 +20,7 @@ package workers import ( "context" "errors" + "net/url" "time" "codeberg.org/gruf/go-kv" @@ -144,6 +145,10 @@ func (p *Processor) ProcessFromFediAPI(ctx context.Context, fMsg *messages.FromF // ACCEPT (pending) ANNOUNCE case ap.ActivityAnnounce: return p.fediAPI.AcceptAnnounce(ctx, fMsg) + + // ACCEPT (remote) REPLY or ANNOUNCE + case ap.ObjectUnknown: + return p.fediAPI.AcceptRemoteStatus(ctx, fMsg) } // REJECT SOMETHING @@ -823,6 +828,60 @@ func (p *fediAPI) AcceptReply(ctx context.Context, fMsg *messages.FromFediAPI) e return nil } +func (p *fediAPI) AcceptRemoteStatus(ctx context.Context, fMsg *messages.FromFediAPI) error { + // See if we can accept a remote + // status we don't have stored yet. + objectIRI, ok := fMsg.APObject.(*url.URL) + if !ok { + return gtserror.Newf("%T not parseable as *url.URL", fMsg.APObject) + } + + acceptIRI := fMsg.APIRI + if acceptIRI == nil { + return gtserror.New("acceptIRI was nil") + } + + // Assume we're accepting a status; create a + // barebones status for dereferencing purposes. + bareStatus := >smodel.Status{ + URI: objectIRI.String(), + ApprovedByURI: acceptIRI.String(), + } + + // Call RefreshStatus() to process the provided + // barebones status and insert it into the database, + // if indeed it's actually a status URI we can fetch. + // + // This will also check whether the given AcceptIRI + // actually grants permission for this status. + status, _, err := p.federate.RefreshStatus(ctx, + fMsg.Receiving.Username, + bareStatus, + nil, nil, + ) + if err != nil { + return gtserror.Newf("error processing accepted status %s: %w", bareStatus.URI, err) + } + + // No error means it was indeed a remote status, and the + // given acceptIRI permitted it. Timeline and notify it. + if err := p.surface.timelineAndNotifyStatus(ctx, status); err != nil { + log.Errorf(ctx, "error timelining and notifying status: %v", err) + } + + // Interaction counts changed on the interacted status; + // uncache the prepared version from all timelines. + if status.InReplyToID != "" { + p.surface.invalidateStatusFromTimelines(ctx, status.InReplyToID) + } + + if status.BoostOfID != "" { + p.surface.invalidateStatusFromTimelines(ctx, status.BoostOfID) + } + + return nil +} + func (p *fediAPI) AcceptAnnounce(ctx context.Context, fMsg *messages.FromFediAPI) error { boost, ok := fMsg.GTSModel.(*gtsmodel.Status) if !ok { diff --git a/internal/typeutils/internaltoas.go b/internal/typeutils/internaltoas.go index d317d6f39..d9d18e1c7 100644 --- a/internal/typeutils/internaltoas.go +++ b/internal/typeutils/internaltoas.go @@ -1988,6 +1988,16 @@ func (c *Converter) InteractionReqToASAccept( return nil, gtserror.Newf("invalid interacting account uri: %w", err) } + publicIRI, err := url.Parse(pub.PublicActivityPubIRI) + if err != nil { + return nil, gtserror.Newf("invalid public uri: %w", err) + } + + followersIRI, err := url.Parse(req.TargetAccount.FollowersURI) + if err != nil { + return nil, gtserror.Newf("invalid followers uri: %w", err) + } + // Set id to the URI of // interaction request. ap.SetJSONLDId(accept, acceptID) @@ -2003,6 +2013,9 @@ func (c *Converter) InteractionReqToASAccept( // of interaction URI. ap.AppendTo(accept, toIRI) + // Cc to the actor's followers, and to Public. + ap.AppendCc(accept, publicIRI, followersIRI) + return accept, nil } @@ -2034,6 +2047,16 @@ func (c *Converter) InteractionReqToASReject( return nil, gtserror.Newf("invalid interacting account uri: %w", err) } + publicIRI, err := url.Parse(pub.PublicActivityPubIRI) + if err != nil { + return nil, gtserror.Newf("invalid public uri: %w", err) + } + + followersIRI, err := url.Parse(req.TargetAccount.FollowersURI) + if err != nil { + return nil, gtserror.Newf("invalid followers uri: %w", err) + } + // Set id to the URI of // interaction request. ap.SetJSONLDId(reject, rejectID) @@ -2049,5 +2072,8 @@ func (c *Converter) InteractionReqToASReject( // of interaction URI. ap.AppendTo(reject, toIRI) + // Cc to the actor's followers, and to Public. + ap.AppendCc(reject, publicIRI, followersIRI) + return reject, nil } diff --git a/internal/typeutils/internaltoas_test.go b/internal/typeutils/internaltoas_test.go index f10685aee..d0ed4204c 100644 --- a/internal/typeutils/internaltoas_test.go +++ b/internal/typeutils/internaltoas_test.go @@ -1181,6 +1181,10 @@ func (suite *InternalToASTestSuite) TestInteractionReqToASAccept() { suite.Equal(`{ "@context": "https://www.w3.org/ns/activitystreams", "actor": "http://localhost:8080/users/the_mighty_zork", + "cc": [ + "https://www.w3.org/ns/activitystreams#Public", + "http://localhost:8080/users/the_mighty_zork/followers" + ], "id": "http://localhost:8080/users/the_mighty_zork/accepts/01J1AKMZ8JE5NW0ZSFTRC1JJNE", "object": "https://fossbros-anonymous.io/users/foss_satan/statuses/01J1AKRRHQ6MDDQHV0TP716T2K", "to": "http://fossbros-anonymous.io/users/foss_satan",