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..8fada5587 100644 --- a/internal/federation/dereferencing/status_permitted.go +++ b/internal/federation/dereferencing/status_permitted.go @@ -116,30 +116,14 @@ func (d *Dereferencer) isPermittedReply( status *gtsmodel.Status, ) (bool, error) { var ( - statusURI = status.URI // Definitely set. - inReplyToURI = status.InReplyToURI // Definitely set. - inReplyTo = status.InReplyTo // Might not yet be set. + statusURI = status.URI // Definitely set. + inReplyToURI = status.InReplyToURI // Definitely set. + inReplyTo = status.InReplyTo // Might not yet be set. + acceptIRI = status.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,66 +132,111 @@ 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 status. + thisReq, 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 + } - // 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, + status, + thisReq, + parentReq, + ) + } + // Parent wasn't rejected. Check if this + // status 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 } + // Status 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 status + // was not explicitly rejected before; worst- + // case, the status stays on the instance for + // a couple hours until we try to deref it + // again and realize it should be forbidden. + return !thisRejected, nil } 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 } + // We have the replied-to status stored, + // and it's not a boost or anything weird. + + // If this reply claims to be approved, + // validate this by dereferencing the + // Accept and checking the return value. + // + // If the approval checks out, then we + // don't need to do further checks here. + // If it doesn't check out, we also don't + // need to do further checks. + if acceptIRI != "" { + permitted, err := d.isPermittedByAcceptIRI( + ctx, + requestUser, + acceptIRI, + statusURI, + inReplyTo.AccountURI, + ) + 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. + err := gtserror.Newf("undereferencable ApprovedByURI: %w", err) + return false, err + } + + if !permitted { + // It's a no, squirt. + return false, nil + } + + // Status is permitted by the Accept. + // If it was previously rejected or + // pending approval, clear that now. + status.PendingApproval = util.Ptr(false) + if thisReq != nil { + + } + } + + // Status doesn't claim to be approved, + // so further checks are required. + // Check visibility of local // inReplyTo to replying account. if inReplyTo.IsLocal() { @@ -302,27 +331,79 @@ func (d *Dereferencer) isPermittedReply( return false, nil } - // Status claims to be approved, check - // this by dereferencing the Accept and - // inspecting the return value. - if err := d.validateApprovedBy( - ctx, - requestUser, - status.ApprovedByURI, - statusURI, - inReplyTo.AccountURI, - ); err != nil { + return true, 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) - return false, nil +// unpermittedByParent marks the given status 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, + status *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 } - // Status has been approved. - status.PendingApproval = util.Ptr(false) - return true, nil + if thisReq != nil { + // This interaction request is + // not yet marked as rejected, + // do this before we return. + thisReq.RejectedAt = time.Now() + thisReq.AcceptedAt = time.Time{} + err := d.state.DB.UpdateInteractionRequest( + ctx, + thisReq, + "rejected_at", + "accepted_at", + ) + if err != nil && !errors.Is(err, db.ErrAlreadyExists) { + return gtserror.Newf("db error updating interaction request: %w", err) + } + } + + // We haven't stored a rejected interaction + // request for this status yet, do it now. + id := 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. + statusID := 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: id, + StatusID: statusID, + TargetAccountID: targetAccountID, + InteractingAccountID: status.AccountID, + InteractionURI: status.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 } func (d *Dereferencer) isPermittedBoost( @@ -418,18 +499,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.isPermittedByAcceptIRI( 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 +523,59 @@ func (d *Dereferencer) isPermittedBoost( return true, nil } -// validateApprovedBy dereferences the activitystreams Accept at -// the specified IRI, and checks the Accept for validity against +// isPermittedByAcceptIRI 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 +// dereffed Accept did not meet expectations. +func (d *Dereferencer) isPermittedByAcceptIRI( 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 +584,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 +656,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..64cec3485 100644 --- a/internal/federation/federatingdb/accept.go +++ b/internal/federation/federatingdb/accept.go @@ -24,6 +24,7 @@ "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,17 @@ func (f *federatingDB) Accept(ctx context.Context, accept vocab.ActivityStreamsA return err } - // UNHANDLED + // ACCEPT OTHER (reply? boost?) default: - log.Debugf(ctx, "unhandled iri type: %s", objIRI) + if err := f.acceptOtherIRI( + ctx, + activityID, + objIRI, + receivingAcct, + requestingAcct, + ); err != nil { + return err + } } } } @@ -276,98 +287,6 @@ func (f *federatingDB) acceptFollowIRI( return nil } -func (f *federatingDB) acceptStatusIRI( - ctx context.Context, - activityID string, - objectIRI string, - 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) - 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. - return nil - } - - if !status.IsLocal() { - // We don't process Accepts of statuses - // that weren't created on our instance. - // Just return. - return nil - } - - pendingApproval := util.PtrOrValue(status.PendingApproval, false) - if !pendingApproval { - // Status doesn't need approval or it's - // already been approved by an Accept. - // Just return. - 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 && - status.InReplyToAccountID != requestingAcct.ID { - const text = "status reply to or boost of account and requesting account were not the same" - return gtserror.NewErrorForbidden(errors.New(text), text) - } - - // Mark the status as approved by this Accept URI. - status.PendingApproval = util.Ptr(false) - status.ApprovedByURI = activityID - if err := f.state.DB.UpdateStatus( - ctx, - status, - "pending_approval", - "approved_by_uri", - ); err != nil { - err := gtserror.Newf("db error accepting status: %w", err) - return gtserror.NewErrorInternalError(err) - } - - var apObjectType string - if status.InReplyToID != "" { - // Accepting a Reply. - apObjectType = ap.ObjectNote - } else { - // Accepting an Announce. - apObjectType = ap.ActivityAnnounce - } - - // Send the now-approved status through to the - // fedi worker again to process side effects. - f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{ - APObjectType: apObjectType, - APActivityType: ap.ActivityAccept, - GTSModel: status, - Receiving: receivingAcct, - Requesting: requestingAcct, - }) - - return nil -} - func (f *federatingDB) acceptLikeIRI( ctx context.Context, activityID string, @@ -449,3 +368,136 @@ func (f *federatingDB) acceptLikeIRI( return nil } + +func (f *federatingDB) acceptOtherIRI( + ctx context.Context, + activityID *url.URL, + objectIRI *url.URL, + receivingAcct *gtsmodel.Account, + requestingAcct *gtsmodel.Account, +) error { + // 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 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 + } + + // 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 + } + + // We do follow the requester, so perhaps they're + // Accepting a reply to one of their statuses. + // Pass to the processor and let them handle it. + f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{ + APObjectType: ap.ObjectNote, // We assume it's a status. + 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 + // already been approved by an Accept. + // Just return. + return nil + } + + // Make sure the target of the interaction (reply/boost) + // is the same as the account doing the Accept. + if status.BoostOfAccountID != requestingAcct.ID && + status.InReplyToAccountID != requestingAcct.ID { + const text = "status reply to or boost of account and requesting account were not the same" + return gtserror.NewErrorForbidden(errors.New(text), text) + } + + // Mark the status as approved by this Accept URI. + status.PendingApproval = util.Ptr(false) + status.ApprovedByURI = activityID.String() + if err := f.state.DB.UpdateStatus( + ctx, + status, + "pending_approval", + "approved_by_uri", + ); err != nil { + err := gtserror.Newf("db error accepting status: %w", err) + return gtserror.NewErrorInternalError(err) + } + + var apObjectType string + if status.InReplyToID != "" { + // Accepting a Reply. + apObjectType = ap.ObjectNote + } else { + // Accepting an Announce. + apObjectType = ap.ActivityAnnounce + } + + // Send the now-approved status through to the + // fedi worker again to process side effects. + f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{ + APObjectType: apObjectType, + APActivityType: ap.ActivityAccept, + GTSModel: status, + Receiving: receivingAcct, + Requesting: requestingAcct, + }) + + return nil +} 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..6fd3ac945 100644 --- a/internal/processing/workers/fromfediapi.go +++ b/internal/processing/workers/fromfediapi.go @@ -20,6 +20,7 @@ import ( "context" "errors" + "net/url" "time" "codeberg.org/gruf/go-kv" @@ -796,11 +797,20 @@ func (p *fediAPI) AcceptLike(ctx context.Context, fMsg *messages.FromFediAPI) er } func (p *fediAPI) AcceptReply(ctx context.Context, fMsg *messages.FromFediAPI) error { + // Check if accepting our status or some + // other status we already had stored locally. status, ok := fMsg.GTSModel.(*gtsmodel.Status) - if !ok { - return gtserror.Newf("%T not parseable as *gtsmodel.Status", fMsg.GTSModel) + if ok { + // We had status stored, accept it. + return p.acceptStoredReply(ctx, status) } + // Not accepting something we had stored locally, + // try to dereference accepted object by IRI. + return p.acceptReplyIRI(ctx, fMsg) +} + +func (p *fediAPI) acceptStoredReply(ctx context.Context, status *gtsmodel.Status) error { // Update stats for the actor account. if err := p.utils.incrementStatusesCount(ctx, status.Account, status); err != nil { log.Errorf(ctx, "error updating account stats: %v", err) @@ -811,7 +821,8 @@ func (p *fediAPI) AcceptReply(ctx context.Context, fMsg *messages.FromFediAPI) e log.Errorf(ctx, "error timelining and notifying status: %v", err) } - // Send out the reply again, fully this time. + // Send out the reply again, fully this + // time (no-op for statuses that aren't ours). if err := p.federate.CreateStatus(ctx, status); err != nil { log.Errorf(ctx, "error federating announce: %v", err) } @@ -823,6 +834,40 @@ func (p *fediAPI) AcceptReply(ctx context.Context, fMsg *messages.FromFediAPI) e return nil } +func (p *fediAPI) acceptReplyIRI(ctx context.Context, fMsg *messages.FromFediAPI) error { + // See if we can accept a remote status + // that 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") + } + + bareStatus := >smodel.Status{ + URI: objectIRI.String(), + ApprovedByURI: acceptIRI.String(), + } + + // Call RefreshStatus() to parse and process the provided + // statusable model, which it will use to further flesh out + // the bare bones model and insert it into the database. + _, _, err := p.federate.RefreshStatus(ctx, + fMsg.Receiving.Username, + bareStatus, + nil, nil, + ) + if err != nil { + return gtserror.Newf("error processing new status %s: %w", bareStatus.URI, err) + } + + 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",