🐸 Frogue(Fed)

~ololduck/Frogue

Raw

//! Federation endpoints: ActivityPub actor and webfinger discovery.
//!
//! The actor route uses content negotiation via the AcceptAp guard: requests
//! with `Accept: application/activity+json` or `application/ld+json` are
//! routed to the AP actor; browsers fall through to the HTML profile.

use std::sync::Arc;

use actix_web::{HttpRequest, HttpResponse, get, guard, post, web};
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use serde::Deserialize;
use tokio::sync::RwLock;
use tracing::error;

use crate::AppState;
use crate::activitypub::{self, ApUrls, types};
use crate::auth::CurrentUser;
use crate::entities::prelude::{ApFollowers, ApRemoteActors, Repositories, Users};
use crate::entities::repositories;
use crate::entities::users;
use crate::entities::{ap_followers, ap_remote_actors};

use crate::state_read;

/// Guard function for ActivityPub content negotiation.
///
/// Returns true when the Accept header contains `application/activity+json` or
/// `application/ld+json` (Mastodon sends the latter with a profile parameter).
pub fn accept_ap() -> impl guard::Guard {
    guard::fn_guard(|req| {
        req.head()
            .headers()
            .get("accept")
            .and_then(|v| v.to_str().ok())
            .map(|accept| {
                accept.contains("application/activity+json")
                    || accept.contains("application/ld+json")
            })
            .unwrap_or(false)
    })
}

#[derive(Deserialize)]
struct WebfingerQuery {
    resource: Option<String>,
}

/// GET /.well-known/webfinger
#[get("/.well-known/webfinger")]
#[tracing::instrument(skip_all)]
pub async fn get_webfinger(
    data: web::Data<Arc<RwLock<AppState>>>,
    query: web::Query<WebfingerQuery>,
) -> HttpResponse {
    let state = state_read(&data).await;
    if !activitypub::enabled(&state) {
        return HttpResponse::NotFound().finish();
    }

    let resource = match &query.resource {
        Some(r) => r.clone(),
        None => return HttpResponse::BadRequest().body("missing resource parameter"),
    };

    let public_url = state.public_url().to_string();
    let db = state.db.clone();
    drop(state);

    activitypub::webfinger::webfinger(&db, &resource, &public_url).await
}

