🐸 Frogue(Fed)

~ololduck/Frogue

Raw

// SPDX-FileCopyrightText: 2026 Ololduck <ololduck@vit.am> and contributors
//
// SPDX-License-Identifier: GPL-3.0-or-later

//! Admin interface handlers.
//!
//! Handles moderation features: user management, report queue, and federation
//! controls. All routes require AdminUser extractor (user_id = 1 for now).

use std::sync::Arc;

use actix_web::{HttpResponse, Responder, get, post, web};
use sea_orm::{
    ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
    QuerySelect, Set,
};
use tokio::sync::RwLock;
use tracing::instrument;

use crate::auth::AdminUser;
use crate::entities::{instance_blocks, reports, users};
use crate::views::admin;
use crate::{AppState, state_read};

/// Query the count of open reports for the nav badge
async fn get_open_report_count(db: &sea_orm::DatabaseConnection) -> u64 {
    reports::Entity::find()
        .filter(reports::Column::State.eq("open"))
        .count(db)
        .await
        .unwrap_or(0)
}

/// GET /admin - Admin home page
#[get("/admin")]
pub async fn admin_home(
    admin: AdminUser,
    data: web::Data<Arc<RwLock<AppState>>>,
) -> impl Responder {
    let state = state_read(&data).await;
    let pending_reports = get_open_report_count(&state.db).await;
    admin::home(&admin, pending_reports, None).into_response()
}

/// Query parameters for users list pagination
#[derive(Debug, serde::Deserialize)]
pub struct UsersListQuery {
    pub page: Option<u64>,
}

/// GET /admin/users - List all users
#[get("/admin/users")]
#[instrument(skip(admin, data))]
pub async fn list_users(
    admin: AdminUser,
    data: web::Data<Arc<RwLock<AppState>>>,
    query: web::Query<UsersListQuery>,
) -> impl Responder {
    let state = state_read(&data).await;
    let db = &state.db;

    let page = query.page.unwrap_or(0);
    const PAGE_SIZE: u64 = 50;
    let offset = page * PAGE_SIZE;

    let users = users::Entity::find()
        .order_by_asc(users::Column::Id)
        .limit(PAGE_SIZE)
        .offset(offset)
        .all(db)
        .await
        .unwrap_or_default();

    // Fetch repository and note counts for each user
    let repos = crate::entities::repositories::Entity::find()
        .all(db)
        .await
        .unwrap_or_default();
    let repo_counts: std::collections::HashMap<i64, usize> =
        repos
            .iter()
            .fold(std::collections::HashMap::new(), |mut acc, r| {
                *acc.entry(r.owner_id).or_insert(0) += 1;
                acc
            });

    let ap_actors = crate::entities::ap_actors::Entity::find()
        .all(db)
        .await
        .unwrap_or_default();
    let user_actor_ids: std::collections::HashMap<i64, i64> = ap_actors
        .iter()
        .filter_map(|a| a.user_id.map(|uid| (uid, a.id)))
        .collect();

    let ap_notes = crate::entities::ap_notes::Entity::find()
        .all(db)
        .await
        .unwrap_or_default();
    let note_counts: std::collections::HashMap<i64, usize> =
        ap_notes
            .iter()
            .fold(std::collections::HashMap::new(), |mut acc, n| {
                *acc.entry(n.actor_id).or_insert(0) += 1;
                acc
            });

    // Map user_id -> note count via actor_id
    let user_note_counts: std::collections::HashMap<i64, usize> = users
        .iter()
        .map(|u| {
            let note_count = user_actor_ids
                .get(&u.id)
                .and_then(|actor_id| note_counts.get(actor_id))
                .copied()
                .unwrap_or(0);
            (u.id, note_count)
        })
        .collect();

    let pending_reports = get_open_report_count(db).await;
    admin::users_list(
        &admin,
        &users,
        &repo_counts,
        &user_note_counts,
        pending_reports,
        None,
    )
    .into_response()
}

