🐸 Frogue(Fed)

~ololduck/Frogue

Raw

//! ActivityPub inbox handler for processing incoming activities.
//!
//! The inbox receives activities from remote actors: Follow, Undo, Create, etc.
//! Each activity is verified (HTTP signature) and then processed according to
//! its type.

use actix_web::{HttpRequest, HttpResponse};
use chrono::{DateTime, Utc};
use sea_orm::{
    ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, ModelTrait, QueryFilter, Set,
};
use tracing::{debug, error, info, warn};
use uuid::Uuid;

use crate::activitypub::remote;
use crate::activitypub::signatures::{self, SignatureError};
use crate::activitypub::types::{InboxActivity, ObjectRef, ReactionActivity, UndoObject};
use crate::entities::prelude::{ApFollowers, ApRemoteActors, ApRemoteNotes};
use crate::entities::{ap_followers, ap_remote_actors, ap_remote_notes};

/// Handle an incoming inbox POST request.
///
/// Verifies the HTTP signature, parses the activity, and dispatches to the
/// appropriate handler based on activity type.
pub async fn handle(
    db: &DatabaseConnection,
    http_client: &reqwest_middleware::ClientWithMiddleware,
    public_url: &str,
    signer: &crate::entities::ap_actors::Model,
    req: &HttpRequest,
    body: &[u8],
) -> actix_web::HttpResponse {
    // Verify HTTP signature
    let verified_actor_url = match verify_inbox_request(db, http_client, req, body).await {
        Ok(actor_url) => actor_url,
        Err(signatures::SignatureError::ActorGone(key_id)) => {
            info!(
                key_id = %key_id,
                path = %req.uri().path(),
                "actor is gone (410), rejecting delivery"
            );
            return actix_web::HttpResponse::Gone().finish();
        }
        Err(e) => {
            warn!(error = %e, path = %req.uri().path(), "inbox signature verification failed");
            error!(
                error = %e,
                method = %req.method(),
                path = %req.uri().path(),
                headers = ?req.headers().keys().collect::<Vec<_>>(),
                "signature verification details"
            );
            return actix_web::HttpResponse::Unauthorized().finish();
        }
    };

    // Parse the activity
    let activity: InboxActivity = match serde_json::from_slice(body) {
        Ok(a) => a,
        Err(e) => {
            warn!(error = %e, "failed to parse inbox activity");
            return actix_web::HttpResponse::BadRequest().finish();
        }
    };

    // Verify actor attribution: the activity's actor field must match the verified actor URL
    let activity_actor: Option<&str> = match &activity {
        InboxActivity::Follow(f) => Some(f.actor.as_str()),
        InboxActivity::Undo(u) => Some(u.actor.as_str()),
        InboxActivity::Like(l) => Some(l.actor.as_str()),
        InboxActivity::Announce(a) => Some(a.actor.as_str()),
        InboxActivity::EmojiReact(e) => Some(e.actor.as_str()),
        InboxActivity::Accept(a) => Some(a.actor.as_str()),
        InboxActivity::Reject(r) => Some(r.actor.as_str()),
        InboxActivity::Create(c) => Some(c.actor.as_str()),
        InboxActivity::Update(u) => Some(u.actor.as_str()),
        InboxActivity::Delete(d) => Some(d.actor.as_str()),
        InboxActivity::Offer(o) => Some(o.actor.as_str()),
        InboxActivity::Resolve(r) => Some(r.actor.as_str()),
        InboxActivity::Edit(e) => Some(e.actor.as_str()),
        InboxActivity::Add(a) => Some(a.actor.as_str()),
        InboxActivity::Unknown => None,
    };

    if let Some(actor) = activity_actor
        && actor != verified_actor_url
    {
        warn!(
            activity_actor = %actor,
            verified_actor = %verified_actor_url,
            "actor mismatch"
        );
        return actix_web::HttpResponse::Unauthorized().finish();
    }

    // Process the activity
    match activity {
        InboxActivity::Follow(follow) => {
            handle_follow(db, http_client, public_url, &verified_actor_url, &follow).await
        }
        InboxActivity::Undo(undo) => handle_undo(db, &verified_actor_url, &undo).await,
        InboxActivity::Create(create) => {
            // Repository actors handle Create activities for comments
            if signer.repo_id.is_some() {
                return crate::activitypub::forge::inbound::handle_create(
                    db,
                    http_client,
                    public_url,
                    signer,
                    &verified_actor_url,
                    &create,
                )
                .await;
            }

            // User actors handle Create activities for notes
            if signer.user_id.is_none() {
                debug!("non-repository actor received Create activity, ignoring");
                return HttpResponse::Accepted().finish();
            }

            // Get user name for building URLs
            let user_name =
                match crate::activitypub::actor::actor_url_for(db, public_url, signer).await {
                    Ok(Some(url)) => {
                        // Extract username from URL: {base}/~{user}
                        let prefix =
                            format!("{}/~", crate::activitypub::ApUrls::new(public_url).base());
                        url.strip_prefix(&prefix).unwrap_or("").to_string()
                    }
                    _ => return HttpResponse::Accepted().finish(),
                };

            match crate::activitypub::remote::notes::handle_create(
                db,
                http_client,
                &verified_actor_url,
                public_url,
                &user_name,
                signer,
                &create,
            )
            .await
            {
                Ok(()) => HttpResponse::Accepted().finish(),
                Err(e) => {
                    warn!(error = %e, "failed to handle Create");
                    HttpResponse::Accepted().finish()
                }
            }
        }
        InboxActivity::Update(update) => {
            // Only user actors handle Update activities for notes
            if signer.user_id.is_none() {
                debug!("repository actor received Update activity, ignoring");
                return HttpResponse::Accepted().finish();
            }

            // Get user name for building URLs
            let user_name =
                match crate::activitypub::actor::actor_url_for(db, public_url, signer).await {
                    Ok(Some(url)) => {
                        // Extract username from URL: {base}/~{user}
                        let prefix =
                            format!("{}/~", crate::activitypub::ApUrls::new(public_url).base());
                        url.strip_prefix(&prefix).unwrap_or("").to_string()
                    }
                    _ => return HttpResponse::Accepted().finish(),
                };

            match crate::activitypub::remote::notes::handle_update(
                db,
                http_client,
                &verified_actor_url,
                public_url,
                &user_name,
                signer,
                &update,
            )
            .await
            {
                Ok(()) => HttpResponse::Accepted().finish(),
                Err(e) => {
                    warn!(error = %e, "failed to handle Update");
                    HttpResponse::Accepted().finish()
                }
            }
        }
        InboxActivity::Delete(delete) => {
            match crate::activitypub::remote::notes::handle_delete(db, &verified_actor_url, &delete)
                .await
            {
                Ok(()) => HttpResponse::Accepted().finish(),
                Err(e) => {
                    warn!(error = %e, "failed to handle Delete");
                    HttpResponse::Accepted().finish()
                }
            }
        }
        // Phase 5: Reactions
        InboxActivity::Like(like) => {
            handle_reaction(
                db,
                http_client,
                public_url,
                &verified_actor_url,
                &like,
                "like",
                None,
            )
            .await
        }
        InboxActivity::Announce(announce) => {
            // Check if the Announce contains an embedded Note from an accepted followee
            if let crate::activitypub::types::AnnounceObject::Embedded(embedded) = &announce.object
            {
                // Only store if it's a Note
                if embedded.kind == "Note" {
                    let object_id = embedded.id.clone();
                    let content = embedded.content.clone().unwrap_or_default();
                    let published = if let Some(pub_str) = &embedded.published {
                        match pub_str.parse::<DateTime<Utc>>() {
                            Ok(dt) => dt,
                            Err(e) => {
                                warn!(error = %e, "failed to parse published timestamp");
                                Utc::now()
                            }
                        }
                    } else {
                        Utc::now()
                    };

                    // Check if we already have this note stored (no-op if so)
                    let existing = match ApRemoteNotes::find()
                        .filter(ap_remote_notes::Column::ObjectUrl.eq(&object_id))
                        .one(db)
                        .await
                    {
                        Ok(result) => result,
                        Err(e) => {
                            warn!(error = %e, "failed to check for existing note");
                            return HttpResponse::Accepted().finish();
                        }
                    };

                    if existing.is_some() {
                        debug!(object_url = %object_id, "note already stored (Announce no-op)");
                        return HttpResponse::Accepted().finish();
                    }

                    // Store the note using the shared function
                    match crate::activitypub::remote::notes::store_note(
                        db,
                        &announce.actor,
                        object_id,
                        content,
                        published.into(),
                        "Note",
                    )
                    .await
                    {
                        Ok(()) => {
                            info!(actor_url = %announce.actor, "stored boosted note");
                            HttpResponse::Accepted().finish()
                        }
                        Err(e) => {
                            warn!(error = %e, "failed to store boosted note");
                            HttpResponse::Accepted().finish()
                        }
                    }
                } else {
                    // Not a Note, treat as reaction on local note
                    handle_reaction(
                        db,
                        http_client,
                        public_url,
                        &verified_actor_url,
                        &ReactionActivity {
                            id: announce.id.clone(),
                            actor: announce.actor.clone(),
                            object: ObjectRef::Url(announce.object.id().to_string()),
                            content: None,
                        },
                        "announce",
                        None,
                    )
                    .await
                }
            } else {
                // URL-only Announce, treat as reaction on local note
                handle_reaction(
                    db,
                    http_client,
                    public_url,
                    &verified_actor_url,
                    &ReactionActivity {
                        id: announce.id.clone(),
                        actor: announce.actor.clone(),
                        object: ObjectRef::Url(announce.object.id().to_string()),
                        content: None,
                    },
                    "announce",
                    None,
                )
                .await
            }
        }
        InboxActivity::EmojiReact(react) => {
            handle_reaction(
                db,
                http_client,
                public_url,
                &verified_actor_url,
                &react,
                "emoji_react",
                react.content.as_deref(),
            )
            .await
        }
        InboxActivity::Accept(accept) => {
            // First, check if this is an Accept for a remote ticket Offer
            let offer_id = accept.object.id().to_string();
            info!(offer_id = %offer_id, signer_user_id = ?signer.user_id, "processing Accept activity");
            if let Some(user_id) = signer.user_id {
                info!(user_id = %user_id, offer_id = %offer_id, "looking for remote ticket");
                if let Ok(Some(remote_ticket)) = crate::entities::ap_remote_tickets::Entity::find()
                    .filter(
                        crate::entities::ap_remote_tickets::Column::OfferActivityUrl.eq(&offer_id),
                    )
                    .filter(crate::entities::ap_remote_tickets::Column::AuthorId.eq(user_id))
                    .one(db)
                    .await
                {
                    info!(remote_ticket_id = %remote_ticket.id, "found remote ticket, updating status");
                    // Update the remote ticket status to accepted
                    let mut active_model: crate::entities::ap_remote_tickets::ActiveModel =
                        remote_ticket.into();
                    active_model.status = Set("accepted".to_string());
                    if let Some(result_url) = &accept.result {
                        active_model.assigned_url = Set(Some(result_url.clone()));
                    }
                    if let Err(e) = active_model.update(db).await {
                        warn!(error = %e, offer_id = %offer_id, "failed to update remote ticket status");
                    } else {
                        info!(offer_id = %offer_id, "remote ticket accepted");
                    }
                    return HttpResponse::Accepted().finish();
                } else {
                    info!(offer_id = %offer_id, user_id = %user_id, "no remote ticket found");
                }
            } else {
                info!("signer has no user_id");
            }

            // Otherwise, handle as a Follow Accept
            match crate::activitypub::following::handle_accept(db, &verified_actor_url, &accept)
                .await
            {
                Ok(()) => HttpResponse::Accepted().finish(),
                Err(e) => {
                    warn!(error = %e, "failed to handle Accept");
                    HttpResponse::Accepted().finish()
                }
            }
        }
        InboxActivity::Reject(reject) => {
            // First, check if this is a Reject for a remote ticket Offer
            let offer_id = reject.object.id().to_string();
            if let Some(user_id) = signer.user_id
                && let Ok(Some(remote_ticket)) = crate::entities::ap_remote_tickets::Entity::find()
                    .filter(
                        crate::entities::ap_remote_tickets::Column::OfferActivityUrl.eq(&offer_id),
                    )
                    .filter(crate::entities::ap_remote_tickets::Column::AuthorId.eq(user_id))
                    .one(db)
                    .await
            {
                // Update the remote ticket status to rejected
                let mut active_model: crate::entities::ap_remote_tickets::ActiveModel =
                    remote_ticket.into();
                active_model.status = Set("rejected".to_string());
                if let Err(e) = active_model.update(db).await {
                    warn!(error = %e, offer_id = %offer_id, "failed to update remote ticket status");
                } else {
                    info!(offer_id = %offer_id, "remote ticket rejected");
                }
                return HttpResponse::Accepted().finish();
            }

            // Otherwise, handle as a Follow Reject
            match crate::activitypub::following::handle_reject(db, &verified_actor_url, &reject)
                .await
            {
                Ok(()) => HttpResponse::Accepted().finish(),
                Err(e) => {
                    warn!(error = %e, "failed to handle Reject");
                    HttpResponse::Accepted().finish()
                }
            }
        }
        // ForgeFed activities - only handled by repository actors
        InboxActivity::Offer(offer) => {
            // Only repository actors handle Offer activities
            if signer.repo_id.is_none() {
                debug!("user actor received Offer activity, ignoring");
                return HttpResponse::Accepted().finish();
            }

            crate::activitypub::forge::inbound::handle_offer(
                db,
                http_client,
                public_url,
                signer,
                &verified_actor_url,
                &offer,
            )
            .await
        }
        InboxActivity::Resolve(resolve) => {
            // Only repository actors handle Resolve activities
            if signer.repo_id.is_none() {
                debug!("user actor received Resolve activity, ignoring");
                return HttpResponse::Accepted().finish();
            }

            crate::activitypub::forge::inbound::handle_resolve(
                db,
                http_client,
                public_url,
                signer,
                &verified_actor_url,
                &resolve,
            )
            .await
        }
        InboxActivity::Edit(edit) => {
            // Only repository actors handle Edit activities
            if signer.repo_id.is_none() {
                debug!("user actor received Edit activity, ignoring");
                return HttpResponse::Accepted().finish();
            }

            crate::activitypub::forge::inbound::handle_edit(
                db,
                http_client,
                public_url,
                signer,
                &verified_actor_url,
                &edit,
            )
            .await
        }
        InboxActivity::Add(add) => {
            // Only repository actors handle Add activities
            if signer.repo_id.is_none() {
                debug!("user actor received Add activity, ignoring");
                return HttpResponse::Accepted().finish();
            }

            crate::activitypub::forge::inbound::handle_add(
                db,
                public_url,
                signer,
                &verified_actor_url,
                &add,
            )
            .await
        }
        InboxActivity::Unknown => {
            debug!("received unknown activity type");
            actix_web::HttpResponse::Accepted().finish()
        }
    }
}