/// GET /~{user} with ActivityPub Accept header
#[tracing::instrument(skip_all, fields(user_or_team = %user_or_team.as_str()))]
pub async fn get_actor(
    data: web::Data<Arc<RwLock<AppState>>>,
    user_or_team: web::Path<String>,
) -> HttpResponse {
    let state = state_read(&data).await;
    if !activitypub::enabled(&state) {
        return HttpResponse::NotFound().finish();
    }

    let user = match Users::find()
        .filter(users::Column::Name.eq(user_or_team.as_str()))
        .filter(users::Column::DeletedAt.is_null())
        .one(&state.db)
        .await
    {
        Ok(Some(user)) => user,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, user = %user_or_team.as_str(), "looking up the user failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Fetch or create the actor row (keypair generated lazily).
    let actor = match activitypub::actor::get_or_create(&state.db, user.id).await {
        Ok(actor) => actor,
        Err(err) => {
            error!(%err, user = %user.name, "fetching or creating actor failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let urls = ApUrls::new(state.public_url());
    let actor_doc = types::Actor {
        context: types::CONTEXT.to_vec(),
        id: urls.actor(&user.name),
        kind: "Person",
        preferred_username: user.name.clone(),
        name: Some(user.name.clone()),
        summary: user.description.clone(),
        inbox: urls.inbox(&user.name),
        outbox: urls.outbox(&user.name),
        followers: Some(urls.followers(&user.name)),
        public_key: types::PublicKeyDoc {
            id: urls.key_id(&user.name),
            owner: urls.actor(&user.name),
            public_key_pem: actor.public_key_pem,
        },
        attributed_to: None,
        clone_uri: None,
        push_uri: None,
        published: None,
        tickets_tracked_by: None,
        send_patches_to: None,
        tracks_tickets_for: None,
        tracks_patches_for: None,
    };

    HttpResponse::Ok()
        .content_type("application/activity+json; charset=utf-8")
        .json(actor_doc)
}

/// POST /~{user}/inbox
#[tracing::instrument(skip_all, fields(user_or_team))]
pub async fn post_inbox(
    state: web::Data<Arc<RwLock<AppState>>>,
    user_or_team: web::Path<String>,
    req: HttpRequest,
    body: web::Bytes,
) -> HttpResponse {
    let state = state_read(&state).await;
    if !activitypub::enabled(&state) {
        return HttpResponse::NotFound().finish();
    }

    let user = match Users::find()
        .filter(users::Column::Name.eq(user_or_team.as_str()))
        .filter(users::Column::DeletedAt.is_null())
        .one(&state.db)
        .await
    {
        Ok(Some(user)) => user,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, user = %user_or_team.as_str(), "looking up the user failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Fetch or create the actor row (needed for signing GET requests)
    let actor = match activitypub::actor::get_or_create(&state.db, user.id).await {
        Ok(actor) => actor,
        Err(err) => {
            error!(%err, user = %user.name, "fetching or creating actor failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    activitypub::inbox::handle(
        &state.db,
        state.http_client(),
        state.public_url(),
        &actor,
        &req,
        &body,
    )
    .await
}

/// GET /~{user}/followers
#[get("/~{user_or_team}/followers")]
#[tracing::instrument(skip_all, fields(user_or_team = %user_or_team.as_str()))]
pub async fn get_followers(
    state: web::Data<Arc<RwLock<AppState>>>,
    user_or_team: web::Path<String>,
) -> HttpResponse {
    let state = state_read(&state).await;
    if !activitypub::enabled(&state) {
        return HttpResponse::NotFound().finish();
    }

    let user = match Users::find()
        .filter(users::Column::Name.eq(user_or_team.as_str()))
        .filter(users::Column::DeletedAt.is_null())
        .one(&state.db)
        .await
    {
        Ok(Some(user)) => user,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, user = %user_or_team.as_str(), "looking up the user failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Get the local actor
    let actor = match activitypub::actor::get_or_create(&state.db, user.id).await {
        Ok(actor) => actor,
        Err(err) => {
            error!(%err, user = %user.name, "fetching actor failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Count followers and get their actor URLs
    let followers: Vec<ap_remote_actors::Model> = match ApFollowers::find()
        .filter(ap_followers::Column::LocalActorId.eq(actor.id))
        .find_also_related(ApRemoteActors)
        .all(&state.db)
        .await
    {
        Ok(followers) => followers
            .into_iter()
            .filter_map(|(_, remote)| remote)
            .collect(),
        Err(err) => {
            error!(%err, user = %user.name, "fetching followers failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let urls = ApUrls::new(state.public_url());
    let follower_urls: Vec<String> = followers.iter().map(|f| f.actor_url.clone()).collect();

    let collection = types::OrderedCollection {
        context: types::CONTEXT.to_vec(),
        id: urls.followers(&user.name),
        kind: "OrderedCollection",
        total_items: follower_urls.len() as u64,
        first: None,
        ordered_items: if follower_urls.is_empty() {
            None
        } else {
            Some(
                follower_urls
                    .into_iter()
                    .map(serde_json::Value::String)
                    .collect(),
            )
        },
    };

    HttpResponse::Ok()
        .content_type("application/activity+json; charset=utf-8")
        .json(collection)
}

#[derive(serde::Deserialize)]
pub struct OutboxQuery {
    page: Option<u64>,
}

#[get("/~{user_or_team}/outbox")]
#[tracing::instrument(skip_all, fields(user_or_team = %user_or_team.as_str()))]
pub async fn get_outbox(
    data: web::Data<Arc<RwLock<AppState>>>,
    user_or_team: web::Path<String>,
    query: web::Query<OutboxQuery>,
) -> HttpResponse {
    let state = state_read(&data).await;
    if !activitypub::enabled(&state) {
        return HttpResponse::NotFound().finish();
    }

    let user = match Users::find()
        .filter(users::Column::Name.eq(user_or_team.as_str()))
        .filter(users::Column::DeletedAt.is_null())
        .one(&state.db)
        .await
    {
        Ok(Some(user)) => user,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, user = %user_or_team.as_str(), "looking up the user failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let actor = match activitypub::actor::get_or_create(&state.db, user.id).await {
        Ok(actor) => actor,
        Err(err) => {
            error!(%err, user = %user.name, "getting actor failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let urls = ApUrls::new(state.public_url());
    let outbox_url = urls.outbox(&user.name);

    match activitypub::outbox::get_outbox(&state.db, actor.id, &outbox_url, query.page).await {
        Ok(collection) => HttpResponse::Ok()
            .content_type("application/activity+json; charset=utf-8")
            .json(collection),
        Err(err) => {
            error!(%err, user = %user.name, "fetching outbox failed");
            HttpResponse::InternalServerError().finish()
        }
    }
}

pub async fn get_note(
    data: web::Data<Arc<RwLock<AppState>>>,
    path: web::Path<(String, String)>,
) -> HttpResponse {
    let (user_or_team, uuid_str) = path.into_inner();
    let state = state_read(&data).await;
    if !activitypub::enabled(&state) {
        return HttpResponse::NotFound().finish();
    }

    let uuid = match uuid::Uuid::parse_str(&uuid_str) {
        Ok(uuid) => uuid,
        Err(_) => return HttpResponse::NotFound().finish(),
    };

    let user = match Users::find()
        .filter(users::Column::Name.eq(user_or_team.as_str()))
        .filter(users::Column::DeletedAt.is_null())
        .one(&state.db)
        .await
    {
        Ok(Some(user)) => user,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, user = %user_or_team, "looking up the user failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let urls = ApUrls::new(state.public_url());
    let actor_url = urls.actor(&user.name);
    let note_url = urls.note(&user.name, uuid);

    match activitypub::notes::get_note(&state.db, &note_url, &actor_url).await {
        Ok(Some(note)) => HttpResponse::Ok()
            .content_type("application/activity+json; charset=utf-8")
            .json(note),
        Ok(None) => HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, user = %user.name, uuid = %uuid, "fetching note failed");
            HttpResponse::InternalServerError().finish()
        }
    }
}

/// GET /~{user_or_team}/notes/{uuid} with HTML Accept header
pub async fn get_note_html(
    data: web::Data<Arc<RwLock<AppState>>>,
    path: web::Path<(String, String)>,
    viewer: Option<CurrentUser>,
) -> HttpResponse {
    let (user_or_team, uuid_str) = path.into_inner();
    let state = state_read(&data).await;
    if !activitypub::enabled(&state) {
        return HttpResponse::NotFound().finish();
    }

    let uuid = match uuid::Uuid::parse_str(&uuid_str) {
        Ok(uuid) => uuid,
        Err(_) => return HttpResponse::NotFound().finish(),
    };

    let user = match Users::find()
        .filter(users::Column::Name.eq(user_or_team.as_str()))
        .filter(users::Column::DeletedAt.is_null())
        .one(&state.db)
        .await
    {
        Ok(Some(user)) => user,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, user = %user_or_team, "looking up the user failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let actor = match activitypub::actor::get_or_create(&state.db, user.id).await {
        Ok(actor) => actor,
        Err(err) => {
            error!(%err, user = %user.name, "getting actor failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Fetch the note
    let note = match crate::entities::prelude::ApNotes::find()
        .filter(crate::entities::ap_notes::Column::Uuid.eq(uuid))
        .filter(crate::entities::ap_notes::Column::ActorId.eq(actor.id))
        .one(&state.db)
        .await
    {
        Ok(Some(note)) => note,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, user = %user.name, uuid = %uuid, "fetching note failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Get reactions
    let reactions = match crate::activitypub::notes::get_reactions(&state.db, note.id).await {
        Ok(reactions) => reactions,
        Err(err) => {
            error!(%err, note_id = note.id, "loading reactions failed");
            crate::activitypub::notes::NoteReactions {
                likes: 0,
                announces: 0,
                emojis: vec![],
            }
        }
    };

    // Get viewer state
    let (reacted, boosted, is_author) = if let Some(viewer_user) = &viewer {
        let is_author = viewer_user.id == user.id;

        let reacted = match crate::entities::prelude::ApReactions::find()
            .filter(crate::entities::ap_reactions::Column::NoteId.eq(Some(note.id)))
            .filter(crate::entities::ap_reactions::Column::LocalUserId.eq(Some(viewer_user.id)))
            .filter(crate::entities::ap_reactions::Column::Kind.eq("like"))
            .one(&state.db)
            .await
        {
            Ok(Some(_)) => true,
            Ok(None) => false,
            Err(err) => {
                error!(%err, "checking viewer reaction failed");
                false
            }
        };

        let boosted = match crate::entities::prelude::ApReactions::find()
            .filter(crate::entities::ap_reactions::Column::NoteId.eq(Some(note.id)))
            .filter(crate::entities::ap_reactions::Column::LocalUserId.eq(Some(viewer_user.id)))
            .filter(crate::entities::ap_reactions::Column::Kind.eq("announce"))
            .one(&state.db)
            .await
        {
            Ok(Some(_)) => true,
            Ok(None) => false,
            Err(err) => {
                error!(%err, "checking viewer boost failed");
                false
            }
        };

        (reacted, boosted, is_author)
    } else {
        (false, false, false)
    };

    let public_url = state.public_url().to_string();
    drop(state);

    let permalink = format!("{}/~{}/notes/{}", public_url, user.name, uuid);
    let permalink_url = match url::Url::parse(&permalink) {
        Ok(url) => url,
        Err(err) => {
            error!(%err, "parsing permalink failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let author = crate::views::NoteAuthor {
        display_name: user.name.clone(),
        handle: crate::views::NoteAuthorHandle::Local {
            username: user.name.clone(),
        },
    };

    let actions = viewer.as_ref().map(|_| crate::views::NoteActions {
        target: crate::views::ActionTarget::Local {
            user: user.name.clone(),
            uuid: uuid.to_string(),
        },
        reacted,
        boosted,
        is_author,
    });

    let note_component = crate::views::Note {
        author,
        content_html: note.content_html,
        created_at: crate::views::components::DateTime::from(&note.created_at),
        updated_at: None,
        permalink: permalink_url,
        reactions: Some(reactions),
        actions,
    };

    let page = crate::views::NotePage {
        title: format!("Note by @{}", user.name),
        note: note_component,
    };

    crate::views::Page {
        title: format!("Note by @{}", user.name),
        viewer: viewer.as_ref().map(|v| v.name.clone()),
        extra_head: None,
        body: page,
    }
    .into_response()
}

#[derive(serde::Deserialize)]
struct PostNoteForm {
    content: String,
}

#[post("/~{user_or_team}/notes")]
#[tracing::instrument(skip_all, fields(user_or_team = %user_or_team.as_str()))]
pub async fn post_note(
    data: web::Data<Arc<RwLock<AppState>>>,
    user_or_team: web::Path<String>,
    form: web::Form<PostNoteForm>,
    session: actix_session::Session,
) -> HttpResponse {
    let state = state_read(&data).await;
    if !activitypub::enabled(&state) {
        return HttpResponse::NotFound().finish();
    }

    let viewer_name = match session.get::<String>("user_name") {
        Ok(Some(name)) => name,
        Ok(None) => return HttpResponse::Unauthorized().finish(),
        Err(err) => {
            error!(%err, "reading session failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Check if the viewer is the owner
    if viewer_name != *user_or_team {
        return HttpResponse::Forbidden().finish();
    }

    let user = match Users::find()
        .filter(users::Column::Name.eq(user_or_team.as_str()))
        .filter(users::Column::DeletedAt.is_null())
        .one(&state.db)
        .await
    {
        Ok(Some(user)) => user,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, user = %user_or_team, "looking up the user failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let actor = match activitypub::actor::get_or_create(&state.db, user.id).await {
        Ok(actor) => actor,
        Err(err) => {
            error!(%err, user = %user.name, "getting actor failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let urls = ApUrls::new(state.public_url());
    let actor_url = urls.actor(&user.name);

    match activitypub::notes::publish(
        &state.db,
        actor.id,
        &actor_url,
        state.public_url(),
        &form.content,
    )
    .await
    {
        Ok(_note) => {
            let profile_url = format!("/~{}", user.name);
            HttpResponse::SeeOther()
                .insert_header(("Location", profile_url))
                .finish()
        }
        Err(err) => {
            error!(%err, user = %user.name, "publishing note failed");
            HttpResponse::InternalServerError().finish()
        }
    }
}

#[post("/~{user_or_team}/notes/{uuid}/react")]
pub async fn post_react(
    data: web::Data<Arc<RwLock<AppState>>>,
    path: web::Path<(String, String)>,
    session: actix_session::Session,
) -> HttpResponse {
    let (user_or_team, uuid_str) = path.into_inner();
    let state = state_read(&data).await;
    if !activitypub::enabled(&state) {
        return HttpResponse::NotFound().finish();
    }

    let viewer_name = match session.get::<String>("user_name") {
        Ok(Some(name)) => name,
        Ok(None) => return HttpResponse::Unauthorized().finish(),
        Err(err) => {
            error!(%err, "reading session failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let uuid = match uuid::Uuid::parse_str(&uuid_str) {
        Ok(uuid) => uuid,
        Err(_) => return HttpResponse::NotFound().finish(),
    };

    let user = match Users::find()
        .filter(users::Column::Name.eq(user_or_team.as_str()))
        .filter(users::Column::DeletedAt.is_null())
        .one(&state.db)
        .await
    {
        Ok(Some(user)) => user,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, user = %user_or_team, "looking up the user failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let viewer = match Users::find()
        .filter(users::Column::Name.eq(viewer_name.as_str()))
        .filter(users::Column::DeletedAt.is_null())
        .one(&state.db)
        .await
    {
        Ok(Some(user)) => user,
        Ok(None) => return HttpResponse::Unauthorized().finish(),
        Err(err) => {
            error!(%err, "looking up viewer failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Prevent authors from liking their own notes
    if viewer.id == user.id {
        return HttpResponse::Forbidden().body("cannot react to your own note");
    }

    let actor = match activitypub::actor::get_or_create(&state.db, user.id).await {
        Ok(actor) => actor,
        Err(err) => {
            error!(%err, user = %user.name, "getting actor failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Query the note entity directly to get the ID
    let note = match crate::entities::prelude::ApNotes::find()
        .filter(crate::entities::ap_notes::Column::Uuid.eq(uuid))
        .filter(crate::entities::ap_notes::Column::ActorId.eq(actor.id))
        .one(&state.db)
        .await
    {
        Ok(Some(note)) => note,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, user = %user.name, uuid = %uuid, "fetching note failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    use crate::entities::ap_reactions;
    use sea_orm::ActiveModelTrait;
    use sea_orm::Set;

    let reaction = ap_reactions::ActiveModel {
        note_id: Set(Some(note.id)),
        kind: Set("like".to_string()),
        emoji: Set(None),
        remote_actor_id: Set(None),
        local_user_id: Set(Some(viewer.id)),
        activity_id: Set(None),
        ..Default::default()
    };

    match reaction.insert(&state.db).await {
        Ok(_) => {
            let profile_url = format!("/~{}", user.name);
            HttpResponse::SeeOther()
                .insert_header(("Location", profile_url))
                .finish()
        }
        Err(err) if err.to_string().contains("UNIQUE constraint failed") => {
            // Already liked, just redirect
            let profile_url = format!("/~{}", user.name);
            HttpResponse::SeeOther()
                .insert_header(("Location", profile_url))
                .finish()
        }
        Err(err) => {
            error!(%err, "inserting reaction failed");
            HttpResponse::InternalServerError().finish()
        }
    }
}

#[post("/~{user_or_team}/notes/{uuid}/unreact")]
pub async fn post_unreact(
    data: web::Data<Arc<RwLock<AppState>>>,
    path: web::Path<(String, String)>,
    session: actix_session::Session,
) -> HttpResponse {
    let (user_or_team, uuid_str) = path.into_inner();
    let state = state_read(&data).await;
    if !activitypub::enabled(&state) {
        return HttpResponse::NotFound().finish();
    }

    let viewer_name = match session.get::<String>("user_name") {
        Ok(Some(name)) => name,
        Ok(None) => return HttpResponse::Unauthorized().finish(),
        Err(err) => {
            error!(%err, "reading session failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let uuid = match uuid::Uuid::parse_str(&uuid_str) {
        Ok(uuid) => uuid,
        Err(_) => return HttpResponse::NotFound().finish(),
    };

    let user = match Users::find()
        .filter(users::Column::Name.eq(user_or_team.as_str()))
        .filter(users::Column::DeletedAt.is_null())
        .one(&state.db)
        .await
    {
        Ok(Some(user)) => user,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, user = %user_or_team, "looking up the user failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let viewer = match Users::find()
        .filter(users::Column::Name.eq(viewer_name.as_str()))
        .filter(users::Column::DeletedAt.is_null())
        .one(&state.db)
        .await
    {
        Ok(Some(user)) => user,
        Ok(None) => return HttpResponse::Unauthorized().finish(),
        Err(err) => {
            error!(%err, "looking up viewer failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let actor = match activitypub::actor::get_or_create(&state.db, user.id).await {
        Ok(actor) => actor,
        Err(err) => {
            error!(%err, user = %user.name, "getting actor failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Query the note entity directly to get the ID
    let note = match crate::entities::prelude::ApNotes::find()
        .filter(crate::entities::ap_notes::Column::Uuid.eq(uuid))
        .filter(crate::entities::ap_notes::Column::ActorId.eq(actor.id))
        .one(&state.db)
        .await
    {
        Ok(Some(note)) => note,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, user = %user.name, uuid = %uuid, "fetching note failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    use crate::entities::ap_reactions;
    use sea_orm::ModelTrait;

    let reaction = match crate::entities::prelude::ApReactions::find()
        .filter(ap_reactions::Column::NoteId.eq(note.id))
        .filter(ap_reactions::Column::LocalUserId.eq(Some(viewer.id)))
        .filter(ap_reactions::Column::Kind.eq("like"))
        .one(&state.db)
        .await
    {
        Ok(Some(reaction)) => reaction,
        Ok(None) => {
            // No reaction to remove, just redirect
            let profile_url = format!("/~{}", user.name);
            return HttpResponse::SeeOther()
                .insert_header(("Location", profile_url))
                .finish();
        }
        Err(err) => {
            error!(%err, "finding reaction failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    match reaction.delete(&state.db).await {
        Ok(_) => {
            let profile_url = format!("/~{}", user.name);
            HttpResponse::SeeOther()
                .insert_header(("Location", profile_url))
                .finish()
        }
        Err(err) => {
            error!(%err, "deleting reaction failed");
            HttpResponse::InternalServerError().finish()
        }
    }
}

#[post("/~{user_or_team}/notes/{uuid}/announce")]
pub async fn post_announce(
    data: web::Data<Arc<RwLock<AppState>>>,
    path: web::Path<(String, String)>,
    session: actix_session::Session,
) -> HttpResponse {
    let (user_or_team, uuid_str) = path.into_inner();
    let state = state_read(&data).await;
    if !activitypub::enabled(&state) {
        return HttpResponse::NotFound().finish();
    }

    let viewer_name = match session.get::<String>("user_name") {
        Ok(Some(name)) => name,
        Ok(None) => return HttpResponse::Unauthorized().finish(),
        Err(err) => {
            error!(%err, "reading session failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let uuid = match uuid::Uuid::parse_str(&uuid_str) {
        Ok(uuid) => uuid,
        Err(_) => return HttpResponse::NotFound().finish(),
    };

    let user = match Users::find()
        .filter(users::Column::Name.eq(user_or_team.as_str()))
        .filter(users::Column::DeletedAt.is_null())
        .one(&state.db)
        .await
    {
        Ok(Some(user)) => user,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, user = %user_or_team, "looking up the user failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let viewer = match Users::find()
        .filter(users::Column::Name.eq(viewer_name.as_str()))
        .filter(users::Column::DeletedAt.is_null())
        .one(&state.db)
        .await
    {
        Ok(Some(user)) => user,
        Ok(None) => return HttpResponse::Unauthorized().finish(),
        Err(err) => {
            error!(%err, "looking up viewer failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let actor = match activitypub::actor::get_or_create(&state.db, user.id).await {
        Ok(actor) => actor,
        Err(err) => {
            error!(%err, user = %user.name, "getting actor failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Query the note entity directly to get the ID
    let note = match crate::entities::prelude::ApNotes::find()
        .filter(crate::entities::ap_notes::Column::Uuid.eq(uuid))
        .filter(crate::entities::ap_notes::Column::ActorId.eq(actor.id))
        .one(&state.db)
        .await
    {
        Ok(Some(note)) => note,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, user = %user.name, uuid = %uuid, "fetching note failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    use crate::entities::ap_reactions;
    use sea_orm::ActiveModelTrait;
    use sea_orm::Set;

    let reaction = ap_reactions::ActiveModel {
        note_id: Set(Some(note.id)),
        kind: Set("announce".to_string()),
        emoji: Set(None),
        remote_actor_id: Set(None),
        local_user_id: Set(Some(viewer.id)),
        activity_id: Set(None),
        ..Default::default()
    };

    match reaction.insert(&state.db).await {
        Ok(_) => {
            let profile_url = format!("/~{}", user.name);
            HttpResponse::SeeOther()
                .insert_header(("Location", profile_url))
                .finish()
        }
        Err(err) if err.to_string().contains("UNIQUE constraint failed") => {
            // Already announced, just redirect
            let profile_url = format!("/~{}", user.name);
            HttpResponse::SeeOther()
                .insert_header(("Location", profile_url))
                .finish()
        }
        Err(err) => {
            error!(%err, "inserting announce failed");
            HttpResponse::InternalServerError().finish()
        }
    }
}

#[post("/~{user_or_team}/notes/{uuid}/unannounce")]
pub async fn post_unannounce(
    data: web::Data<Arc<RwLock<AppState>>>,
    path: web::Path<(String, String)>,
    session: actix_session::Session,
) -> HttpResponse {
    let (user_or_team, uuid_str) = path.into_inner();
    let state = state_read(&data).await;
    if !activitypub::enabled(&state) {
        return HttpResponse::NotFound().finish();
    }

    let viewer_name = match session.get::<String>("user_name") {
        Ok(Some(name)) => name,
        Ok(None) => return HttpResponse::Unauthorized().finish(),
        Err(err) => {
            error!(%err, "reading session failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let uuid = match uuid::Uuid::parse_str(&uuid_str) {
        Ok(uuid) => uuid,
        Err(_) => return HttpResponse::NotFound().finish(),
    };

    let user = match Users::find()
        .filter(users::Column::Name.eq(user_or_team.as_str()))
        .filter(users::Column::DeletedAt.is_null())
        .one(&state.db)
        .await
    {
        Ok(Some(user)) => user,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, user = %user_or_team, "looking up the user failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let viewer = match Users::find()
        .filter(users::Column::Name.eq(viewer_name.as_str()))
        .filter(users::Column::DeletedAt.is_null())
        .one(&state.db)
        .await
    {
        Ok(Some(user)) => user,
        Ok(None) => return HttpResponse::Unauthorized().finish(),
        Err(err) => {
            error!(%err, "looking up viewer failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let actor = match activitypub::actor::get_or_create(&state.db, user.id).await {
        Ok(actor) => actor,
        Err(err) => {
            error!(%err, user = %user.name, "getting actor failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Query the note entity directly to get the ID
    let note = match crate::entities::prelude::ApNotes::find()
        .filter(crate::entities::ap_notes::Column::Uuid.eq(uuid))
        .filter(crate::entities::ap_notes::Column::ActorId.eq(actor.id))
        .one(&state.db)
        .await
    {
        Ok(Some(note)) => note,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, user = %user.name, uuid = %uuid, "fetching note failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    use crate::entities::ap_reactions;

    let delete_result = ap_reactions::Entity::delete_many()
        .filter(ap_reactions::Column::NoteId.eq(Some(note.id)))
        .filter(ap_reactions::Column::Kind.eq("announce"))
        .filter(ap_reactions::Column::LocalUserId.eq(Some(viewer.id)))
        .exec(&state.db)
        .await;

    match delete_result {
        Ok(_) => {
            let profile_url = format!("/~{}", user.name);
            HttpResponse::SeeOther()
                .insert_header(("Location", profile_url))
                .finish()
        }
        Err(err) => {
            error!(%err, "deleting announce failed");
            HttpResponse::InternalServerError().finish()
        }
    }
}

/// POST /remote-notes/{id}/react
///
/// React to a remote note with a Like activity.
/// Sends the activity to the remote actor's inbox and stores it locally.
#[post("/remote-notes/{id}/react")]
#[tracing::instrument(skip_all, fields(remote_note_id = %id))]
pub async fn post_react_remote(
    id: web::Path<i64>,
    data: web::Data<Arc<RwLock<AppState>>>,
    session: actix_session::Session,
) -> HttpResponse {
    let remote_note_id = id.into_inner();
    let state = state_read(&data).await;
    if !activitypub::enabled(&state) {
        return HttpResponse::NotFound().finish();
    }

    let viewer_name = match session.get::<String>("user_name") {
        Ok(Some(name)) => name,
        Ok(None) => return HttpResponse::Unauthorized().finish(),
        Err(err) => {
            error!(%err, "reading session failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let viewer = match Users::find()
        .filter(users::Column::Name.eq(viewer_name.as_str()))
        .filter(users::Column::DeletedAt.is_null())
        .one(&state.db)
        .await
    {
        Ok(Some(user)) => user,
        Ok(None) => return HttpResponse::Unauthorized().finish(),
        Err(err) => {
            error!(%err, "looking up viewer failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let actor = match activitypub::actor::get_or_create(&state.db, viewer.id).await {
        Ok(actor) => actor,
        Err(err) => {
            error!(%err, user = %viewer.name, "getting actor failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Fetch the remote note
    let remote_note = match crate::entities::prelude::ApRemoteNotes::find_by_id(remote_note_id)
        .one(&state.db)
        .await
    {
        Ok(Some(note)) => note,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, "fetching remote note failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Fetch the remote actor who authored the note
    let remote_actor =
        match crate::entities::prelude::ApRemoteActors::find_by_id(remote_note.remote_actor_id)
            .one(&state.db)
            .await
        {
            Ok(Some(actor)) => actor,
            Ok(None) => return HttpResponse::NotFound().finish(),
            Err(err) => {
                error!(%err, "fetching remote actor failed");
                return HttpResponse::InternalServerError().finish();
            }
        };

    let urls = ApUrls::new(state.public_url());
    let activity_uuid = uuid::Uuid::new_v4();
    let activity_id = urls.activity(&viewer.name, activity_uuid);

    // Build the Like activity
    let like_activity = serde_json::json!({
        "@context": "https://www.w3.org/ns/activitystreams",
        "id": activity_id,
        "type": "Like",
        "actor": urls.actor(&viewer.name),
        "object": remote_note.object_url
    });

    // Enqueue delivery to the remote actor's inbox
    let inbox_url = remote_actor
        .shared_inbox_url
        .as_deref()
        .unwrap_or(&remote_actor.inbox_url);

    if let Err(err) = activitypub::delivery::enqueue_delivery(
        &state.db,
        actor.id,
        activity_uuid,
        serde_json::to_string(&like_activity).unwrap(),
        remote_note.object_url.clone(),
        inbox_url.to_string(),
    )
    .await
    {
        error!(%err, "enqueuing delivery failed");
        return HttpResponse::InternalServerError().finish();
    }

    // Store the reaction locally
    use crate::entities::ap_reactions;
    use sea_orm::ActiveModelTrait;
    use sea_orm::Set;

    let reaction = ap_reactions::ActiveModel {
        note_id: Set(None),
        remote_note_id: Set(Some(remote_note_id)),
        remote_note_object_url: Set(Some(remote_note.object_url.clone())),
        kind: Set("like".to_string()),
        emoji: Set(None),
        remote_actor_id: Set(None),
        local_user_id: Set(Some(viewer.id)),
        activity_id: Set(Some(activity_id.clone())),
        ..Default::default()
    };

    match reaction.insert(&state.db).await {
        Ok(_) => {
            // Redirect back to the home feed
            HttpResponse::SeeOther()
                .insert_header(("Location", "/"))
                .finish()
        }
        Err(err) if err.to_string().contains("UNIQUE constraint failed") => {
            // Already liked, just redirect
            HttpResponse::SeeOther()
                .insert_header(("Location", "/"))
                .finish()
        }
        Err(err) => {
            error!(%err, "inserting reaction failed");
            HttpResponse::InternalServerError().finish()
        }
    }
}

/// POST /remote-notes/{id}/unreact
///
/// Remove a reaction from a remote note by sending an Undo(Like) activity.
#[post("/remote-notes/{id}/unreact")]
#[tracing::instrument(skip_all, fields(remote_note_id = %id))]
pub async fn post_unreact_remote(
    id: web::Path<i64>,
    data: web::Data<Arc<RwLock<AppState>>>,
    session: actix_session::Session,
) -> HttpResponse {
    let remote_note_id = id.into_inner();
    let state = state_read(&data).await;
    if !activitypub::enabled(&state) {
        return HttpResponse::NotFound().finish();
    }

    let viewer_name = match session.get::<String>("user_name") {
        Ok(Some(name)) => name,
        Ok(None) => return HttpResponse::Unauthorized().finish(),
        Err(err) => {
            error!(%err, "reading session failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let viewer = match Users::find()
        .filter(users::Column::Name.eq(viewer_name.as_str()))
        .filter(users::Column::DeletedAt.is_null())
        .one(&state.db)
        .await
    {
        Ok(Some(user)) => user,
        Ok(None) => return HttpResponse::Unauthorized().finish(),
        Err(err) => {
            error!(%err, "looking up viewer failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let actor = match activitypub::actor::get_or_create(&state.db, viewer.id).await {
        Ok(actor) => actor,
        Err(err) => {
            error!(%err, user = %viewer.name, "getting actor failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Fetch the remote note
    let remote_note = match crate::entities::prelude::ApRemoteNotes::find_by_id(remote_note_id)
        .one(&state.db)
        .await
    {
        Ok(Some(note)) => note,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, "fetching remote note failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Fetch the remote actor who authored the note
    let remote_actor =
        match crate::entities::prelude::ApRemoteActors::find_by_id(remote_note.remote_actor_id)
            .one(&state.db)
            .await
        {
            Ok(Some(actor)) => actor,
            Ok(None) => return HttpResponse::NotFound().finish(),
            Err(err) => {
                error!(%err, "fetching remote actor failed");
                return HttpResponse::InternalServerError().finish();
            }
        };

    use crate::entities::ap_reactions;
    use sea_orm::ModelTrait;

    // Find the reaction
    let reaction = match crate::entities::prelude::ApReactions::find()
        .filter(ap_reactions::Column::RemoteNoteId.eq(remote_note_id))
        .filter(ap_reactions::Column::LocalUserId.eq(Some(viewer.id)))
        .filter(ap_reactions::Column::Kind.eq("like"))
        .one(&state.db)
        .await
    {
        Ok(Some(reaction)) => reaction,
        Ok(None) => {
            // No reaction to remove, just redirect
            return HttpResponse::SeeOther()
                .insert_header(("Location", "/"))
                .finish();
        }
        Err(err) => {
            error!(%err, "finding reaction failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // If we have an activity_id, send an Undo(Like) activity
    if let Some(activity_id) = &reaction.activity_id {
        let urls = ApUrls::new(state.public_url());
        let undo_uuid = uuid::Uuid::new_v4();
        let undo_id = urls.activity(&viewer.name, undo_uuid);

        let undo_activity = serde_json::json!({
            "@context": "https://www.w3.org/ns/activitystreams",
            "id": undo_id,
            "type": "Undo",
            "actor": urls.actor(&viewer.name),
            "object": {
                "id": activity_id,
                "type": "Like",
                "actor": urls.actor(&viewer.name),
                "object": remote_note.object_url
            }
        });

        let inbox_url = remote_actor
            .shared_inbox_url
            .as_deref()
            .unwrap_or(&remote_actor.inbox_url);

        if let Err(err) = activitypub::delivery::enqueue_delivery(
            &state.db,
            actor.id,
            undo_uuid,
            serde_json::to_string(&undo_activity).unwrap(),
            remote_note.object_url.clone(),
            inbox_url.to_string(),
        )
        .await
        {
            error!(%err, "enqueuing Undo delivery failed");
            // Continue with deletion anyway
        }
    }

    // Delete the reaction
    match reaction.delete(&state.db).await {
        Ok(_) => HttpResponse::SeeOther()
            .insert_header(("Location", "/"))
            .finish(),
        Err(err) => {
            error!(%err, "deleting reaction failed");
            HttpResponse::InternalServerError().finish()
        }
    }
}

/// POST /remote-notes/{id}/announce
///
/// Announce (boost) a remote note with an Announce activity.
/// Sends the activity to the remote actor's inbox and stores it locally.
#[post("/remote-notes/{id}/announce")]
#[tracing::instrument(skip_all, fields(remote_note_id = %id))]
pub async fn post_announce_remote(
    id: web::Path<i64>,
    data: web::Data<Arc<RwLock<AppState>>>,
    session: actix_session::Session,
) -> HttpResponse {
    let remote_note_id = id.into_inner();
    let state = state_read(&data).await;
    if !activitypub::enabled(&state) {
        return HttpResponse::NotFound().finish();
    }

    let viewer_name = match session.get::<String>("user_name") {
        Ok(Some(name)) => name,
        Ok(None) => return HttpResponse::Unauthorized().finish(),
        Err(err) => {
            error!(%err, "reading session failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let viewer = match Users::find()
        .filter(users::Column::Name.eq(viewer_name.as_str()))
        .filter(users::Column::DeletedAt.is_null())
        .one(&state.db)
        .await
    {
        Ok(Some(user)) => user,
        Ok(None) => return HttpResponse::Unauthorized().finish(),
        Err(err) => {
            error!(%err, "looking up viewer failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let actor = match activitypub::actor::get_or_create(&state.db, viewer.id).await {
        Ok(actor) => actor,
        Err(err) => {
            error!(%err, user = %viewer.name, "getting actor failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Fetch the remote note
    let remote_note = match crate::entities::prelude::ApRemoteNotes::find_by_id(remote_note_id)
        .one(&state.db)
        .await
    {
        Ok(Some(note)) => note,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, "fetching remote note failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Fetch the remote actor who authored the note
    let remote_actor =
        match crate::entities::prelude::ApRemoteActors::find_by_id(remote_note.remote_actor_id)
            .one(&state.db)
            .await
        {
            Ok(Some(actor)) => actor,
            Ok(None) => return HttpResponse::NotFound().finish(),
            Err(err) => {
                error!(%err, "fetching remote actor failed");
                return HttpResponse::InternalServerError().finish();
            }
        };

    let urls = ApUrls::new(state.public_url());
    let activity_uuid = uuid::Uuid::new_v4();
    let activity_id = urls.activity(&viewer.name, activity_uuid);

    // Build the Announce activity
    let announce_activity = serde_json::json!({
        "@context": "https://www.w3.org/ns/activitystreams",
        "id": activity_id,
        "type": "Announce",
        "actor": urls.actor(&viewer.name),
        "object": remote_note.object_url
    });

    // Enqueue delivery to the remote actor's inbox
    let inbox_url = remote_actor
        .shared_inbox_url
        .as_deref()
        .unwrap_or(&remote_actor.inbox_url);

    if let Err(err) = activitypub::delivery::enqueue_delivery(
        &state.db,
        actor.id,
        activity_uuid,
        serde_json::to_string(&announce_activity).unwrap(),
        remote_note.object_url.clone(),
        inbox_url.to_string(),
    )
    .await
    {
        error!(%err, "enqueuing delivery failed");
        return HttpResponse::InternalServerError().finish();
    }

    // Store the reaction locally
    use crate::entities::ap_reactions;
    use sea_orm::ActiveModelTrait;
    use sea_orm::Set;

    let reaction = ap_reactions::ActiveModel {
        note_id: Set(None),
        remote_note_id: Set(Some(remote_note_id)),
        remote_note_object_url: Set(Some(remote_note.object_url.clone())),
        kind: Set("announce".to_string()),
        emoji: Set(None),
        remote_actor_id: Set(None),
        local_user_id: Set(Some(viewer.id)),
        activity_id: Set(Some(activity_id.clone())),
        ..Default::default()
    };

    match reaction.insert(&state.db).await {
        Ok(_) => {
            // Redirect back to the home feed
            HttpResponse::SeeOther()
                .insert_header(("Location", "/"))
                .finish()
        }
        Err(err) if err.to_string().contains("UNIQUE constraint failed") => {
            // Already announced, just redirect
            HttpResponse::SeeOther()
                .insert_header(("Location", "/"))
                .finish()
        }
        Err(err) => {
            error!(%err, "inserting reaction failed");
            HttpResponse::InternalServerError().finish()
        }
    }
}

/// POST /remote-notes/{id}/unannounce
///
/// Remove an announce (unboost) from a remote note by sending an Undo(Announce) activity.
#[post("/remote-notes/{id}/unannounce")]
#[tracing::instrument(skip_all, fields(remote_note_id = %id))]
pub async fn post_unannounce_remote(
    id: web::Path<i64>,
    data: web::Data<Arc<RwLock<AppState>>>,
    session: actix_session::Session,
) -> HttpResponse {
    let remote_note_id = id.into_inner();
    let state = state_read(&data).await;
    if !activitypub::enabled(&state) {
        return HttpResponse::NotFound().finish();
    }

    let viewer_name = match session.get::<String>("user_name") {
        Ok(Some(name)) => name,
        Ok(None) => return HttpResponse::Unauthorized().finish(),
        Err(err) => {
            error!(%err, "reading session failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let viewer = match Users::find()
        .filter(users::Column::Name.eq(viewer_name.as_str()))
        .filter(users::Column::DeletedAt.is_null())
        .one(&state.db)
        .await
    {
        Ok(Some(user)) => user,
        Ok(None) => return HttpResponse::Unauthorized().finish(),
        Err(err) => {
            error!(%err, "looking up viewer failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let actor = match activitypub::actor::get_or_create(&state.db, viewer.id).await {
        Ok(actor) => actor,
        Err(err) => {
            error!(%err, user = %viewer.name, "getting actor failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Fetch the remote note
    let remote_note = match crate::entities::prelude::ApRemoteNotes::find_by_id(remote_note_id)
        .one(&state.db)
        .await
    {
        Ok(Some(note)) => note,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, "fetching remote note failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Fetch the remote actor who authored the note
    let remote_actor =
        match crate::entities::prelude::ApRemoteActors::find_by_id(remote_note.remote_actor_id)
            .one(&state.db)
            .await
        {
            Ok(Some(actor)) => actor,
            Ok(None) => return HttpResponse::NotFound().finish(),
            Err(err) => {
                error!(%err, "fetching remote actor failed");
                return HttpResponse::InternalServerError().finish();
            }
        };

    use crate::entities::ap_reactions;
    use sea_orm::ModelTrait;

    // Find the reaction
    let reaction = match crate::entities::prelude::ApReactions::find()
        .filter(ap_reactions::Column::RemoteNoteId.eq(remote_note_id))
        .filter(ap_reactions::Column::LocalUserId.eq(Some(viewer.id)))
        .filter(ap_reactions::Column::Kind.eq("announce"))
        .one(&state.db)
        .await
    {
        Ok(Some(reaction)) => reaction,
        Ok(None) => {
            // No reaction to remove, just redirect
            return HttpResponse::SeeOther()
                .insert_header(("Location", "/"))
                .finish();
        }
        Err(err) => {
            error!(%err, "finding reaction failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // If we have an activity_id, send an Undo(Announce) activity
    if let Some(activity_id) = &reaction.activity_id {
        let urls = ApUrls::new(state.public_url());
        let undo_uuid = uuid::Uuid::new_v4();
        let undo_id = urls.activity(&viewer.name, undo_uuid);

        let undo_activity = serde_json::json!({
            "@context": "https://www.w3.org/ns/activitystreams",
            "id": undo_id,
            "type": "Undo",
            "actor": urls.actor(&viewer.name),
            "object": {
                "id": activity_id,
                "type": "Announce",
                "actor": urls.actor(&viewer.name),
                "object": remote_note.object_url
            }
        });

        let inbox_url = remote_actor
            .shared_inbox_url
            .as_deref()
            .unwrap_or(&remote_actor.inbox_url);

        if let Err(err) = activitypub::delivery::enqueue_delivery(
            &state.db,
            actor.id,
            undo_uuid,
            serde_json::to_string(&undo_activity).unwrap(),
            remote_note.object_url.clone(),
            inbox_url.to_string(),
        )
        .await
        {
            error!(%err, "enqueuing Undo delivery failed");
            // Continue with deletion anyway
        }
    }

    // Delete the reaction
    match reaction.delete(&state.db).await {
        Ok(_) => HttpResponse::SeeOther()
            .insert_header(("Location", "/"))
            .finish(),
        Err(err) => {
            error!(%err, "deleting reaction failed");
            HttpResponse::InternalServerError().finish()
        }
    }
}

/// POST /~{user_or_team}/follow
///
/// Follow a remote actor by handle or actor URL.
/// If handle is provided, discovery is performed first.
#[post("/~{user_or_team}/follow")]
#[tracing::instrument(skip_all, fields(user_or_team = %user_or_team.as_str()))]
pub async fn post_follow(
    data: web::Data<Arc<RwLock<AppState>>>,
    user_or_team: web::Path<String>,
    viewer: CurrentUser,
    form: web::Form<FollowForm>,
) -> HttpResponse {
    let state = state_read(&data).await;
    if !activitypub::enabled(&state) {
        return HttpResponse::NotFound().finish();
    }

    // Verify the viewer is the owner
    if viewer.name != *user_or_team {
        return HttpResponse::Forbidden().finish();
    }

    let user = match Users::find()
        .filter(users::Column::Name.eq(user_or_team.as_str()))
        .filter(users::Column::DeletedAt.is_null())
        .one(&state.db)
        .await
    {
        Ok(Some(user)) => user,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, user = %user_or_team.as_str(), "looking up the user failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let actor = match activitypub::actor::get_or_create(&state.db, user.id).await {
        Ok(actor) => actor,
        Err(err) => {
            error!(%err, user = %user.name, "getting actor failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Determine the actor URL: either from form or via discovery
    let actor_url = if let Some(handle) = &form.handle {
        // Perform discovery
        match activitypub::remote::discover_by_handle(
            &state.db,
            state.http_client(),
            state.public_url(),
            &actor,
            &user.name,
            handle,
        )
        .await
        {
            Ok(remote_actor) => remote_actor.actor_url,
            Err(err) => {
                error!(%err, user = %user.name, handle = %handle, "discovery failed");
                let error_msg = url::form_urlencoded::byte_serialize(
                    format!("discovery failed: {}", err).as_bytes(),
                )
                .collect::<String>();
                let profile_url = format!("/~{}?error={}", user.name, error_msg);
                return HttpResponse::SeeOther()
                    .insert_header(("Location", profile_url))
                    .finish();
            }
        }
    } else if let Some(actor_url) = &form.actor_url {
        actor_url.clone()
    } else {
        let error_msg = url::form_urlencoded::byte_serialize(b"missing handle or actor_url")
            .collect::<String>();
        let profile_url = format!("/~{}?error={}", user.name, error_msg);
        return HttpResponse::SeeOther()
            .insert_header(("Location", profile_url))
            .finish();
    };

    // Verify the remote actor is cached (i.e., was discovered)
    let remote_actor = match ApRemoteActors::find()
        .filter(ap_remote_actors::Column::ActorUrl.eq(&actor_url))
        .one(&state.db)
        .await
    {
        Ok(Some(remote)) => remote,
        Ok(None) => {
            let error_msg =
                url::form_urlencoded::byte_serialize(b"actor not found").collect::<String>();
            let profile_url = format!("/~{}?error={}", user.name, error_msg);
            return HttpResponse::SeeOther()
                .insert_header(("Location", profile_url))
                .finish();
        }
        Err(err) => {
            error!(%err, actor_url = %actor_url, "looking up remote actor failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Check if already following (idempotent)
    use crate::entities::ap_following;
    use crate::entities::prelude::ApFollowing;
    use sea_orm::ColumnTrait;
    use sea_orm::QueryFilter;

    let existing = ApFollowing::find()
        .filter(ap_following::Column::LocalActorId.eq(actor.id))
        .filter(ap_following::Column::RemoteActorId.eq(remote_actor.id))
        .one(&state.db)
        .await;

    match existing {
        Ok(Some(_)) => {
            // Already following, just redirect (idempotent)
            let notice_msg =
                url::form_urlencoded::byte_serialize(b"already following").collect::<String>();
            let profile_url = format!("/~{}?notice={}", user.name, notice_msg);
            return HttpResponse::SeeOther()
                .insert_header(("Location", profile_url))
                .finish();
        }
        Ok(None) => {
            // Send the follow
            match activitypub::following::send_follow(
                &state.db,
                state.public_url(),
                &actor,
                &remote_actor,
            )
            .await
            {
                Ok(()) => {
                    let notice_msg = url::form_urlencoded::byte_serialize(b"follow request sent")
                        .collect::<String>();
                    let profile_url = format!("/~{}?notice={}", user.name, notice_msg);
                    HttpResponse::SeeOther()
                        .insert_header(("Location", profile_url))
                        .finish()
                }
                Err(err) => {
                    error!(%err, user = %user.name, actor_url = %remote_actor.actor_url, "sending follow failed");
                    let error_msg = url::form_urlencoded::byte_serialize(
                        format!("follow failed: {}", err).as_bytes(),
                    )
                    .collect::<String>();
                    let profile_url = format!("/~{}?error={}", user.name, error_msg);
                    HttpResponse::SeeOther()
                        .insert_header(("Location", profile_url))
                        .finish()
                }
            }
        }
        Err(err) => {
            error!(%err, "checking existing follow failed");
            HttpResponse::InternalServerError().finish()
        }
    }
}

#[derive(Deserialize)]
struct FollowForm {
    handle: Option<String>,
    actor_url: Option<String>,
}

/// POST /~{user_or_team}/unfollow
///
/// Unfollow a remote actor by actor URL.
#[post("/~{user_or_team}/unfollow")]
#[tracing::instrument(skip_all, fields(user_or_team = %user_or_team.as_str()))]
pub async fn post_unfollow(
    data: web::Data<Arc<RwLock<AppState>>>,
    user_or_team: web::Path<String>,
    viewer: CurrentUser,
    form: web::Form<FollowForm>,
) -> HttpResponse {
    let state = state_read(&data).await;
    if !activitypub::enabled(&state) {
        return HttpResponse::NotFound().finish();
    }

    // Verify the viewer is the owner
    if viewer.name != *user_or_team {
        return HttpResponse::Forbidden().finish();
    }

    let user = match Users::find()
        .filter(users::Column::Name.eq(user_or_team.as_str()))
        .filter(users::Column::DeletedAt.is_null())
        .one(&state.db)
        .await
    {
        Ok(Some(user)) => user,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, user = %user_or_team.as_str(), "looking up the user failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let actor_url = match &form.actor_url {
        Some(url) => url.clone(),
        None => {
            let error_msg =
                url::form_urlencoded::byte_serialize(b"actor_url is required").collect::<String>();
            let profile_url = format!("/~{}?error={}", user_or_team.as_str(), error_msg);
            return HttpResponse::SeeOther()
                .insert_header(("Location", profile_url))
                .finish();
        }
    };

    let remote_actor = match ApRemoteActors::find()
        .filter(ap_remote_actors::Column::ActorUrl.eq(&actor_url))
        .one(&state.db)
        .await
    {
        Ok(Some(remote)) => remote,
        Ok(None) => {
            let error_msg =
                url::form_urlencoded::byte_serialize(b"actor not found").collect::<String>();
            let profile_url = format!("/~{}?error={}", user_or_team.as_str(), error_msg);
            return HttpResponse::SeeOther()
                .insert_header(("Location", profile_url))
                .finish();
        }
        Err(err) => {
            error!(%err, actor_url = %actor_url, "looking up remote actor failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let actor = match activitypub::actor::get_or_create(&state.db, user.id).await {
        Ok(actor) => actor,
        Err(err) => {
            error!(%err, user = %user.name, "getting actor failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    match activitypub::following::send_unfollow(
        &state.db,
        state.public_url(),
        &actor,
        &remote_actor,
    )
    .await
    {
        Ok(()) => {
            let profile_url = format!("/~{}", user.name);
            HttpResponse::SeeOther()
                .insert_header(("Location", profile_url))
                .finish()
        }
        Err(err) => {
            error!(%err, user = %user.name, actor_url = %remote_actor.actor_url, "sending unfollow failed");
            HttpResponse::InternalServerError().body(format!("unfollow failed: {}", err))
        }
    }
}

// ============================================================================
// Repository Actor Endpoints
// ============================================================================

/// GET /~{user_or_team}/{repository_slug} with ActivityPub Accept header
/// Serves the repository as a ForgeFed Repository actor.
#[tracing::instrument(skip_all)]
pub async fn get_repo_actor(
    data: web::Data<Arc<RwLock<AppState>>>,
    path: web::Path<(String, String)>,
) -> HttpResponse {
    let (user_or_team, repository_slug) = path.into_inner();
    let state = state_read(&data).await;
    if !activitypub::enabled(&state) {
        return HttpResponse::NotFound().finish();
    }

    // Look up the owner user
    let owner = match Users::find()
        .filter(users::Column::Name.eq(user_or_team.as_str()))
        .filter(users::Column::DeletedAt.is_null())
        .one(&state.db)
        .await
    {
        Ok(Some(user)) => user,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, user = %user_or_team.as_str(), "looking up the user failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Look up the repository (must be public and not deleted for federation)
    let repo = match Repositories::find()
        .filter(repositories::Column::OwnerId.eq(owner.id))
        .filter(repositories::Column::Slug.eq(repository_slug.as_str()))
        .filter(repositories::Column::Visibility.eq(repositories::Visibility::Public))
        .filter(repositories::Column::DeletedAt.is_null())
        .one(&state.db)
        .await
    {
        Ok(Some(repo)) => repo,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, user = %owner.name, repo = %repository_slug.as_str(), "looking up the repository failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Fetch or create the repository actor (keypair generated lazily)
    let actor = match activitypub::actor::get_or_create_for_repo(&state.db, repo.id).await {
        Ok(actor) => actor,
        Err(err) => {
            error!(%err, user = %owner.name, repo = %repository_slug.as_str(), "fetching or creating repository actor failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let urls = ApUrls::new(state.public_url());
    let repo_actor_url = urls.repo_actor(&owner.name, &repository_slug);
    let ssh_host = state.ssh.host.clone();
    let push_uris = urls.repo_push_uris(&owner.name, &repository_slug, &ssh_host);
    let published = repo.created_at.to_rfc3339();

    let actor_doc = types::Actor {
        context: types::FORGEFED_CONTEXT.to_vec(),
        id: repo_actor_url.clone(),
        kind: "Repository",
        preferred_username: repository_slug.clone(),
        name: Some(repository_slug.clone()),
        summary: repo.description.clone(),
        inbox: urls.repo_inbox(&owner.name, &repository_slug),
        outbox: urls.repo_outbox(&owner.name, &repository_slug),
        followers: Some(urls.repo_followers(&owner.name, &repository_slug)),
        public_key: types::PublicKeyDoc {
            id: urls.repo_key_id(&owner.name, &repository_slug),
            owner: repo_actor_url.clone(),
            public_key_pem: actor.public_key_pem,
        },
        attributed_to: Some(urls.actor(&owner.name)),
        clone_uri: Some(urls.repo_clone_uri(&owner.name, &repository_slug)),
        push_uri: Some(push_uris),
        published: Some(published),
        tickets_tracked_by: Some(repo_actor_url.clone()),
        send_patches_to: Some(repo_actor_url.clone()),
        tracks_tickets_for: Some(vec![repo_actor_url.clone()]),
        tracks_patches_for: Some(vec![repo_actor_url]),
    };

    HttpResponse::Ok()
        .content_type("application/activity+json; charset=utf-8")
        .json(actor_doc)
}

/// POST /~{user_or_team}/{repository_slug}/inbox
/// Handles incoming ActivityPub activities for the repository actor.
#[tracing::instrument(skip_all, fields(user_or_team, repository_slug))]
pub async fn post_repo_inbox(
    data: web::Data<Arc<RwLock<AppState>>>,
    path: web::Path<(String, String)>,
    req: HttpRequest,
    body: web::Bytes,
) -> HttpResponse {
    let (user_or_team, repository_slug) = path.into_inner();
    let state = state_read(&data).await;
    if !activitypub::enabled(&state) {
        return HttpResponse::NotFound().finish();
    }

    // Look up the owner user
    let owner = match Users::find()
        .filter(users::Column::Name.eq(user_or_team.as_str()))
        .filter(users::Column::DeletedAt.is_null())
        .one(&state.db)
        .await
    {
        Ok(Some(user)) => user,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, user = %user_or_team.as_str(), "looking up the user failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Look up the repository (must be public)
    let repo = match Repositories::find()
        .filter(repositories::Column::OwnerId.eq(owner.id))
        .filter(repositories::Column::Slug.eq(repository_slug.as_str()))
        .filter(repositories::Column::Visibility.eq(repositories::Visibility::Public))
        .filter(repositories::Column::DeletedAt.is_null())
        .one(&state.db)
        .await
    {
        Ok(Some(repo)) => repo,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, user = %owner.name, repo = %repository_slug.as_str(), "looking up the repositories failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Fetch or create the repository actor
    let actor = match activitypub::actor::get_or_create_for_repo(&state.db, repo.id).await {
        Ok(actor) => actor,
        Err(err) => {
            error!(%err, user = %owner.name, repo = %repository_slug.as_str(), "fetching repository actor failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    activitypub::inbox::handle(
        &state.db,
        state.http_client(),
        state.public_url(),
        &actor,
        &req,
        &body,
    )
    .await
}

/// GET /~{user_or_team}/{repository_slug}/followers
/// Serves the followers collection for the repository actor.
#[tracing::instrument(skip_all)]
pub async fn get_repo_followers(
    data: web::Data<Arc<RwLock<AppState>>>,
    path: web::Path<(String, String)>,
) -> HttpResponse {
    let (user_or_team, repository_slug) = path.into_inner();
    let state = state_read(&data).await;
    if !activitypub::enabled(&state) {
        return HttpResponse::NotFound().finish();
    }

    // Look up the owner user
    let owner = match Users::find()
        .filter(users::Column::Name.eq(user_or_team.as_str()))
        .filter(users::Column::DeletedAt.is_null())
        .one(&state.db)
        .await
    {
        Ok(Some(user)) => user,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, user = %user_or_team.as_str(), "looking up the user failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Look up the repository (must be public)
    let repo = match Repositories::find()
        .filter(repositories::Column::OwnerId.eq(owner.id))
        .filter(repositories::Column::Slug.eq(repository_slug.as_str()))
        .filter(repositories::Column::Visibility.eq(repositories::Visibility::Public))
        .filter(repositories::Column::DeletedAt.is_null())
        .one(&state.db)
        .await
    {
        Ok(Some(repo)) => repo,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, user = %owner.name, repo = %repository_slug.as_str(), "looking up the repository failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Get the repository actor
    let actor = match activitypub::actor::get_or_create_for_repo(&state.db, repo.id).await {
        Ok(actor) => actor,
        Err(err) => {
            error!(%err, user = %owner.name, repo = %repository_slug.as_str(), "fetching repository actor failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let followers: Vec<ap_remote_actors::Model> = match ApFollowers::find()
        .filter(ap_followers::Column::LocalActorId.eq(actor.id))
        .find_also_related(ApRemoteActors)
        .all(&state.db)
        .await
    {
        Ok(followers) => followers
            .into_iter()
            .filter_map(|(_, remote)| remote)
            .collect(),
        Err(err) => {
            error!(%err, user = %owner.name, repo = %repository_slug.as_str(), "fetching followers failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let urls = ApUrls::new(state.public_url());
    let follower_urls: Vec<String> = followers.iter().map(|f| f.actor_url.clone()).collect();

    let collection = types::OrderedCollection {
        context: types::FORGEFED_CONTEXT.to_vec(),
        id: urls.repo_followers(&owner.name, &repository_slug),
        kind: "OrderedCollection",
        total_items: follower_urls.len() as u64,
        first: None,
        ordered_items: if follower_urls.is_empty() {
            None
        } else {
            Some(
                follower_urls
                    .into_iter()
                    .map(serde_json::Value::String)
                    .collect(),
            )
        },
    };

    HttpResponse::Ok()
        .content_type("application/activity+json; charset=utf-8")
        .json(collection)
}

/// GET /~{user_or_team}/{repository_slug}/outbox
/// Serves the outbox collection for the repository actor.
#[tracing::instrument(skip_all)]
pub async fn get_repo_outbox(
    data: web::Data<Arc<RwLock<AppState>>>,
    path: web::Path<(String, String)>,
    query: web::Query<crate::webui::federation::OutboxQuery>,
) -> HttpResponse {
    let (user_or_team, repository_slug) = path.into_inner();
    let state = state_read(&data).await;
    if !activitypub::enabled(&state) {
        return HttpResponse::NotFound().finish();
    }

    // Look up the owner user
    let owner = match Users::find()
        .filter(users::Column::Name.eq(user_or_team.as_str()))
        .filter(users::Column::DeletedAt.is_null())
        .one(&state.db)
        .await
    {
        Ok(Some(user)) => user,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, user = %user_or_team.as_str(), "looking up the user failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Look up the repository (must be public)
    let repo = match Repositories::find()
        .filter(repositories::Column::OwnerId.eq(owner.id))
        .filter(repositories::Column::Slug.eq(repository_slug.as_str()))
        .filter(repositories::Column::Visibility.eq(repositories::Visibility::Public))
        .filter(repositories::Column::DeletedAt.is_null())
        .one(&state.db)
        .await
    {
        Ok(Some(repo)) => repo,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, user = %owner.name, repo = %repository_slug.as_str(), "looking up the repository failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Get the repository actor
    let actor = match activitypub::actor::get_or_create_for_repo(&state.db, repo.id).await {
        Ok(actor) => actor,
        Err(err) => {
            error!(%err, user = %owner.name, repo = %repository_slug.as_str(), "fetching repository actor failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let urls = ApUrls::new(state.public_url());
    let outbox_url = urls.repo_outbox(&owner.name, &repository_slug);

    match activitypub::outbox::get_outbox(&state.db, actor.id, &outbox_url, query.page).await {
        Ok(collection) => HttpResponse::Ok()
            .content_type("application/activity+json; charset=utf-8")
            .json(collection),
        Err(err) => {
            error!(%err, user = %owner.name, repo = %repository_slug.as_str(), "fetching outbox failed");
            HttpResponse::InternalServerError().finish()
        }
    }
}

/// GET /~{user_or_team}/{repository_slug}/commits/{hash}
/// Serves a commit as a ForgeFed Commit document.
#[tracing::instrument(skip_all)]
pub async fn get_repo_commit(
    data: web::Data<Arc<RwLock<AppState>>>,
    path: web::Path<(String, String, String)>,
) -> HttpResponse {
    let (user_or_team, repository_slug, hash) = path.into_inner();
    let state = state_read(&data).await;
    if !activitypub::enabled(&state) {
        return HttpResponse::NotFound().finish();
    }

    // Validate hash is 40-character hex
    if hash.len() != 40 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
        return HttpResponse::NotFound().finish();
    }

    // Look up the owner user
    let owner = match Users::find()
        .filter(users::Column::Name.eq(user_or_team.as_str()))
        .filter(users::Column::DeletedAt.is_null())
        .one(&state.db)
        .await
    {
        Ok(Some(user)) => user,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, user = %user_or_team.as_str(), "looking up the user failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Look up the repository (must be public)
    let repo = match Repositories::find()
        .filter(repositories::Column::OwnerId.eq(owner.id))
        .filter(repositories::Column::Slug.eq(repository_slug.as_str()))
        .filter(repositories::Column::Visibility.eq(repositories::Visibility::Public))
        .filter(repositories::Column::DeletedAt.is_null())
        .one(&state.db)
        .await
    {
        Ok(Some(repo)) => repo,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, user = %owner.name, repo = %repository_slug.as_str(), "looking up the repository failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Get repository path
    let repo_path = crate::repositories::RepositoryPath {
        owner: owner.name.clone(),
        slug: repo.slug.clone(),
    };
    let bare_path = repo_path.bare_path(&state.repo_root);

    // Try to read the commit using git
    let commit = match crate::repositories::git::commit(&bare_path, &hash).await {
        Ok(Some(commit)) => commit,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, hash = %hash, "failed to read commit");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let urls = ApUrls::new(state.public_url());

    // Note: We don't have author email in the Commit struct, so we omit attributed_to
    // In the future, this could be matched by author name or extended to include email
    let attributed_to = None;

    let commit_doc = types::Commit {
        context: types::FORGEFED_CONTEXT.to_vec(),
        id: urls.repo_commit(&owner.name, &repository_slug, &commit.full_hash),
        kind: "Commit",
        attributed_to,
        hash: commit.full_hash,
        created: commit.date,
        summary: commit.subject,
    };

    HttpResponse::Ok()
        .content_type("application/activity+json; charset=utf-8")
        .json(commit_doc)
}

/// GET /~{user_or_team}/{repository_slug}/tickets/{number}
/// Serves a ticket as a ForgeFed Ticket document.
#[tracing::instrument(skip_all)]
pub async fn get_ticket_ap(
    data: web::Data<Arc<RwLock<AppState>>>,
    path: web::Path<(String, String, i64)>,
) -> HttpResponse {
    let (user_or_team, repository_slug, ticket_number) = path.into_inner();
    let state = state_read(&data).await;
    if !activitypub::enabled(&state) {
        return HttpResponse::NotFound().finish();
    }

    // Look up the owner user
    let owner = match Users::find()
        .filter(users::Column::Name.eq(user_or_team.as_str()))
        .filter(users::Column::DeletedAt.is_null())
        .one(&state.db)
        .await
    {
        Ok(Some(user)) => user,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, user = %user_or_team.as_str(), "looking up the user failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Look up the repository (must be public)
    let repo = match Repositories::find()
        .filter(repositories::Column::OwnerId.eq(owner.id))
        .filter(repositories::Column::Slug.eq(repository_slug.as_str()))
        .filter(repositories::Column::Visibility.eq(repositories::Visibility::Public))
        .filter(repositories::Column::DeletedAt.is_null())
        .one(&state.db)
        .await
    {
        Ok(Some(repo)) => repo,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, user = %owner.name, repo = %repository_slug.as_str(), "looking up the repository failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Look up the ticket
    let ticket = match crate::tickets::store::find_ticket(&state.db, repo.id, ticket_number).await {
        Ok(Some(ticket)) => ticket,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, ticket = %ticket_number, "looking up ticket failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Look up the creator (local tickets have creator_id, remote tickets have remote_actor_id)
    let creator_name = match ticket.creator_id {
        Some(creator_id) => {
            match Users::find()
                .filter(users::Column::Id.eq(creator_id))
                .one(&state.db)
                .await
            {
                Ok(Some(user)) => user.name,
                Ok(None) => return HttpResponse::NotFound().finish(),
                Err(err) => {
                    error!(%err, user_id = creator_id, "looking up user failed");
                    return HttpResponse::InternalServerError().finish();
                }
            }
        }
        None => {
            // Remote ticket - look up the remote actor
            match ticket.remote_actor_id {
                Some(remote_actor_id) => {
                    match crate::entities::prelude::ApRemoteActors::find_by_id(remote_actor_id)
                        .one(&state.db)
                        .await
                    {
                        Ok(Some(remote_actor)) => remote_actor
                            .preferred_username
                            .unwrap_or_else(|| remote_actor.actor_url.clone()),
                        Ok(None) => return HttpResponse::NotFound().finish(),
                        Err(err) => {
                            error!(%err, remote_actor_id, "looking up remote actor failed");
                            return HttpResponse::InternalServerError().finish();
                        }
                    }
                }
                None => return HttpResponse::NotFound().finish(),
            }
        }
    };

    let urls = ApUrls::new(state.public_url());
    let creator_url = match ticket.creator_id {
        Some(_) => urls.actor(&creator_name),
        None => creator_name, // For remote tickets, creator_name is already the actor URL
    };
    let ticket_doc = crate::activitypub::forge::builders::build_ticket_document(
        &ticket,
        &owner.name,
        &repository_slug,
        &creator_url,
        &urls,
    );

    HttpResponse::Ok()
        .content_type("application/activity+json; charset=utf-8")
        .json(ticket_doc)
}

/// GET /~{user_or_team}/{repository_slug}/patches/{number}
/// Serves a patch as a ForgeFed Patch document.
#[tracing::instrument(skip_all)]
pub async fn get_patch_ap(
    data: web::Data<Arc<RwLock<AppState>>>,
    path: web::Path<(String, String, i64)>,
) -> HttpResponse {
    let (user_or_team, repository_slug, patch_number) = path.into_inner();
    let state = state_read(&data).await;
    if !activitypub::enabled(&state) {
        return HttpResponse::NotFound().finish();
    }

    // Look up the owner user
    let owner = match Users::find()
        .filter(users::Column::Name.eq(user_or_team.as_str()))
        .filter(users::Column::DeletedAt.is_null())
        .one(&state.db)
        .await
    {
        Ok(Some(user)) => user,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, user = %user_or_team.as_str(), "looking up the user failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Look up the repository (must be public)
    let repo = match Repositories::find()
        .filter(repositories::Column::OwnerId.eq(owner.id))
        .filter(repositories::Column::Slug.eq(repository_slug.as_str()))
        .filter(repositories::Column::Visibility.eq(repositories::Visibility::Public))
        .filter(repositories::Column::DeletedAt.is_null())
        .one(&state.db)
        .await
    {
        Ok(Some(repo)) => repo,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, user = %owner.name, repo = %repository_slug.as_str(), "looking up the repository failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Look up the patch
    let patch = match crate::patches::store::find_by_number(&state.db, repo.id, patch_number).await
    {
        Ok(Some(patch)) => patch,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, patch = %patch_number, "looking up patch failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Look up the creator (local patches have creator_id, remote patches don't)
    let creator = match patch.creator_id {
        Some(creator_id) => {
            match Users::find()
                .filter(users::Column::Id.eq(creator_id))
                .one(&state.db)
                .await
            {
                Ok(Some(user)) => user,
                Ok(None) => return HttpResponse::NotFound().finish(),
                Err(err) => {
                    error!(%err, user_id = creator_id, "looking up user failed");
                    return HttpResponse::InternalServerError().finish();
                }
            }
        }
        None => {
            // TODO: Handle remote patches by looking up the remote actor
            return HttpResponse::NotImplemented().finish();
        }
    };

    // Get repository path
    let repo_path = crate::repositories::RepositoryPath {
        owner: owner.name.clone(),
        slug: repo.slug.clone(),
    };
    let bare_path = repo_path.bare_path(&state.repo_root);

    let urls = ApUrls::new(state.public_url());
    match crate::activitypub::forge::builders::build_patch_document(
        &patch,
        &repo,
        &owner.name,
        &bare_path,
        &creator,
        &urls,
    )
    .await
    {
        Ok(patch_doc) => HttpResponse::Ok()
            .content_type("application/activity+json; charset=utf-8")
            .json(patch_doc),
        Err(err) => {
            error!(%err, patch = %patch_number, "building patch document failed");
            HttpResponse::InternalServerError().finish()
        }
    }
}

/// GET /~{user_or_team}/{repository_slug}/tickets/{number}/replies
/// Serves the replies collection for a ticket.
#[tracing::instrument(skip_all)]
pub async fn get_ticket_replies(
    data: web::Data<Arc<RwLock<AppState>>>,
    path: web::Path<(String, String, i64)>,
) -> HttpResponse {
    let (user_or_team, repository_slug, ticket_number) = path.into_inner();
    let state = state_read(&data).await;
    if !activitypub::enabled(&state) {
        return HttpResponse::NotFound().finish();
    }

    // Look up the owner user
    let owner = match Users::find()
        .filter(users::Column::Name.eq(user_or_team.as_str()))
        .filter(users::Column::DeletedAt.is_null())
        .one(&state.db)
        .await
    {
        Ok(Some(user)) => user,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, user = %user_or_team.as_str(), "looking up the user failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Look up the repository (must be public)
    let repo = match Repositories::find()
        .filter(repositories::Column::OwnerId.eq(owner.id))
        .filter(repositories::Column::Slug.eq(repository_slug.as_str()))
        .filter(repositories::Column::Visibility.eq(repositories::Visibility::Public))
        .filter(repositories::Column::DeletedAt.is_null())
        .one(&state.db)
        .await
    {
        Ok(Some(repo)) => repo,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, user = %owner.name, repo = %repository_slug.as_str(), "looking up the repository failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Look up the ticket
    let ticket = match crate::tickets::store::find_ticket(&state.db, repo.id, ticket_number).await {
        Ok(Some(ticket)) => ticket,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, ticket = %ticket_number, "looking up ticket failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let urls = ApUrls::new(state.public_url());
    match crate::activitypub::forge::builders::build_ticket_replies(
        &state.db,
        &ticket,
        &owner.name,
        &repository_slug,
        &urls,
    )
    .await
    {
        Ok(replies_doc) => HttpResponse::Ok()
            .content_type("application/activity+json; charset=utf-8")
            .json(replies_doc),
        Err(err) => {
            error!(%err, ticket = %ticket_number, "building ticket replies failed");
            HttpResponse::InternalServerError().finish()
        }
    }
}

/// GET /~{user_or_team}/{repository_slug}/patches/{number}/replies
/// Serves the replies collection for a patch.
#[tracing::instrument(skip_all)]
pub async fn get_patch_replies(
    data: web::Data<Arc<RwLock<AppState>>>,
    path: web::Path<(String, String, i64)>,
) -> HttpResponse {
    let (user_or_team, repository_slug, patch_number) = path.into_inner();
    let state = state_read(&data).await;
    if !activitypub::enabled(&state) {
        return HttpResponse::NotFound().finish();
    }

    // Look up the owner user
    let owner = match Users::find()
        .filter(users::Column::Name.eq(user_or_team.as_str()))
        .filter(users::Column::DeletedAt.is_null())
        .one(&state.db)
        .await
    {
        Ok(Some(user)) => user,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, user = %user_or_team.as_str(), "looking up the user failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Look up the repository (must be public)
    let repo = match Repositories::find()
        .filter(repositories::Column::OwnerId.eq(owner.id))
        .filter(repositories::Column::Slug.eq(repository_slug.as_str()))
        .filter(repositories::Column::Visibility.eq(repositories::Visibility::Public))
        .filter(repositories::Column::DeletedAt.is_null())
        .one(&state.db)
        .await
    {
        Ok(Some(repo)) => repo,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, user = %owner.name, repo = %repository_slug.as_str(), "looking up the repository failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Look up the patch
    let patch = match crate::patches::store::find_by_number(&state.db, repo.id, patch_number).await
    {
        Ok(Some(patch)) => patch,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, patch = %patch_number, "looking up patch failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let urls = ApUrls::new(state.public_url());
    match crate::activitypub::forge::builders::build_patch_replies(
        &state.db,
        &patch,
        &owner.name,
        &repository_slug,
        &urls,
    )
    .await
    {
        Ok(replies_doc) => HttpResponse::Ok()
            .content_type("application/activity+json; charset=utf-8")
            .json(replies_doc),
        Err(err) => {
            error!(%err, patch = %patch_number, "building patch replies failed");
            HttpResponse::InternalServerError().finish()
        }
    }
}

/// GET /~{user_or_team}/{repository_slug}/tickets/{number}/comments/{uuid}
/// Serves a ticket comment as a ForgeFed Note document.
#[tracing::instrument(skip_all)]
pub async fn get_ticket_comment_ap(
    data: web::Data<Arc<RwLock<AppState>>>,
    path: web::Path<(String, String, i64, String)>,
) -> HttpResponse {
    let (user_or_team, repository_slug, ticket_number, comment_uuid_str) = path.into_inner();
    let state = state_read(&data).await;
    if !activitypub::enabled(&state) {
        return HttpResponse::NotFound().finish();
    }

    // Parse the comment UUID
    let comment_uuid = match uuid::Uuid::parse_str(&comment_uuid_str) {
        Ok(uuid) => uuid,
        Err(_) => return HttpResponse::NotFound().finish(),
    };

    // Look up the owner user
    let owner = match Users::find()
        .filter(users::Column::Name.eq(user_or_team.as_str()))
        .filter(users::Column::DeletedAt.is_null())
        .one(&state.db)
        .await
    {
        Ok(Some(user)) => user,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, user = %user_or_team.as_str(), "looking up the user failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Look up the repository (must be public)
    let repo = match Repositories::find()
        .filter(repositories::Column::OwnerId.eq(owner.id))
        .filter(repositories::Column::Slug.eq(repository_slug.as_str()))
        .filter(repositories::Column::Visibility.eq(repositories::Visibility::Public))
        .filter(repositories::Column::DeletedAt.is_null())
        .one(&state.db)
        .await
    {
        Ok(Some(repo)) => repo,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, user = %owner.name, repo = %repository_slug.as_str(), "looking up the repository failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Look up the ticket
    let ticket = match crate::tickets::store::find_ticket(&state.db, repo.id, ticket_number).await {
        Ok(Some(ticket)) => ticket,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, ticket = %ticket_number, "looking up ticket failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Look up the comment
    let comment = match crate::comments::store::find_by_uuid(&state.db, comment_uuid).await {
        Ok(Some(comment)) => comment,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, uuid = %comment_uuid, "looking up comment failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Verify the comment belongs to this ticket
    if comment.ticket_id != Some(ticket.id) {
        return HttpResponse::NotFound().finish();
    }

    // Look up the comment author (must be local for now)
    let author = match comment.author_id {
        Some(author_id) => match Users::find()
            .filter(users::Column::Id.eq(author_id))
            .one(&state.db)
            .await
        {
            Ok(Some(user)) => user,
            Ok(None) => return HttpResponse::NotFound().finish(),
            Err(err) => {
                error!(%err, author_id = author_id, "looking up comment author failed");
                return HttpResponse::InternalServerError().finish();
            }
        },
        None => {
            // Remote-authored comments are not yet supported for serving
            return HttpResponse::NotImplemented().finish();
        }
    };

    let urls = ApUrls::new(state.public_url());
    let note_doc = crate::activitypub::forge::builders::build_comment_note_document(
        &comment,
        &owner.name,
        &repository_slug,
        ticket.number,
        true, // is_ticket
        &author,
        &urls,
    );

    HttpResponse::Ok()
        .content_type("application/activity+json; charset=utf-8")
        .json(note_doc)
}

/// GET /~{user_or_team}/{repository_slug}/patches/{number}/comments/{uuid}
/// Serves a patch comment as a ForgeFed Note document.
#[tracing::instrument(skip_all)]
pub async fn get_patch_comment_ap(
    data: web::Data<Arc<RwLock<AppState>>>,
    path: web::Path<(String, String, i64, String)>,
) -> HttpResponse {
    let (user_or_team, repository_slug, patch_number, comment_uuid_str) = path.into_inner();
    let state = state_read(&data).await;
    if !activitypub::enabled(&state) {
        return HttpResponse::NotFound().finish();
    }

    // Parse the comment UUID
    let comment_uuid = match uuid::Uuid::parse_str(&comment_uuid_str) {
        Ok(uuid) => uuid,
        Err(_) => return HttpResponse::NotFound().finish(),
    };

    // Look up the owner user
    let owner = match Users::find()
        .filter(users::Column::Name.eq(user_or_team.as_str()))
        .filter(users::Column::DeletedAt.is_null())
        .one(&state.db)
        .await
    {
        Ok(Some(user)) => user,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, user = %user_or_team.as_str(), "looking up the user failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Look up the repository (must be public)
    let repo = match Repositories::find()
        .filter(repositories::Column::OwnerId.eq(owner.id))
        .filter(repositories::Column::Slug.eq(repository_slug.as_str()))
        .filter(repositories::Column::Visibility.eq(repositories::Visibility::Public))
        .filter(repositories::Column::DeletedAt.is_null())
        .one(&state.db)
        .await
    {
        Ok(Some(repo)) => repo,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, user = %owner.name, repo = %repository_slug.as_str(), "looking up the repository failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Look up the patch
    let patch = match crate::patches::store::find_by_number(&state.db, repo.id, patch_number).await
    {
        Ok(Some(patch)) => patch,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, patch = %patch_number, "looking up patch failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Look up the comment
    let comment = match crate::comments::store::find_by_uuid(&state.db, comment_uuid).await {
        Ok(Some(comment)) => comment,
        Ok(None) => return HttpResponse::NotFound().finish(),
        Err(err) => {
            error!(%err, uuid = %comment_uuid, "looking up comment failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Verify the comment belongs to this patch
    if comment.patch_proposal_id != Some(patch.id) {
        return HttpResponse::NotFound().finish();
    }

    // Look up the comment author (must be local for now)
    let author = match comment.author_id {
        Some(author_id) => match Users::find()
            .filter(users::Column::Id.eq(author_id))
            .one(&state.db)
            .await
        {
            Ok(Some(user)) => user,
            Ok(None) => return HttpResponse::NotFound().finish(),
            Err(err) => {
                error!(%err, author_id = author_id, "looking up comment author failed");
                return HttpResponse::InternalServerError().finish();
            }
        },
        None => {
            // Remote-authored comments are not yet supported for serving
            return HttpResponse::NotImplemented().finish();
        }
    };

    let urls = ApUrls::new(state.public_url());
    let note_doc = crate::activitypub::forge::builders::build_comment_note_document(
        &comment,
        &owner.name,
        &repository_slug,
        patch.number,
        false, // is_ticket
        &author,
        &urls,
    );

    HttpResponse::Ok()
        .content_type("application/activity+json; charset=utf-8")
        .json(note_doc)
}