/// POST /admin/users/{id}/delete - Delete a user
///
/// Note: Sessions are stateless cookies; a deleted user's cookie dies on first
/// lookup (name no longer resolves). No revocation needed.
#[post("/admin/users/{id}/delete")]
#[instrument(name = "admin.delete_user", skip(admin, data), fields(user_id))]
pub async fn delete_user(
    admin: AdminUser,
    data: web::Data<Arc<RwLock<AppState>>>,
    id: web::Path<i64>,
) -> impl Responder {
    let user_id = id.into_inner();
    tracing::Span::current().record("user_id", user_id);
    let state = state_read(&data).await;
    let db = &state.db;
    let public_url = state.public_url();

    // Prevent deleting the admin (user_id = 1)
    if user_id == admin.id {
        return HttpResponse::BadRequest().body("Cannot delete the admin user");
    }

    // Schedule user deletion with grace period
    let grace_period = crate::deletion_grace_period_seconds();
    match crate::moderation::schedule_user_deletion(db, public_url, user_id, grace_period).await {
        Ok(_) => HttpResponse::Found()
            .append_header(("Location", "/admin/users"))
            .finish(),
        Err(crate::moderation::ScheduleDeletionError::UserNotFound) => {
            let users = users::Entity::find()
                .order_by_asc(users::Column::Id)
                .all(db)
                .await
                .unwrap_or_default();
            let pending_reports = get_open_report_count(db).await;
            admin::users_list(
                &admin,
                &users,
                &std::collections::HashMap::new(),
                &std::collections::HashMap::new(),
                pending_reports,
                Some("User not found"),
            )
            .into_response()
        }
        Err(e) => {
            tracing::error!(error = %e, user_id = user_id, "Failed to schedule user deletion");
            let users = users::Entity::find()
                .order_by_asc(users::Column::Id)
                .all(db)
                .await
                .unwrap_or_default();
            let pending_reports = get_open_report_count(db).await;
            admin::users_list(
                &admin,
                &users,
                &std::collections::HashMap::new(),
                &std::collections::HashMap::new(),
                pending_reports,
                Some("Failed to schedule user deletion"),
            )
            .into_response()
        }
    }
}

/// Query parameters for filtering reports
#[derive(serde::Deserialize)]
pub struct ReportFilter {
    pub state: Option<String>,
}

/// GET /admin/reports - List all reports
#[get("/admin/reports")]
#[instrument(skip(admin, data, query))]
pub async fn list_reports(
    admin: AdminUser,
    data: web::Data<Arc<RwLock<AppState>>>,
    query: web::Query<ReportFilter>,
) -> impl Responder {
    let state = state_read(&data).await;
    let db = &state.db;

    let reports = if let Some(ref state_filter) = query.state {
        if state_filter.is_empty() {
            // Show all reports
            reports::Entity::find()
                .order_by_desc(reports::Column::CreatedAt)
                .all(db)
                .await
                .unwrap_or_default()
        } else {
            // Filter by state
            reports::Entity::find()
                .filter(reports::Column::State.eq(state_filter))
                .order_by_desc(reports::Column::CreatedAt)
                .all(db)
                .await
                .unwrap_or_default()
        }
    } else {
        // Default: show open reports
        reports::Entity::find()
            .filter(reports::Column::State.eq("open"))
            .order_by_desc(reports::Column::CreatedAt)
            .all(db)
            .await
            .unwrap_or_default()
    };

    // Fetch all users for resolving reporter/target usernames
    let users = users::Entity::find().all(db).await.unwrap_or_default();

    // Fetch all remote actors for resolving reporter/target handles
    let remote_actors = crate::entities::ap_remote_actors::Entity::find()
        .all(db)
        .await
        .unwrap_or_default();

    let pending_reports = get_open_report_count(db).await;
    admin::reports_list(
        &admin,
        &reports,
        &users,
        &remote_actors,
        query.state.as_deref(),
        pending_reports,
        None,
    )
    .into_response()
}

