use askama::Template; use askama_web::WebTemplate; use axum::extract::{Path, Query, State}; use axum::http::HeaderMap; use axum::response::{AppendHeaders, IntoResponse, Redirect, Response}; use axum::Form; use serde::Deserialize; use sqlx::FromRow; use crate::auth_guard::AdminUser; use crate::flash; use crate::page::PageContext; use crate::state::AppState; async fn audit( db: &sqlx::PgPool, admin_sub: &str, action: &str, target_type: &str, target_id: &str, metadata: Option, ) { if let Err(e) = sqlx::query( "INSERT INTO admin_audit_log (admin_sub, action, target_type, target_id, metadata) VALUES ($1, $2, $3, $4, $5)", ) .bind(admin_sub) .bind(action) .bind(target_type) .bind(target_id) .bind(metadata) .execute(db) .await { tracing::error!("audit log insert failed: {e}"); } } async fn set_keycloak_user_enabled(state: &AppState, sub: &str, enabled: bool) -> Result<(), String> { let token = crate::keycloak::get_admin_token(state).await?; let base_url = state.oidc.issuer_url.replace("/realms/irc-now", ""); let url = format!("{}/admin/realms/irc-now/users/{}", base_url, sub); let resp = state .http_client .put(&url) .bearer_auth(&token) .json(&serde_json::json!({ "enabled": enabled })) .send() .await .map_err(|e| format!("keycloak user update failed: {e}"))?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); return Err(format!("keycloak user update returned {status}: {body}")); } Ok(()) } #[derive(FromRow)] #[allow(dead_code)] struct ReportRow { id: i64, content_type: String, content_id: String, content_url: String, reporter_email: Option, reason: String, description: Option, status: String, resolution: Option, resolved_by: Option, resolved_at: Option>, created_at: chrono::DateTime, } #[allow(dead_code)] pub struct ReportEntry { pub id: i64, pub content_type: String, pub reason: String, pub status: String, pub created_at: String, pub content_url: String, } #[derive(Template, WebTemplate)] #[template(path = "admin_reports.html")] pub struct AdminReportsTemplate { pub page: PageContext, pub is_admin: bool, pub reports: Vec, pub filter: String, } #[derive(Deserialize)] pub struct ReportsQuery { pub status: Option, } pub async fn reports( headers: HeaderMap, State(state): State, AdminUser(_admin): AdminUser, Query(q): Query, ) -> Result { let page = PageContext::from_request(&headers, &state.announcement); let clear = page.flash.is_some(); let filter = q.status.unwrap_or_else(|| "pending".to_string()); let rows = sqlx::query_as::<_, ReportRow>( "SELECT * FROM abuse_reports WHERE status = $1 ORDER BY created_at DESC LIMIT 100", ) .bind(&filter) .fetch_all(&state.db) .await .map_err(|e| { tracing::error!("reports query failed: {e}"); axum::http::StatusCode::INTERNAL_SERVER_ERROR })?; let reports = rows .into_iter() .map(|r| ReportEntry { id: r.id, content_type: r.content_type, reason: r.reason, status: r.status, created_at: r.created_at.format("%Y-%m-%d %H:%M").to_string(), content_url: r.content_url, }) .collect(); let template = AdminReportsTemplate { page, is_admin: true, reports, filter, }; if clear { Ok((AppendHeaders([flash::clear_flash()]), template).into_response()) } else { Ok(template.into_response()) } } #[derive(Template, WebTemplate)] #[template(path = "admin_report_detail.html")] pub struct AdminReportDetailTemplate { pub page: PageContext, pub is_admin: bool, pub report: ReportDetailView, } pub struct ReportDetailView { pub id: i64, pub content_type: String, pub content_id: String, pub content_url: String, pub reporter_email: String, pub reason: String, pub description: String, pub status: String, pub resolution: String, pub created_at: String, } pub async fn report_detail( headers: HeaderMap, State(state): State, AdminUser(_admin): AdminUser, Path(id): Path, ) -> Result { let page = PageContext::from_request(&headers, &state.announcement); let row = sqlx::query_as::<_, ReportRow>( "SELECT * FROM abuse_reports WHERE id = $1", ) .bind(id) .fetch_optional(&state.db) .await .map_err(|e| { tracing::error!("report detail query failed: {e}"); axum::http::StatusCode::INTERNAL_SERVER_ERROR })? .ok_or(axum::http::StatusCode::NOT_FOUND)?; Ok(AdminReportDetailTemplate { page, is_admin: true, report: ReportDetailView { id: row.id, content_type: row.content_type, content_id: row.content_id, content_url: row.content_url, reporter_email: row.reporter_email.unwrap_or_default(), reason: row.reason, description: row.description.unwrap_or_default(), status: row.status, resolution: row.resolution.unwrap_or_default(), created_at: row.created_at.format("%Y-%m-%d %H:%M UTC").to_string(), }, }) } #[derive(Deserialize)] pub struct ReportActionForm { action: String, } pub async fn report_action( State(state): State, AdminUser(admin): AdminUser, Path(id): Path, Form(form): Form, ) -> Response { let row = match sqlx::query_as::<_, ReportRow>( "SELECT * FROM abuse_reports WHERE id = $1", ) .bind(id) .fetch_optional(&state.db) .await { Ok(Some(r)) => r, Ok(None) => return axum::http::StatusCode::NOT_FOUND.into_response(), Err(e) => { tracing::error!("report query failed: {e}"); return axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response(); } }; match form.action.as_str() { "dismiss" => { let _ = sqlx::query( "UPDATE abuse_reports SET status = 'dismissed', resolution = 'dismissed', resolved_by = $1, resolved_at = NOW() WHERE id = $2", ) .bind(&admin.sub) .bind(id) .execute(&state.db) .await; audit(&state.db, &admin.sub, "dismiss_report", "report", &id.to_string(), None).await; } "hide" => { let internal_url = match row.content_type.as_str() { "image" => format!("{}/api/internal/images/{}/hide", state.pics_url, row.content_id), "paste" => format!("{}/api/internal/pastes/{}/hide", state.txt_url, row.content_id), _ => String::new(), }; if !internal_url.is_empty() { let _ = state.http_client.post(&internal_url).send().await; } let _ = sqlx::query( "UPDATE abuse_reports SET status = 'resolved', resolution = 'content_hidden', resolved_by = $1, resolved_at = NOW() WHERE id = $2", ) .bind(&admin.sub) .bind(id) .execute(&state.db) .await; audit(&state.db, &admin.sub, "hide_content", &row.content_type, &row.content_id, None).await; } "delete" => { let internal_url = match row.content_type.as_str() { "image" => format!("{}/api/internal/images/{}", state.pics_url, row.content_id), "paste" => format!("{}/api/internal/pastes/{}", state.txt_url, row.content_id), _ => String::new(), }; if !internal_url.is_empty() { let _ = state.http_client.delete(&internal_url).send().await; } let _ = sqlx::query( "UPDATE abuse_reports SET status = 'resolved', resolution = 'content_deleted', resolved_by = $1, resolved_at = NOW() WHERE id = $2", ) .bind(&admin.sub) .bind(id) .execute(&state.db) .await; audit(&state.db, &admin.sub, "delete_content", &row.content_type, &row.content_id, None).await; } "suspend" => { let _owner_sub: Option = match row.content_type.as_str() { "image" => { sqlx::query_scalar("SELECT user_id FROM images WHERE id = $1") .bind(&row.content_id) .fetch_optional(&state.db) .await .ok() .flatten() } "paste" => { sqlx::query_scalar("SELECT user_id FROM pastes WHERE id = $1") .bind(&row.content_id) .fetch_optional(&state.db) .await .ok() .flatten() } _ => None, }; // Note: user_id queries above go to accounts-db, but images/pastes are in separate DBs. // The admin panel calls the internal APIs which handle their own DB. // For suspension we just need the user sub which is stored in the report or looked up. // Since we can't query pics/txt DBs from web-api, we skip the auto-lookup // and the admin can suspend from the user detail page instead. let _ = sqlx::query( "UPDATE abuse_reports SET status = 'resolved', resolution = 'user_suspended', resolved_by = $1, resolved_at = NOW() WHERE id = $2", ) .bind(&admin.sub) .bind(id) .execute(&state.db) .await; audit(&state.db, &admin.sub, "suspend_from_report", "report", &id.to_string(), None).await; } _ => {} } ( AppendHeaders([flash::set_flash("report_actioned")]), Redirect::to("/admin/reports"), ) .into_response() } #[derive(FromRow)] struct UserSearchRow { keycloak_sub: String, email: Option, plan: String, is_admin: bool, suspended_at: Option>, created_at: Option>, } #[allow(dead_code)] pub struct UserSearchEntry { pub sub: String, pub email: String, pub plan: String, pub is_admin: bool, pub suspended: bool, pub created_at: String, } #[derive(Template, WebTemplate)] #[template(path = "admin_users.html")] pub struct AdminUsersTemplate { pub page: PageContext, pub is_admin: bool, pub users: Vec, pub query: String, } #[derive(Deserialize)] pub struct UsersQuery { pub q: Option, } pub async fn users( headers: HeaderMap, State(state): State, AdminUser(_admin): AdminUser, Query(q): Query, ) -> Result { let page = PageContext::from_request(&headers, &state.announcement); let query = q.q.unwrap_or_default(); let rows = if query.is_empty() { sqlx::query_as::<_, UserSearchRow>( "SELECT keycloak_sub, email, plan, is_admin, suspended_at, created_at FROM users ORDER BY created_at DESC LIMIT 50", ) .fetch_all(&state.db) .await } else { sqlx::query_as::<_, UserSearchRow>( "SELECT keycloak_sub, email, plan, is_admin, suspended_at, created_at FROM users WHERE email ILIKE $1 OR keycloak_sub = $2 ORDER BY created_at DESC LIMIT 50", ) .bind(format!("%{query}%")) .bind(&query) .fetch_all(&state.db) .await }; let rows = rows.map_err(|e| { tracing::error!("users query failed: {e}"); axum::http::StatusCode::INTERNAL_SERVER_ERROR })?; let users = rows .into_iter() .map(|r| UserSearchEntry { sub: r.keycloak_sub, email: r.email.unwrap_or_default(), plan: r.plan, is_admin: r.is_admin, suspended: r.suspended_at.is_some(), created_at: r .created_at .map(|t| t.format("%Y-%m-%d").to_string()) .unwrap_or_default(), }) .collect(); Ok(AdminUsersTemplate { page, is_admin: true, users, query }) } #[derive(FromRow)] struct UserDetailRow { keycloak_sub: String, email: Option, plan: String, display_name: Option, is_admin: bool, suspended_at: Option>, suspended_reason: Option, created_at: Option>, } #[derive(Template, WebTemplate)] #[template(path = "admin_user_detail.html")] pub struct AdminUserDetailTemplate { pub page: PageContext, pub is_admin: bool, pub user: UserDetailView, pub report_count: i64, } pub struct UserDetailView { pub sub: String, pub email: String, pub display_name: String, pub plan: String, pub is_admin: bool, pub suspended: bool, pub suspended_reason: String, pub created_at: String, } pub async fn user_detail( headers: HeaderMap, State(state): State, AdminUser(_admin): AdminUser, Path(sub): Path, ) -> Result { let page = PageContext::from_request(&headers, &state.announcement); let clear = page.flash.is_some(); let row = sqlx::query_as::<_, UserDetailRow>( "SELECT keycloak_sub, email, plan, display_name, is_admin, suspended_at, suspended_reason, created_at FROM users WHERE keycloak_sub = $1", ) .bind(&sub) .fetch_optional(&state.db) .await .map_err(|e| { tracing::error!("user detail query failed: {e}"); axum::http::StatusCode::INTERNAL_SERVER_ERROR })? .ok_or(axum::http::StatusCode::NOT_FOUND)?; let report_count: (i64,) = sqlx::query_as( "SELECT COUNT(*) FROM abuse_reports WHERE reporter_sub = $1", ) .bind(&sub) .fetch_one(&state.db) .await .unwrap_or((0,)); let template = AdminUserDetailTemplate { page, is_admin: true, user: UserDetailView { sub: row.keycloak_sub, email: row.email.unwrap_or_default(), display_name: row.display_name.unwrap_or_default(), plan: row.plan, is_admin: row.is_admin, suspended: row.suspended_at.is_some(), suspended_reason: row.suspended_reason.unwrap_or_default(), created_at: row .created_at .map(|t| t.format("%Y-%m-%d").to_string()) .unwrap_or_default(), }, report_count: report_count.0, }; if clear { Ok((AppendHeaders([flash::clear_flash()]), template).into_response()) } else { Ok(template.into_response()) } } #[derive(Deserialize)] pub struct SuspendForm { reason: Option, } pub async fn suspend_user( State(state): State, AdminUser(admin): AdminUser, Path(sub): Path, Form(form): Form, ) -> Response { let reason = form.reason.filter(|r| !r.trim().is_empty()); if let Err(e) = set_keycloak_user_enabled(&state, &sub, false).await { tracing::error!("keycloak disable failed for {sub}: {e}"); return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, format!("keycloak disable failed: {e}")).into_response(); } let _ = sqlx::query( "UPDATE users SET suspended_at = NOW(), suspended_reason = $1 WHERE keycloak_sub = $2", ) .bind(&reason) .bind(&sub) .execute(&state.db) .await; audit( &state.db, &admin.sub, "suspend_user", "user", &sub, reason.as_ref().map(|r| serde_json::json!({ "reason": r })), ) .await; ( AppendHeaders([flash::set_flash("user_suspended")]), Redirect::to(&format!("/admin/users/{sub}")), ) .into_response() } pub async fn unsuspend_user( State(state): State, AdminUser(admin): AdminUser, Path(sub): Path, ) -> Response { if let Err(e) = set_keycloak_user_enabled(&state, &sub, true).await { tracing::error!("keycloak enable failed: {e}"); } let _ = sqlx::query( "UPDATE users SET suspended_at = NULL, suspended_reason = NULL WHERE keycloak_sub = $1", ) .bind(&sub) .execute(&state.db) .await; audit(&state.db, &admin.sub, "unsuspend_user", "user", &sub, None).await; ( AppendHeaders([flash::set_flash("user_unsuspended")]), Redirect::to(&format!("/admin/users/{sub}")), ) .into_response() } #[derive(FromRow)] struct AuditRow { admin_sub: String, action: String, target_type: String, target_id: String, created_at: chrono::DateTime, } pub struct AuditEntry { pub admin_sub: String, pub action: String, pub target_type: String, pub target_id: String, pub created_at: String, } #[derive(Template, WebTemplate)] #[template(path = "admin_audit.html")] pub struct AdminAuditTemplate { pub page: PageContext, pub is_admin: bool, pub entries: Vec, } pub async fn audit_log( headers: HeaderMap, State(state): State, AdminUser(_admin): AdminUser, ) -> Result { let page = PageContext::from_request(&headers, &state.announcement); let rows = sqlx::query_as::<_, AuditRow>( "SELECT admin_sub, action, target_type, target_id, created_at FROM admin_audit_log ORDER BY created_at DESC LIMIT 100", ) .fetch_all(&state.db) .await .map_err(|e| { tracing::error!("audit log query failed: {e}"); axum::http::StatusCode::INTERNAL_SERVER_ERROR })?; let entries = rows .into_iter() .map(|r| AuditEntry { admin_sub: r.admin_sub, action: r.action, target_type: r.target_type, target_id: r.target_id, created_at: r.created_at.format("%Y-%m-%d %H:%M UTC").to_string(), }) .collect(); Ok(AdminAuditTemplate { page, entries }) } #[derive(FromRow)] struct StatsRow { label: String, count: i64, } #[derive(Template, WebTemplate)] #[template(path = "admin_stats.html")] pub struct AdminStatsTemplate { pub page: PageContext, pub total_reports: i64, pub pending_reports: i64, pub resolved_reports: i64, pub dismissed_reports: i64, pub by_reason: Vec, } pub struct StatEntry { pub label: String, pub count: i64, } pub async fn stats( headers: HeaderMap, State(state): State, AdminUser(_admin): AdminUser, ) -> Result { let page = PageContext::from_request(&headers, &state.announcement); let total: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM abuse_reports") .fetch_one(&state.db) .await .unwrap_or((0,)); let pending: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM abuse_reports WHERE status = 'pending'") .fetch_one(&state.db) .await .unwrap_or((0,)); let resolved: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM abuse_reports WHERE status = 'resolved'") .fetch_one(&state.db) .await .unwrap_or((0,)); let dismissed: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM abuse_reports WHERE status = 'dismissed'") .fetch_one(&state.db) .await .unwrap_or((0,)); let reason_rows = sqlx::query_as::<_, StatsRow>( "SELECT reason AS label, COUNT(*) AS count FROM abuse_reports GROUP BY reason ORDER BY count DESC", ) .fetch_all(&state.db) .await .unwrap_or_default(); let by_reason = reason_rows .into_iter() .map(|r| StatEntry { label: r.label, count: r.count, }) .collect(); Ok(AdminStatsTemplate { page, total_reports: total.0, pending_reports: pending.0, resolved_reports: resolved.0, dismissed_reports: dismissed.0, by_reason, }) }