/// Handle a reaction (Like, Announce, EmojiReact) on a local note.
async fn handle_reaction(
    db: &DatabaseConnection,
    http_client: &reqwest_middleware::ClientWithMiddleware,
    public_url: &str,
    actor_url: &str,
    reaction: &crate::activitypub::types::ReactionActivity,
    kind: &str,
    emoji: Option<&str>,
) -> HttpResponse {
    // Extract the note URL from the object
    let note_url = reaction.object.id();

    // Parse the note URL to extract the UUID
    // Expected format: {base}/~{user}/notes/{uuid}
    let urls = crate::activitypub::ApUrls::new(public_url);
    let base = urls.base();

    if !note_url.starts_with(base) {
        debug!(note_url, "reaction on non-local note");
        return HttpResponse::Accepted().finish();
    }

    // Extract UUID from the note URL
    let uuid_str = match note_url.strip_prefix(&format!(
        "{}/notes/",
        urls.actor(&extract_user_from_url(note_url, base))
    )) {
        Some(uuid) => uuid,
        None => {
            debug!(note_url, "could not extract UUID from note URL");
            return HttpResponse::Accepted().finish();
        }
    };

    let note_uuid = match uuid::Uuid::parse_str(uuid_str) {
        Ok(uuid) => uuid,
        Err(_) => {
            debug!(uuid_str, "invalid UUID in note URL");
            return HttpResponse::Accepted().finish();
        }
    };

    // Find the note
    let note = match crate::entities::prelude::ApNotes::find()
        .filter(crate::entities::ap_notes::Column::Uuid.eq(note_uuid))
        .one(db)
        .await
    {
        Ok(Some(note)) => note,
        Ok(None) => {
            debug!(note_uuid = %note_uuid, "note not found");
            return HttpResponse::Accepted().finish();
        }
        Err(e) => {
            error!(error = %e, "failed to look up note");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Resolve the remote actor
    let remote_actor = match remote::resolve_actor(db, http_client, actor_url).await {
        Ok(actor) => actor,
        Err(e) => {
            error!(error = %e, actor_url, "failed to resolve remote actor");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Insert the reaction (idempotent via unique constraint)
    let reaction_model = crate::entities::ap_reactions::ActiveModel {
        note_id: Set(Some(note.id)),
        kind: Set(kind.to_string()),
        emoji: Set(emoji.map(|s| s.to_string())),
        remote_actor_id: Set(Some(remote_actor.id)),
        local_user_id: Set(None),
        activity_id: Set(Some(reaction.id.clone())),
        ..Default::default()
    };

    match reaction_model.insert(db).await {
        Ok(_) => {
            info!(
                actor_url = %actor_url,
                note_uuid = %note_uuid,
                kind = %kind,
                "recorded reaction"
            );
            HttpResponse::Accepted().finish()
        }
        Err(e) if e.to_string().contains("UNIQUE constraint failed") => {
            debug!(
                actor_url = %actor_url,
                note_uuid = %note_uuid,
                kind = %kind,
                "reaction already recorded"
            );
            HttpResponse::Accepted().finish()
        }
        Err(e) => {
            error!(error = %e, "failed to insert reaction");
            HttpResponse::InternalServerError().finish()
        }
    }
}

/// Extract the username from a note URL.
fn extract_user_from_url(note_url: &str, base: &str) -> String {
    // Expected format: {base}/~{user}/notes/{uuid}
    let suffix = &note_url[base.len()..];
    if let Some(user_part) = suffix.strip_prefix("/~")
        && let Some(user) = user_part.split('/').next()
    {
        return user.to_string();
    }
    String::new()
}

/// Verify the HTTP signature on an inbox request.
async fn verify_inbox_request(
    db: &DatabaseConnection,
    http_client: &reqwest_middleware::ClientWithMiddleware,
    req: &HttpRequest,
    body: &[u8],
) -> Result<String, SignatureError> {
    let method = req.method().as_str();

    // Extract scheme, authority, path, and query from the request
    let conn_info = req.connection_info();
    let scheme = conn_info.scheme();
    let authority = conn_info.host();

    let path = req.uri().path();
    let query = req.uri().query();

    signatures::verify_request(
        scheme,
        authority,
        path,
        query,
        method,
        req.headers(),
        body,
        |key_id| async move {
            match remote::resolve_key(db, http_client, &key_id).await {
                Ok(Some((actor_url, public_key_pem))) => Ok(Some((actor_url, public_key_pem))),
                Ok(None) => Ok(None),
                Err(remote::ResolveError::Gone) => {
                    Err(signatures::SignatureError::ActorGone(key_id.clone()))
                }
                Err(e) => {
                    error!(key_id = %key_id, error = %e, "failed to resolve key");
                    Ok(None)
                }
            }
        },
    )
    .await
}

/// Handle a Follow activity.
async fn handle_follow(
    db: &DatabaseConnection,
    http_client: &reqwest_middleware::ClientWithMiddleware,
    public_url: &str,
    actor_url: &str,
    follow: &crate::activitypub::types::FollowActivity,
) -> HttpResponse {
    // The object should be one of our local actors
    let target_actor_url = match &follow.object {
        crate::activitypub::types::ObjectRef::Url(url) => url.as_str(),
        crate::activitypub::types::ObjectRef::Embedded(emb) => emb.id.as_str(),
    };

    tracing::info!(
        target = "handle_follow",
        target_actor_url,
        actor_url,
        "processing follow"
    );

    // Find the local actor
    let local_actor =
        match crate::activitypub::actor::get_by_url(db, target_actor_url, public_url).await {
            Ok(Some(actor)) => actor,
            Ok(None) => {
                tracing::warn!(target_actor_url, "follow target not found");
                return HttpResponse::NotFound().finish();
            }
            Err(e) => {
                tracing::error!(error = %e, "failed to look up local actor");
                return HttpResponse::InternalServerError().finish();
            }
        };

    tracing::info!(local_actor_id = local_actor.id, "found local actor");

    // Resolve or fetch the remote actor
    let remote_actor = match remote::resolve_actor(db, http_client, actor_url).await {
        Ok(actor) => actor,
        Err(e) => {
            tracing::error!(error = %e, actor_url, "failed to resolve remote actor");
            return HttpResponse::InternalServerError().finish();
        }
    };

    tracing::info!(remote_actor_id = remote_actor.id, "resolved remote actor");

    // Check if we already have this follow
    let existing = ApFollowers::find()
        .filter(ap_followers::Column::LocalActorId.eq(local_actor.id))
        .filter(ap_followers::Column::RemoteActorId.eq(remote_actor.id))
        .one(db)
        .await;

    match existing {
        Ok(Some(_)) => {
            debug!("follow already recorded");
            HttpResponse::Accepted().finish()
        }
        Ok(None) => {
            // Insert the follow
            let follow_model = ap_followers::ActiveModel {
                local_actor_id: Set(local_actor.id),
                remote_actor_id: Set(remote_actor.id),
                follow_activity_id: Set(follow.id.clone()),
                ..Default::default()
            };

            match follow_model.insert(db).await {
                Ok(_) => {
                    info!(
                        local_actor_url = target_actor_url,
                        remote_actor_url = actor_url,
                        "recorded follow"
                    );

                    // Enqueue Accept(Follow) activity for delivery
                    if let Err(e) = enqueue_accept_follow(
                        db,
                        public_url,
                        &local_actor,
                        &remote_actor,
                        &follow.id,
                    )
                    .await
                    {
                        error!(error = %e, "failed to enqueue Accept(Follow)");
                    }

                    HttpResponse::Accepted().finish()
                }
                Err(e) => {
                    error!(error = %e, "failed to insert follow");
                    HttpResponse::InternalServerError().finish()
                }
            }
        }
        Err(e) => {
            error!(error = %e, "failed to check existing follow");
            HttpResponse::InternalServerError().finish()
        }
    }
}

/// Handle an Undo activity.
async fn handle_undo(
    db: &DatabaseConnection,
    actor_url: &str,
    undo: &crate::activitypub::types::UndoActivity,
) -> HttpResponse {
    let undone_object = match undo.object.as_ref() {
        UndoObject::Embedded(emb) => emb,
        UndoObject::Url(url) => {
            // For now, we only handle embedded objects
            debug!(url, "undo of URL reference not supported");
            return HttpResponse::Accepted().finish();
        }
    };

    let undone_type = undone_object.kind.as_str();
    let undone_id = undone_object.id.as_str();

    match undone_type {
        "Follow" => {
            // Find the remote actor
            let remote_actor = match ApRemoteActors::find()
                .filter(ap_remote_actors::Column::ActorUrl.eq(actor_url))
                .one(db)
                .await
            {
                Ok(Some(actor)) => actor,
                Ok(None) => {
                    debug!(actor_url, "undo from unknown remote actor");
                    return HttpResponse::Accepted().finish();
                }
                Err(e) => {
                    error!(error = %e, "failed to look up remote actor");
                    return HttpResponse::InternalServerError().finish();
                }
            };

            // Find and delete the follow
            let follow = ApFollowers::find()
                .filter(ap_followers::Column::FollowActivityId.eq(undone_id))
                .filter(ap_followers::Column::RemoteActorId.eq(remote_actor.id))
                .one(db)
                .await;

            match follow {
                Ok(Some(f)) => match f.delete(db).await {
                    Ok(_) => {
                        info!(
                            follow_activity_id = undone_id,
                            remote_actor_url = actor_url,
                            "removed follow via undo"
                        );
                        HttpResponse::Accepted().finish()
                    }
                    Err(e) => {
                        error!(error = %e, "failed to delete follow");
                        HttpResponse::InternalServerError().finish()
                    }
                },
                Ok(None) => {
                    debug!(follow_activity_id = undone_id, "undo of unknown follow");
                    HttpResponse::Accepted().finish()
                }
                Err(e) => {
                    error!(error = %e, "failed to look up follow");
                    HttpResponse::InternalServerError().finish()
                }
            }
        }
        "Like" | "Announce" | "EmojiReact" => {
            // Find the remote actor
            let remote_actor = match ApRemoteActors::find()
                .filter(ap_remote_actors::Column::ActorUrl.eq(actor_url))
                .one(db)
                .await
            {
                Ok(Some(actor)) => actor,
                Ok(None) => {
                    debug!(actor_url, "undo from unknown remote actor");
                    return HttpResponse::Accepted().finish();
                }
                Err(e) => {
                    error!(error = %e, "failed to look up remote actor");
                    return HttpResponse::InternalServerError().finish();
                }
            };

            // Find and delete the reaction by activity_id
            let reaction = crate::entities::prelude::ApReactions::find()
                .filter(crate::entities::ap_reactions::Column::ActivityId.eq(undone_id))
                .filter(crate::entities::ap_reactions::Column::RemoteActorId.eq(remote_actor.id))
                .one(db)
                .await;

            match reaction {
                Ok(Some(r)) => match r.delete(db).await {
                    Ok(_) => {
                        info!(
                            activity_id = undone_id,
                            remote_actor_url = actor_url,
                            kind = undone_type,
                            "removed reaction via undo"
                        );
                        HttpResponse::Accepted().finish()
                    }
                    Err(e) => {
                        error!(error = %e, "failed to delete reaction");
                        HttpResponse::InternalServerError().finish()
                    }
                },
                Ok(None) => {
                    debug!(activity_id = undone_id, "undo of unknown reaction");
                    HttpResponse::Accepted().finish()
                }
                Err(e) => {
                    error!(error = %e, "failed to look up reaction");
                    HttpResponse::InternalServerError().finish()
                }
            }
        }
        "Resolve" => {
            // Find the ticket that was closed by this Resolve activity
            let ticket = crate::entities::prelude::Tickets::find()
                .filter(crate::entities::tickets::Column::ResolveActivityUrl.eq(undone_id))
                .one(db)
                .await;

            match ticket {
                Ok(Some(t)) => {
                    // Reopen the ticket
                    match crate::tickets::store::update_ticket(
                        db,
                        t.id,
                        &t.summary,
                        t.description.as_deref(),
                        crate::tickets::State::Open,
                        None, // Clear the resolve activity URL
                    )
                    .await
                    {
                        Ok(_) => {
                            info!(
                                ticket_id = t.id,
                                resolve_activity_id = undone_id,
                                "reopened ticket via undo of resolve"
                            );
                            HttpResponse::Accepted().finish()
                        }
                        Err(e) => {
                            error!(error = %e, "failed to reopen ticket");
                            HttpResponse::InternalServerError().finish()
                        }
                    }
                }
                Ok(None) => {
                    debug!(resolve_activity_id = undone_id, "undo of unknown resolve");
                    HttpResponse::Accepted().finish()
                }
                Err(e) => {
                    error!(error = %e, "failed to look up ticket");
                    HttpResponse::InternalServerError().finish()
                }
            }
        }
        _ => {
            debug!(undone_type, "undo of unsupported activity type");
            HttpResponse::Accepted().finish()
        }
    }
}

/// Enqueue an Accept(Follow) activity for delivery to the remote actor's inbox.
async fn enqueue_accept_follow(
    db: &DatabaseConnection,
    public_url: &str,
    local_actor: &crate::entities::ap_actors::Model,
    remote_actor: &crate::entities::ap_remote_actors::Model,
    follow_id: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    let urls = crate::activitypub::ApUrls::new(public_url);

    // Get the actor URL
    let actor_url = crate::activitypub::actor::actor_url_for(db, public_url, local_actor)
        .await?
        .ok_or("actor not found")?;

    // Generate activity URL - need to handle both user and repo actors
    let activity_uuid = Uuid::new_v4();
    let activity_url = if let Some(user_id) = local_actor.user_id {
        // User actor
        let user = crate::entities::prelude::Users::find_by_id(user_id)
            .one(db)
            .await?
            .ok_or("user not found")?;
        urls.activity(&user.name, activity_uuid)
    } else if let Some(repo_id) = local_actor.repo_id {
        // Repository actor
        let repo = crate::entities::prelude::Repositories::find_by_id(repo_id)
            .one(db)
            .await?
            .ok_or("repository not found")?;
        let owner = crate::entities::prelude::Users::find_by_id(repo.owner_id)
            .one(db)
            .await?
            .ok_or("repository owner not found")?;
        urls.repo_activity(&owner.name, &repo.slug, activity_uuid)
    } else {
        return Err("actor has neither user_id nor repo_id".into());
    };

    // Build the Accept(Follow) activity
    let accept_activity = serde_json::json!({
        "@context": "https://www.w3.org/ns/activitystreams",
        "id": activity_url,
        "type": "Accept",
        "actor": actor_url,
        "object": {
            "id": follow_id,
            "type": "Follow",
            "actor": remote_actor.actor_url,
            "object": actor_url
        }
    });

    let activity_json = serde_json::to_string(&accept_activity)?;

    // Use the shared enqueue_delivery helper
    crate::activitypub::delivery::enqueue_delivery(
        db,
        local_actor.id,
        activity_uuid,
        activity_json,
        remote_actor.actor_url.clone(),
        remote_actor.inbox_url.clone(),
    )
    .await?;

    info!(
        inbox_url = %remote_actor.inbox_url,
        "enqueued Accept(Follow) for delivery"
    );

    Ok(())
}