/// GET /admin/reports/{uuid} - Report detail view
#[get("/admin/reports/{uuid}")]
#[instrument(skip(admin, data), fields(report_uuid = %uuid.as_str()))]
pub async fn report_detail(
    admin: AdminUser,
    data: web::Data<Arc<RwLock<AppState>>>,
    uuid: web::Path<String>,
) -> impl Responder {
    let state = state_read(&data).await;
    let db = &state.db;
    let public_url = state.public_url();

    // Find the report by UUID
    let report = match reports::Entity::find()
        .filter(reports::Column::Uuid.eq(uuid.as_str()))
        .one(db)
        .await
    {
        Ok(Some(report)) => report,
        Ok(None) => {
            return HttpResponse::NotFound().body("Report not found");
        }
        Err(e) => {
            tracing::error!(error = %e, "Failed to fetch report");
            return HttpResponse::InternalServerError().body("Database error");
        }
    };

    // Fetch reporter user (if local)
    let reporter_user = if let Some(reporter_id) = report.reporter_local_user_id {
        users::Entity::find_by_id(reporter_id)
            .one(db)
            .await
            .unwrap_or(None)
    } else {
        None
    };

    // Fetch target user (if local)
    let target_user = if let Some(target_id) = report.target_local_user_id {
        users::Entity::find_by_id(target_id)
            .one(db)
            .await
            .unwrap_or(None)
    } else {
        None
    };

    // Fetch target remote actor (if remote)
    let target_remote = if let Some(target_remote_id) = report.target_remote_actor_id {
        crate::entities::ap_remote_actors::Entity::find_by_id(target_remote_id)
            .one(db)
            .await
            .unwrap_or(None)
    } else {
        None
    };

    // Resolve object URLs to local notes or external links
    let object_urls: Vec<String> = serde_json::from_str(&report.object_urls).unwrap_or_default();
    let reported_objects = resolve_object_urls(db, &object_urls, public_url).await;

    let pending_reports = get_open_report_count(db).await;
    admin::report_detail(
        &admin,
        &report,
        reporter_user.as_ref(),
        report.reporter_remote_actor_url.as_deref(),
        target_user.as_ref(),
        target_remote.as_ref(),
        &reported_objects,
        pending_reports,
        None,
    )
    .into_response()
}

/// POST /admin/reports/{uuid}/resolve - Resolve a report
#[post("/admin/reports/{uuid}/resolve")]
#[instrument(skip(_admin, data), fields(report_uuid = %uuid.as_str()))]
pub async fn resolve_report(
    _admin: AdminUser,
    data: web::Data<Arc<RwLock<AppState>>>,
    uuid: web::Path<String>,
) -> impl Responder {
    let report_uuid = uuid.into_inner();
    let state = state_read(&data).await;
    let db = &state.db;

    let report = reports::Entity::find()
        .filter(reports::Column::Uuid.eq(&report_uuid))
        .one(db)
        .await
        .unwrap_or(None);

    if let Some(report) = report {
        let mut active: reports::ActiveModel = report.into();
        active.state = sea_orm::ActiveValue::Set("resolved".to_string());
        let _ = active.update(db).await;
    }

    HttpResponse::Found()
        .append_header(("Location", "/admin/reports"))
        .finish()
}

/// POST /admin/reports/{uuid}/dismiss - Dismiss a report
#[post("/admin/reports/{uuid}/dismiss")]
#[instrument(skip(_admin, data), fields(report_uuid = %uuid.as_str()))]
pub async fn dismiss_report(
    _admin: AdminUser,
    data: web::Data<Arc<RwLock<AppState>>>,
    uuid: web::Path<String>,
) -> impl Responder {
    let report_uuid = uuid.into_inner();
    let state = state_read(&data).await;
    let db = &state.db;

    let report = reports::Entity::find()
        .filter(reports::Column::Uuid.eq(&report_uuid))
        .one(db)
        .await
        .unwrap_or(None);

    if let Some(report) = report {
        let mut active: reports::ActiveModel = report.into();
        active.state = sea_orm::ActiveValue::Set("dismissed".to_string());
        let _ = active.update(db).await;
    }

    HttpResponse::Found()
        .append_header(("Location", "/admin/reports"))
        .finish()
}

/// GET /admin/federation - Federation controls
#[get("/admin/federation")]
#[instrument(skip(admin, data))]
pub async fn federation_controls(
    admin: AdminUser,
    data: web::Data<Arc<RwLock<AppState>>>,
) -> impl Responder {
    let state = state_read(&data).await;
    let db = &state.db;

    let blocks = instance_blocks::Entity::find()
        .order_by_desc(instance_blocks::Column::CreatedAt)
        .all(db)
        .await
        .unwrap_or_default();

    // Fetch admin remote blocks with remote actor info
    let admin_blocks = crate::entities::admin_remote_blocks::Entity::find()
        .order_by_desc(crate::entities::admin_remote_blocks::Column::CreatedAt)
        .all(db)
        .await
        .unwrap_or_default();

    let remote_actors = crate::entities::ap_remote_actors::Entity::find()
        .all(db)
        .await
        .unwrap_or_default();

    let users = users::Entity::find().all(db).await.unwrap_or_default();

    let pending_reports = get_open_report_count(db).await;
    admin::federation(
        &admin,
        &blocks,
        &admin_blocks,
        &remote_actors,
        &users,
        pending_reports,
        None,
    )
    .into_response()
}

/// POST /admin/federation/block-domain - Block a domain
#[post("/admin/federation/block-domain")]
#[instrument(name = "admin.block_domain", skip(admin, data, form), fields(domain))]
pub async fn block_domain(
    admin: AdminUser,
    data: web::Data<Arc<RwLock<AppState>>>,
    form: web::Form<BlockDomainForm>,
) -> impl Responder {
    let state = state_read(&data).await;
    let db = &state.db;

    // Normalize to a lowercase host: strips scheme, port, path, query,
    // fragment.
    let Some(domain) = crate::moderation::normalize_domain(&form.domain) else {
        return HttpResponse::Found()
            .append_header(("Location", "/admin/federation"))
            .finish();
    };

    // Check if already blocked
    let existing = instance_blocks::Entity::find()
        .filter(instance_blocks::Column::Domain.eq(domain.as_str()))
        .one(db)
        .await
        .unwrap_or(None);

    if existing.is_some() {
        return HttpResponse::Found()
            .append_header(("Location", "/admin/federation"))
            .finish();
    }

    let block = instance_blocks::ActiveModel {
        domain: Set(domain.clone()),
        reason: Set(if form.reason.is_empty() {
            None
        } else {
            Some(form.reason.clone())
        }),
        blocked_by: Set(admin.id),
        ..Default::default()
    };

    match block.insert(db).await {
        Ok(_) => {
            tracing::info!(admin = %admin.name, domain = %domain, "Domain blocked");
            HttpResponse::Found()
                .append_header(("Location", "/admin/federation"))
                .finish()
        }
        Err(e) => {
            tracing::error!(error = %e, "Failed to block domain");
            HttpResponse::Found()
                .append_header(("Location", "/admin/federation"))
                .finish()
        }
    }
}

/// POST /admin/federation/unblock-domain - Unblock a domain
#[post("/admin/federation/unblock-domain")]
#[instrument(skip(admin, data, form), fields(domain))]
pub async fn unblock_domain(
    admin: AdminUser,
    data: web::Data<Arc<RwLock<AppState>>>,
    form: web::Form<UnblockDomainForm>,
) -> impl Responder {
    let state = state_read(&data).await;
    let db = &state.db;

    let domain = crate::moderation::normalize_domain(&form.domain).unwrap_or_default();
    tracing::Span::current().record("domain", &domain);

    // TODO: In v1, federate Undo(Block) activity before deleting the row
    match instance_blocks::Entity::delete_many()
        .filter(instance_blocks::Column::Domain.eq(domain.as_str()))
        .exec(db)
        .await
    {
        Ok(result) => {
            tracing::info!(admin = %admin.name, domain = %domain, rows_affected = result.rows_affected, "Domain unblocked");
        }
        Err(e) => {
            tracing::error!(error = %e, "Failed to unblock domain");
        }
    }

    HttpResponse::Found()
        .append_header(("Location", "/admin/federation"))
        .finish()
}

/// POST /admin/federation/block-remote-user - Block a remote user
#[post("/admin/federation/block-remote-user")]
#[instrument(
    name = "admin.block_remote_user",
    skip(admin, data, form),
    fields(actor_url)
)]
pub async fn block_remote_user(
    admin: AdminUser,
    data: web::Data<Arc<RwLock<AppState>>>,
    form: web::Form<BlockRemoteUserForm>,
) -> impl Responder {
    let state = state_read(&data).await;
    let db = &state.db;

    let input = form.actor_url.trim();
    let public_url = state.public_url();

    // Determine if input is a handle or URL
    let actor_url =
        if input.contains('@') && !input.starts_with("http://") && !input.starts_with("https://") {
            // Handle format: @user@host or user@host
            // Resolve via webfinger
            let instance_actor = match crate::activitypub::actor::get_or_create_instance(db).await {
                Ok(actor) => actor,
                Err(e) => {
                    tracing::error!(error = %e, "Failed to get or create instance actor");
                    return HttpResponse::InternalServerError().body("Database error");
                }
            };

            match crate::activitypub::remote::discover_by_handle(
                db,
                &state.http_client,
                public_url,
                &instance_actor,
                &admin.name,
                input,
            )
            .await
            {
                Ok(remote_actor) => remote_actor.actor_url,
                Err(e) => {
                    tracing::error!(error = %e, handle = %input, "Failed to resolve handle");
                    return HttpResponse::BadRequest()
                        .body(format!("Failed to resolve handle: {}", e));
                }
            }
        } else {
            // URL format
            input.to_string()
        };

    tracing::Span::current().record("actor_url", &actor_url);

    // Refuse to block local actors
    if actor_url.starts_with(public_url) {
        return HttpResponse::BadRequest().body("Cannot block local actors");
    }

    // Check if the domain is blocked
    let actor_domain = crate::moderation::normalize_domain(&actor_url).unwrap_or_default();
    if let Ok(Some(domain_block)) = instance_blocks::Entity::find()
        .filter(instance_blocks::Column::Domain.eq(actor_domain))
        .one(db)
        .await
    {
        tracing::info!(
            admin = %admin.name,
            actor_url = %actor_url,
            blocked_domain = %domain_block.domain,
            "Actor domain is already blocked"
        );
        return HttpResponse::BadRequest()
            .body(format!("Domain {} is already blocked", domain_block.domain));
    }

    // Look up the remote actor
    let remote_actor = match crate::entities::ap_remote_actors::Entity::find()
        .filter(crate::entities::ap_remote_actors::Column::ActorUrl.eq(&actor_url))
        .one(db)
        .await
    {
        Ok(Some(actor)) => actor,
        Ok(None) => {
            return HttpResponse::NotFound().body("Remote actor not found");
        }
        Err(e) => {
            tracing::error!(error = %e, "Failed to lookup remote actor");
            return HttpResponse::InternalServerError().body("Database error");
        }
    };

    // Check if already blocked
    let existing = crate::entities::admin_remote_blocks::Entity::find()
        .filter(crate::entities::admin_remote_blocks::Column::RemoteActorId.eq(remote_actor.id))
        .one(db)
        .await
        .unwrap_or(None);

    if existing.is_some() {
        return HttpResponse::Found()
            .append_header(("Location", "/admin/federation"))
            .finish();
    }

    // Insert the block
    let block = crate::entities::admin_remote_blocks::ActiveModel {
        remote_actor_id: sea_orm::Set(remote_actor.id),
        reason: sea_orm::Set(if form.reason.is_empty() {
            None
        } else {
            Some(form.reason.clone())
        }),
        blocked_by: sea_orm::Set(admin.id),
        ..Default::default()
    };

    match block.insert(db).await {
        Ok(_) => {
            // Remove follow relationships (both directions)
            if let Err(e) = remove_follow_relationships(db, remote_actor.id).await {
                tracing::error!(error = %e, "Failed to remove follow relationships");
            }

            // Enqueue instance-actor-signed Block to their inbox
            if let Err(e) = deliver_block_activity(&state, db, &remote_actor).await {
                tracing::error!(error = %e, "Failed to deliver Block activity");
            }

            tracing::info!(
                admin = %admin.name,
                remote_actor_id = remote_actor.id,
                "Remote user blocked"
            );
            HttpResponse::Found()
                .append_header(("Location", "/admin/federation"))
                .finish()
        }
        Err(e) => {
            tracing::error!(error = %e, "Failed to block remote user");
            HttpResponse::Found()
                .append_header(("Location", "/admin/federation"))
                .finish()
        }
    }
}

/// POST /admin/federation/unblock-remote-user - Unblock a remote user
#[post("/admin/federation/unblock-remote-user")]
#[instrument(skip(admin, data, form), fields(remote_actor_id))]
pub async fn unblock_remote_user(
    admin: AdminUser,
    data: web::Data<Arc<RwLock<AppState>>>,
    form: web::Form<UnblockRemoteUserForm>,
) -> impl Responder {
    let state = state_read(&data).await;
    let db = &state.db;

    let remote_actor_id: i64 = match form.remote_actor_id.parse() {
        Ok(id) => id,
        Err(_) => {
            return HttpResponse::BadRequest().body("Invalid remote actor ID");
        }
    };

    tracing::Span::current().record("remote_actor_id", remote_actor_id);

    // TODO: In v1, federate Undo(Block) activity before deleting the row
    match crate::entities::admin_remote_blocks::Entity::delete_many()
        .filter(crate::entities::admin_remote_blocks::Column::RemoteActorId.eq(remote_actor_id))
        .exec(db)
        .await
    {
        Ok(result) => {
            tracing::info!(
                admin = %admin.name,
                remote_actor_id = remote_actor_id,
                rows_affected = result.rows_affected,
                "Remote user unblocked"
            );
        }
        Err(e) => {
            tracing::error!(error = %e, "Failed to unblock remote user");
        }
    }

    HttpResponse::Found()
        .append_header(("Location", "/admin/federation"))
        .finish()
}

/// Remove follow relationships involving a remote actor (both directions)
async fn remove_follow_relationships(
    db: &sea_orm::DatabaseConnection,
    remote_actor_id: i64,
) -> Result<(), sea_orm::DbErr> {
    use crate::entities::{ap_followers, ap_following};

    // Remove from ap_followers (remote follows local)
    ap_followers::Entity::delete_many()
        .filter(ap_followers::Column::RemoteActorId.eq(remote_actor_id))
        .exec(db)
        .await?;

    // Remove from ap_following (local follows remote)
    ap_following::Entity::delete_many()
        .filter(ap_following::Column::RemoteActorId.eq(remote_actor_id))
        .exec(db)
        .await?;

    Ok(())
}

/// Deliver a Block activity signed by the instance actor to the remote actor's
/// inbox
async fn deliver_block_activity(
    state: &crate::AppState,
    db: &sea_orm::DatabaseConnection,
    remote_actor: &crate::entities::ap_remote_actors::Model,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    // Get or create the instance actor; it may not exist yet on a fresh
    // instance, and blocking must still federate.
    let instance_actor = crate::activitypub::actor::get_or_create_instance(db).await?;

    let public_url = state.public_url();
    let instance_actor_url = format!("{}/actor", public_url);
    let block_id = format!("{}#block-{}", instance_actor_url, uuid::Uuid::new_v4());

    let block_activity = serde_json::json!({
        "@context": "https://www.w3.org/ns/activitystreams",
        "id": block_id,
        "type": "Block",
        "actor": instance_actor_url,
        "object": remote_actor.actor_url,
        "to": [remote_actor.actor_url]
    });

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

    // Enqueue delivery to the remote actor's inbox
    crate::activitypub::delivery::enqueue_delivery(
        db,
        instance_actor.id,
        uuid::Uuid::new_v4(),
        activity_json,
        instance_actor_url,
        remote_actor.inbox_url.clone(),
    )
    .await?;

    Ok(())
}

/// Resolve object URLs to local notes or external links for the report detail
/// view.
async fn resolve_object_urls(
    db: &sea_orm::DatabaseConnection,
    object_urls: &[String],
    public_url: &str,
) -> Vec<admin::ReportedObject> {
    let notes_prefix = format!("{}/~/", public_url);

    let mut result = Vec::with_capacity(object_urls.len());
    for url in object_urls {
        if let Some(note) = try_resolve_local_note(db, url, &notes_prefix, public_url).await {
            result.push(admin::ReportedObject::LocalNote(Box::new(note)));
        } else {
            result.push(admin::ReportedObject::ExternalLink(url.clone()));
        }
    }

    result
}

/// Attempt to resolve a single object URL as a local note. Returns None if the
/// URL doesn't match the local note pattern or the note can't be found.
async fn try_resolve_local_note(
    db: &sea_orm::DatabaseConnection,
    url: &str,
    notes_prefix: &str,
    public_url: &str,
) -> Option<crate::views::Note> {
    use sea_orm::{ColumnTrait, EntityTrait};
    use url::Url;

    use crate::entities::{ap_actors, ap_notes, users};

    // URL must match {public_url}/~{user}/notes/{uuid}
    let rest = url.strip_prefix(notes_prefix)?;
    let (username, uuid_part) = rest.split_once("/notes/")?;
    let note_uuid = uuid::Uuid::parse_str(uuid_part).ok()?;

    let user = users::Entity::find()
        .filter(users::Column::Name.eq(username))
        .one(db)
        .await
        .ok()??;

    let actor = ap_actors::Entity::find()
        .filter(ap_actors::Column::UserId.eq(user.id))
        .one(db)
        .await
        .ok()??;

    let note = ap_notes::Entity::find()
        .filter(ap_notes::Column::ActorId.eq(actor.id))
        .filter(ap_notes::Column::Uuid.eq(note_uuid))
        .one(db)
        .await
        .ok()??;

    Some(crate::views::Note {
        author: crate::views::NoteAuthor {
            display_name: user.name.clone(),
            handle: crate::views::NoteAuthorHandle::Local {
                username: user.name.clone(),
                actor_url: format!("/~{}", user.name),
            },
            icon_url: None,
        },
        content_html: note.content_html.clone(),
        created_at: (&note.created_at).into(),
        updated_at: None,
        permalink: Url::parse(url).unwrap_or_else(|_| {
            Url::parse(&format!("{}/~{}/notes/{}", public_url, username, uuid_part)).unwrap()
        }),
        reactions: None,
        actions: None,
        attachments: Vec::new(),
        base_url: format!("{}/~{}/notes", public_url, username),
        uuid: uuid_part.to_string(),
    })
}

/// Form data for blocking a remote user
#[derive(serde::Deserialize)]
pub struct BlockRemoteUserForm {
    pub actor_url: String,
    pub reason: String,
}

/// Form data for unblocking a remote user
#[derive(serde::Deserialize)]
pub struct UnblockRemoteUserForm {
    pub remote_actor_id: String,
}

/// Form data for blocking a domain
#[derive(serde::Deserialize)]
pub struct BlockDomainForm {
    pub domain: String,
    pub reason: String,
}

/// Form data for unblocking a domain
#[derive(serde::Deserialize)]
pub struct UnblockDomainForm {
    pub domain: String,
}