diff --git a/cruciverb-server/src/admin.rs b/cruciverb-server/src/admin.rs new file mode 100644 index 0000000..ccd05fc --- /dev/null +++ b/cruciverb-server/src/admin.rs @@ -0,0 +1,1536 @@ +use std::sync::Arc; +use std::collections::HashMap; +use axum::Json; +use axum::extract::{ConnectInfo, Multipart, Path, Query, State}; +use axum::http::{StatusCode, HeaderMap}; +use axum::response::{IntoResponse, Response}; +use sha2::{Sha256, Digest}; +use crate::db; +use crate::state::AppState; + +static STATS_CACHE: std::sync::LazyLock>> = + std::sync::LazyLock::new(|| std::sync::Mutex::new(None)); +const STATS_CACHE_TTL: i64 = 300; + +pub fn invalidate_stats_cache() { + if let Ok(mut c) = STATS_CACHE.lock() { *c = None; } +} + +// admin session store: token -> expiry timestamp +static ADMIN_SESSIONS: std::sync::LazyLock>> = + std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new())); + +// failed login tracker +static LOGIN_ATTEMPTS: std::sync::LazyLock>> = + std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new())); + +const SESSION_TTL: i64 = 3600; // 1 hour +const MAX_ATTEMPTS: u32 = 5; +const LOCKOUT_SECS: i64 = 300; // 5 min lockout after max attempts + +fn env_admin_configured() -> bool { + !std::env::var("ADMIN_USER").unwrap_or_default().is_empty() + && !std::env::var("ADMIN_PASS").unwrap_or_default().is_empty() +} + +fn admin_configured_with_db(db_has_admin: bool) -> bool { + db_has_admin || env_admin_configured() +} + +pub async fn list_outbox( + State(state): State>, + headers: HeaderMap, +) -> Result, Response> { + check_admin(&headers).await?; + let dbi = state.db.lock().await; + let mut stmt = dbi.db.prepare("SELECT id, published FROM ap_outbox ORDER BY published DESC").unwrap(); + let items: Vec = stmt.query_map([], |row| { + let id: String = row.get(0)?; + let published: String = row.get(1)?; + Ok(serde_json::json!({ "id": id, "published": published })) + }).unwrap().filter_map(|r| r.ok()).collect(); + Ok(Json(serde_json::json!({ "items": items }))) +} + +pub async fn delete_outbox_item( + State(state): State>, + headers: HeaderMap, + Path(idx): Path, +) -> Result, Response> { + check_admin(&headers).await?; + let item_id = { + let dbi = state.db.lock().await; + let mut stmt = dbi.db.prepare("SELECT id FROM ap_outbox ORDER BY published DESC").unwrap(); + let ids: Vec = stmt.query_map([], |row| row.get(0)).unwrap().filter_map(|r| r.ok()).collect(); + ids.get(idx).cloned() + }; + if let Some(id) = item_id { + let object_uri = id.strip_suffix("/activity").unwrap_or(&id).to_string(); + crate::activitypub::outbox::publish_delete(&state, &object_uri).await; + // ensure the exact row is gone + let dbi = state.db.lock().await; + dbi.db.execute("DELETE FROM ap_outbox WHERE id = ?1", [&id]).ok(); + db::log_admin_action(&dbi.db, "delete_outbox_item", Some(&id), None); + Ok(Json(serde_json::json!({ "ok": true, "deleted": id }))) + } else { + Err((StatusCode::NOT_FOUND, "item not found").into_response()) + } +} + +pub async fn delete_remote_post( + State(state): State>, + headers: HeaderMap, + Json(req): Json, +) -> Result, Response> { + check_admin(&headers).await?; + let uri = req.get("uri").and_then(|v| v.as_str()) + .ok_or_else(|| (StatusCode::BAD_REQUEST, "missing uri").into_response())?; + crate::activitypub::outbox::publish_delete(&state, uri).await; + db::log_admin_action(&state.db.lock().await.db, "delete_remote_post", Some(uri), None); + Ok(Json(serde_json::json!({ "ok": true }))) +} + +pub fn verify_admin_session(token: &str) -> bool { + if token.is_empty() { return false; } + let now = chrono::Utc::now().timestamp(); + let sessions = ADMIN_SESSIONS.lock().unwrap(); + matches!(sessions.get(token), Some(exp) if *exp > now) +} + +async fn check_admin(headers: &HeaderMap) -> Result<(), Response> { + let token = headers.get("X-Admin-Session") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + if token.is_empty() { + return Err((StatusCode::UNAUTHORIZED, "no admin session").into_response()); + } + let now = chrono::Utc::now().timestamp(); + let mut sessions = ADMIN_SESSIONS.lock().unwrap(); + // clean expired + sessions.retain(|_, exp| *exp > now); + match sessions.get(token) { + Some(exp) if *exp > now => { + // refresh expiry on use + sessions.insert(token.to_string(), now + SESSION_TTL); + Ok(()) + } + _ => Err((StatusCode::UNAUTHORIZED, "expired or invalid session").into_response()), + } +} + +#[axum::debug_handler] +pub async fn admin_login( + State(state): State>, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, + Json(req): Json, +) -> Result, Response> { + let db_has_admin = { + let dbi = state.db.lock().await; + db::has_admin_account(&dbi.db) + }; + + if !admin_configured_with_db(db_has_admin) { + return Err((StatusCode::FORBIDDEN, "admin not configured").into_response()); + } + + let now = chrono::Utc::now().timestamp(); + // the lockout key is the proxy-forwarded IP only when a trusted proxy is + // configured; otherwise use the real socket address so a spoofable header + // cannot reset or trigger lockouts + let ip = if std::env::var("TRUST_PROXY").as_deref() == Ok("true") { + crate::security::extract_client_ip(&headers) + } else { + addr.ip().to_string() + }; + + // check lockout per IP + { + let attempts = LOGIN_ATTEMPTS.lock().unwrap(); + if let Some(&(count, last)) = attempts.get(&ip) { + if count >= MAX_ATTEMPTS && now - last < LOCKOUT_SECS { + let remaining = LOCKOUT_SECS - (now - last); + return Err((StatusCode::TOO_MANY_REQUESTS, + format!("too many failed attempts - try again in {}s", remaining)).into_response()); + } + } + } + + let user = req.get("username").and_then(|v| v.as_str()).unwrap_or(""); + let pass = req.get("password").and_then(|v| v.as_str()).unwrap_or(""); + + // check database admin first, then fall back to env vars + let credentials_ok = if db_has_admin { + let dbi = state.db.lock().await; + db::verify_admin_account(&dbi.db, user, pass) + } else { + let expected_user = std::env::var("ADMIN_USER").unwrap_or_default(); + let expected_pass = std::env::var("ADMIN_PASS").unwrap_or_default(); + let user_ok = user.len() == expected_user.len() + && user.bytes().zip(expected_user.bytes()).all(|(a, b)| a == b); + let pass_ok = pass.len() == expected_pass.len() + && pass.bytes().zip(expected_pass.bytes()).all(|(a, b)| a == b); + user_ok && pass_ok + }; + + if !credentials_ok { + // record failed attempt + { + let mut attempts = LOGIN_ATTEMPTS.lock().unwrap(); + let entry = attempts.entry(ip.clone()).or_insert((0, now)); + if now - entry.1 > LOCKOUT_SECS { *entry = (1, now); } else { entry.0 += 1; entry.1 = now; } + } + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + return Err((StatusCode::UNAUTHORIZED, "invalid credentials").into_response()); + } + + // upgrade legacy salted-sha256 rows to argon2id on first successful login + if db_has_admin { + let dbi = state.db.lock().await; + let stored: Option = dbi.db + .query_row( + "SELECT password_hash FROM admin_accounts WHERE username = ?1", + [user], + |r| r.get(0), + ) + .ok(); + if let Some(s) = stored { + if !s.starts_with("$argon2") { + db::rehash_admin_password(&dbi.db, user, pass); + } + } + } + + // reset failed attempts on success + { LOGIN_ATTEMPTS.lock().unwrap().remove(&ip); } + + // generate random session token + let session_token = { + use rand::Rng; + let mut rng = rand::rng(); + let bytes: Vec = (0..32).map(|_| rng.random::()).collect(); + hex::encode(Sha256::digest(&bytes)) + }; + + ADMIN_SESSIONS.lock().unwrap().insert(session_token.clone(), now + SESSION_TTL); + + { + let dbi = state.db.lock().await; + db::log_admin_action(&dbi.db, "admin_login", None, Some(&format!("ip: {}", ip))); + } + + let path = std::env::var("ADMIN_PATH").unwrap_or_else(|_| "/admin".into()); + Ok(Json(serde_json::json!({ "session": session_token, "path": path }))) +} + +pub async fn admin_config( + State(state): State>, +) -> Json { + let db_has_admin = { + let dbi = state.db.lock().await; + db::has_admin_account(&dbi.db) + }; + Json(serde_json::json!({ + "configured": admin_configured_with_db(db_has_admin), + "path": std::env::var("ADMIN_PATH").unwrap_or_else(|_| "/admin".into()), + })) +} + +pub async fn admin_status( + State(state): State>, +) -> Json { + let db_has_admin = { + let dbi = state.db.lock().await; + db::has_admin_account(&dbi.db) + }; + Json(serde_json::json!({ + "configured": admin_configured_with_db(db_has_admin), + })) +} + +pub async fn admin_setup( + State(state): State>, + Json(req): Json, +) -> Result, Response> { + let dbi = state.db.lock().await; + + if db::has_admin_account(&dbi.db) { + return Err((StatusCode::FORBIDDEN, "admin already configured").into_response()); + } + + let username = req.get("username").and_then(|v| v.as_str()).unwrap_or(""); + let password = req.get("password").and_then(|v| v.as_str()).unwrap_or(""); + + if username.len() < 3 { + return Err((StatusCode::BAD_REQUEST, "username must be at least 3 characters").into_response()); + } + if password.len() < 8 { + return Err((StatusCode::BAD_REQUEST, "password must be at least 8 characters").into_response()); + } + + // hash password with argon2id (memory-hard, replaces the old salted sha256) + let stored = { + use argon2::PasswordHasher; + let salt = + argon2::password_hash::SaltString::generate(&mut argon2::password_hash::rand_core::OsRng); + match argon2::Argon2::default().hash_password(password.as_bytes(), &salt) { + Ok(h) => h.to_string(), + Err(_) => { + return Err((StatusCode::INTERNAL_SERVER_ERROR, "password hashing failed").into_response()) + } + } + }; + + db::create_admin_account(&dbi.db, username, &stored); + db::log_admin_action(&dbi.db, "admin_setup", Some(username), None); + + Ok(Json(serde_json::json!({ "ok": true }))) +} + +pub async fn stats( + State(state): State>, + headers: HeaderMap, +) -> Result, Response> { + check_admin(&headers).await?; + + let now = chrono::Utc::now().timestamp(); + if let Ok(cache) = STATS_CACHE.lock() { + if let Some((ref val, ts)) = *cache { + if now - ts < STATS_CACHE_TTL { + return Ok(Json(val.clone())); + } + } + } + + let dict_words = state.dict.word_count(); + let clue_db_words = state.clue_db.word_count(); + let cryptic_words = state.cryptic_db.as_ref().map(|c| c.word_count()).unwrap_or(0); + let cached_puzzles = state.puzzles.read().await.len(); + let word_quality_count = state.word_quality.read().await.len(); + let clue_quality_count = state.clue_quality.read().await.len(); + + let db_path = state.db_path.clone(); + let db_stats = tokio::task::spawn_blocking(move || { + let conn = rusqlite::Connection::open_with_flags( + &db_path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, + ).map_err(|e| format!("db open failed: {}", e))?; + + // combined counts in a single query + let (sessions, solves, solves_today, active_today): (i64, i64, i64, i64) = conn.query_row( + "SELECT + (SELECT COUNT(*) FROM sessions), + (SELECT COUNT(*) FROM solve_records WHERE is_complete = 1), + (SELECT COUNT(*) FROM solve_records WHERE is_complete = 1 AND completed_at >= date('now')), + (SELECT COUNT(*) FROM sessions WHERE last_seen >= date('now'))", + [], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)) + ).unwrap_or((0, 0, 0, 0)); + + let (puzzles, daily_puzzles, user_puzzles, published_puzzles): (i64, i64, i64, i64) = conn.query_row( + "SELECT + (SELECT COUNT(*) FROM puzzles), + (SELECT COUNT(*) FROM puzzles WHERE daily_date IS NOT NULL), + (SELECT COUNT(*) FROM user_puzzles), + (SELECT COUNT(*) FROM user_puzzles WHERE status = 'published')", + [], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)) + ).unwrap_or((0, 0, 0, 0)); + + let (community_clues, pending_clues, webhooks, passkeys, ratings, achievements): (i64, i64, i64, i64, i64, i64) = conn.query_row( + "SELECT + (SELECT COUNT(*) FROM community_clues), + (SELECT COUNT(*) FROM community_clues WHERE status = 'pending'), + (SELECT COUNT(*) FROM webhooks), + (SELECT COUNT(*) FROM passkeys), + (SELECT COUNT(*) FROM puzzle_ratings), + (SELECT COUNT(*) FROM achievements)", + [], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?)) + ).unwrap_or((0, 0, 0, 0, 0, 0)); + + let (avg_solve_time, fastest_solve, avg_rating): (f64, i64, f64) = conn.query_row( + "SELECT + (SELECT COALESCE(AVG(solve_time_secs), 0) FROM solve_records WHERE is_complete = 1 AND solve_time_secs > 0), + (SELECT COALESCE(MIN(solve_time_secs), 0) FROM solve_records WHERE is_complete = 1 AND solve_time_secs > 0), + (SELECT COALESCE(AVG(CAST(rating AS REAL)), 0) FROM puzzle_ratings)", + [], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)) + ).unwrap_or((0.0, 0, 0.0)); + + let mut preset_stats = Vec::new(); + if let Ok(mut stmt) = conn.prepare( + "SELECT p.preset, COUNT(sr.id), COALESCE(AVG(sr.solve_time_secs), 0) + FROM puzzles p JOIN solve_records sr ON p.id = sr.puzzle_id + WHERE p.preset IS NOT NULL AND sr.is_complete = 1 + GROUP BY p.preset ORDER BY p.preset" + ) { + if let Ok(rows) = stmt.query_map([], |row| { + Ok(serde_json::json!({ + "preset": row.get::<_, String>(0)?, "solves": row.get::<_, i64>(1)?, + "avg_time": (row.get::<_, f64>(2)? * 10.0).round() / 10.0, + })) + }) { + for r in rows.flatten() { preset_stats.push(r); } + } + } + + let mut daily_activity = Vec::new(); + if let Ok(mut stmt) = conn.prepare( + "SELECT DATE(completed_at) as d, COUNT(*) FROM solve_records + WHERE is_complete = 1 AND completed_at >= date('now', '-7 days') + GROUP BY d ORDER BY d" + ) { + if let Ok(rows) = stmt.query_map([], |row| { + Ok(serde_json::json!({ "date": row.get::<_, String>(0)?, "solves": row.get::<_, i64>(1)? })) + }) { + for r in rows.flatten() { daily_activity.push(r); } + } + } + + let mut top_contributors = Vec::new(); + if let Ok(mut stmt) = conn.prepare( + "SELECT cc.submitted_by, CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, COUNT(*), SUM(CASE WHEN cc.status = 'approved' THEN 1 ELSE 0 END) + FROM community_clues cc + LEFT JOIN sessions s ON cc.submitted_by = s.token + LEFT JOIN settings st ON cc.submitted_by = st.session_token + GROUP BY cc.submitted_by ORDER BY COUNT(*) DESC LIMIT 10" + ) { + if let Ok(rows) = stmt.query_map([], |row| { + Ok(serde_json::json!({ + "token": row.get::<_, String>(0)?, + "name": row.get::<_, Option>(1)?.unwrap_or_else(|| "Anonymous".into()), + "submitted": row.get::<_, i64>(2)?, + "approved": row.get::<_, i64>(3)?, + })) + }) { + for r in rows.flatten() { top_contributors.push(r); } + } + } + + let ap_enabled = std::env::var("AP_DOMAIN").is_ok(); + let (ap_followers, ap_peers, ap_queue, ap_blocked_count): (i64, i64, i64, i64) = conn.query_row( + "SELECT + (SELECT COUNT(*) FROM ap_followers WHERE accepted = 1), + (SELECT COUNT(*) FROM ap_peers), + (SELECT COUNT(*) FROM ap_delivery_queue), + (SELECT COUNT(*) FROM ap_blocked)", + [], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)) + ).unwrap_or((0, 0, 0, 0)); + + let (ap_inbound, ap_outbound, ap_key_changes, pending_reports): (i64, i64, i64, i64) = conn.query_row( + "SELECT + (SELECT COALESCE(SUM(count), 0) FROM ap_stats WHERE direction = 'inbound'), + (SELECT COALESCE(SUM(count), 0) FROM ap_stats WHERE direction = 'outbound'), + (SELECT COUNT(*) FROM admin_audit_log WHERE action = 'key_change_detected'), + (SELECT COUNT(*) FROM content_reports WHERE status = 'pending')", + [], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)) + ).unwrap_or((0, 0, 0, 0)); + + Ok::<_, String>(serde_json::json!({ + "sessions": { "total": sessions, "active_today": active_today }, + "solves": { "total": solves, "today": solves_today, "avg_time": (avg_solve_time * 10.0).round() / 10.0, "fastest": fastest_solve }, + "puzzles": { "generated": puzzles, "daily": daily_puzzles, "user_created": user_puzzles, "published": published_puzzles }, + "community_clues": { "total": community_clues, "pending": pending_clues }, + "ratings": { "total": ratings, "average": (avg_rating * 10.0).round() / 10.0 }, + "achievements_unlocked": achievements, + "passkeys": passkeys, + "webhooks": webhooks, + "federation": { "enabled": ap_enabled, "followers": ap_followers, "peers": ap_peers, "queue": ap_queue, + "inbound": ap_inbound, "outbound": ap_outbound, "blocked": ap_blocked_count, + "key_changes": ap_key_changes, "domain": std::env::var("AP_DOMAIN").ok() }, + "preset_stats": preset_stats, + "daily_activity": daily_activity, + "top_contributors": top_contributors, + "pending_reports": pending_reports, + })) + }).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("stats task failed: {}", e)).into_response())? + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e).into_response())?; + + // merge in-memory stats that don't need DB + let mut result = db_stats; + if let Some(obj) = result.as_object_mut() { + obj.insert("dictionary".into(), serde_json::json!({ + "words": dict_words, "clue_entries": clue_db_words, "cryptic_entries": cryptic_words + })); + obj.insert("cache".into(), serde_json::json!({ + "puzzles": cached_puzzles, "word_quality": word_quality_count, "clue_quality": clue_quality_count + })); + } + + if let Ok(mut cache) = STATS_CACHE.lock() { + *cache = Some((result.clone(), now)); + } + + Ok(Json(result)) +} + +pub async fn list_sessions( + State(state): State>, + headers: HeaderMap, +) -> Result, Response> { + check_admin(&headers).await?; + let dbi = state.db.lock().await; + let mut stmt = dbi.db.prepare( + "SELECT s.token, s.display_name, s.created_at, s.last_seen, + (SELECT COUNT(*) FROM solve_records sr WHERE sr.session_token = s.token AND sr.is_complete = 1), + (SELECT current_streak FROM streaks st WHERE st.session_token = s.token), + COALESCE(s.banned, 0) + FROM sessions s ORDER BY s.last_seen DESC LIMIT 100" + ).unwrap(); + let sessions: Vec = stmt.query_map([], |row| { + Ok(serde_json::json!({ + "token": row.get::<_, String>(0)?, + "display_name": row.get::<_, Option>(1)?, + "created_at": row.get::<_, String>(2)?, + "last_seen": row.get::<_, String>(3)?, + "solves": row.get::<_, i64>(4)?, + "streak": row.get::<_, Option>(5)?, + "banned": row.get::<_, i64>(6)? != 0, + })) + }).unwrap().filter_map(|r| r.ok()).collect(); + Ok(Json(serde_json::json!({ "sessions": sessions }))) +} + +pub async fn delete_session( + State(state): State>, + headers: HeaderMap, + Path(token): Path, +) -> Result, Response> { + check_admin(&headers).await?; + let dbi = state.db.lock().await; + dbi.db.execute("DELETE FROM sessions WHERE token = ?1", [&token]).ok(); + dbi.db.execute("DELETE FROM settings WHERE session_token = ?1", [&token]).ok(); + db::log_admin_action(&dbi.db, "delete_session", Some(&token), None); + invalidate_stats_cache(); + Ok(Json(serde_json::json!({ "ok": true }))) +} + +pub async fn ban_user( + State(state): State>, + headers: HeaderMap, + Path(token): Path, +) -> Result, Response> { + check_admin(&headers).await?; + + let puzzle_ids: Vec; + { + let dbi = state.db.lock().await; + puzzle_ids = db::get_user_published_puzzles(&dbi.db, &token); + db::ban_session(&dbi.db, &token); + db::cleanup_user_content(&dbi.db, &token); + db::log_admin_action(&dbi.db, "ban_user", Some(&token), None); + } + + for pid in &puzzle_ids { + if let Some(ap) = state.ap.as_ref() { + let uri = format!("https://{}/ap/community-puzzles/{}", ap.domain, pid); + crate::activitypub::outbox::publish_delete(&state, &uri).await; + } + } + + invalidate_stats_cache(); + Ok(Json(serde_json::json!({ "ok": true, "cleaned_puzzles": puzzle_ids.len() }))) +} + +pub async fn unban_user( + State(state): State>, + headers: HeaderMap, + Path(token): Path, +) -> Result, Response> { + check_admin(&headers).await?; + let dbi = state.db.lock().await; + db::unban_session(&dbi.db, &token); + db::log_admin_action(&dbi.db, "unban_user", Some(&token), None); + Ok(Json(serde_json::json!({ "ok": true }))) +} + +pub async fn list_pending_clues( + State(state): State>, + headers: HeaderMap, +) -> Result, Response> { + check_admin(&headers).await?; + let dbi = state.db.lock().await; + let mut stmt = dbi.db.prepare( + "SELECT id, word, clue_text, submitted_by, upvotes, downvotes, status, created_at + FROM community_clues WHERE status = 'pending' ORDER BY created_at DESC LIMIT 100" + ).unwrap(); + let clues: Vec = stmt.query_map([], |row| { + Ok(serde_json::json!({ + "id": row.get::<_, i64>(0)?, + "word": row.get::<_, String>(1)?, + "clue": row.get::<_, String>(2)?, + "submitted_by": row.get::<_, String>(3)?, + "upvotes": row.get::<_, i64>(4)?, + "downvotes": row.get::<_, i64>(5)?, + "status": row.get::<_, String>(6)?, + "created_at": row.get::<_, String>(7)?, + })) + }).unwrap().filter_map(|r| r.ok()).collect(); + Ok(Json(serde_json::json!({ "clues": clues }))) +} + +pub async fn moderate_clue( + State(state): State>, + headers: HeaderMap, + Path(id): Path, + Json(req): Json, +) -> Result, Response> { + check_admin(&headers).await?; + let action = req.get("action").and_then(|v| v.as_str()).unwrap_or(""); + let dbi = state.db.lock().await; + // get origin info before moderation + let (origin_domain, origin_id): (Option, Option) = dbi.db.query_row( + "SELECT origin_domain, origin_id FROM community_clues WHERE id = ?1", + [id], |row| Ok((row.get(0)?, row.get(1)?)), + ).unwrap_or((None, None)); + + match action { + "approve" => { dbi.db.execute("UPDATE community_clues SET status = 'approved' WHERE id = ?1", [id]).ok(); } + "reject" => { dbi.db.execute("UPDATE community_clues SET status = 'rejected' WHERE id = ?1", [id]).ok(); } + "delete" => { dbi.db.execute("DELETE FROM community_clues WHERE id = ?1", [id]).ok(); } + _ => {} + } + + // federate Delete for locally-originated clues + if action == "delete" { + if let Some(ref oid) = origin_id { + if oid.contains(&state.ap.as_ref().map(|a| a.domain.as_str()).unwrap_or("")) { + drop(dbi); + crate::activitypub::outbox::publish_delete(&state, oid).await; + let dbi = state.db.lock().await; + // update reputation below uses dbi, re-acquire and proceed + if let Some(ref domain) = origin_domain { + match action { + "approve" => { db::update_instance_reputation(&dbi.db, domain, 1.0); } + "reject" | "delete" => { db::update_instance_reputation(&dbi.db, domain, -5.0); } + _ => {} + } + } + db::log_admin_action(&dbi.db, "moderate_clue", Some(&id.to_string()), Some(action)); + return Ok(Json(serde_json::json!({ "ok": true }))); + } + } + } + + // update instance reputation for federated clues + if let Some(ref domain) = origin_domain { + match action { + "approve" => { db::update_instance_reputation(&dbi.db, domain, 1.0); } + "reject" | "delete" => { db::update_instance_reputation(&dbi.db, domain, -5.0); } + _ => {} + } + } + + db::log_admin_action(&dbi.db, "moderate_clue", Some(&id.to_string()), Some(action)); + invalidate_stats_cache(); + Ok(Json(serde_json::json!({ "ok": true }))) +} + +pub async fn list_all_puzzles( + State(state): State>, + headers: HeaderMap, +) -> Result, Response> { + check_admin(&headers).await?; + let dbi = state.db.lock().await; + let mut stmt = dbi.db.prepare( + "SELECT p.id, p.short_id, p.daily_date, p.preset, p.width, p.height, p.difficulty, p.created_at, + (SELECT COUNT(*) FROM solve_records sr WHERE sr.puzzle_id = p.id AND sr.is_complete = 1), + (SELECT COALESCE(AVG(CAST(pr.rating AS REAL)), 0) FROM puzzle_ratings pr WHERE pr.puzzle_id = p.id) + FROM puzzles p ORDER BY p.created_at DESC LIMIT 100" + ).unwrap(); + let puzzles: Vec = stmt.query_map([], |row| { + Ok(serde_json::json!({ + "id": row.get::<_, String>(0)?, + "short_id": row.get::<_, String>(1)?, + "daily_date": row.get::<_, Option>(2)?, + "preset": row.get::<_, Option>(3)?, + "width": row.get::<_, i64>(4)?, + "height": row.get::<_, i64>(5)?, + "difficulty": row.get::<_, i64>(6)?, + "created_at": row.get::<_, String>(7)?, + "solves": row.get::<_, i64>(8)?, + "rating": (row.get::<_, f64>(9)? * 10.0).round() / 10.0, + })) + }).unwrap().filter_map(|r| r.ok()).collect(); + Ok(Json(serde_json::json!({ "puzzles": puzzles }))) +} + +pub async fn delete_puzzle( + State(state): State>, + headers: HeaderMap, + Path(id): Path, +) -> Result, Response> { + check_admin(&headers).await?; + let dbi = state.db.lock().await; + dbi.db.execute("DELETE FROM puzzles WHERE id = ?1", [&id]).ok(); + dbi.db.execute("DELETE FROM user_puzzles WHERE id = ?1", [&id]).ok(); + db::log_admin_action(&dbi.db, "delete_puzzle", Some(&id), None); + drop(dbi); + if let Ok(uuid) = uuid::Uuid::parse_str(&id) { + state.puzzles.write().await.remove(&uuid); + } + invalidate_stats_cache(); + Ok(Json(serde_json::json!({ "ok": true }))) +} + +pub async fn achievement_stats( + State(state): State>, + headers: HeaderMap, +) -> Result, Response> { + check_admin(&headers).await?; + let dbi = state.db.lock().await; + let all = crate::achievements::all_achievements(); + let mut stats: Vec = Vec::new(); + for (id, name, desc, icon, c1, c2) in &all { + let count: i64 = dbi.db.query_row( + "SELECT COUNT(*) FROM achievements WHERE achievement_id = ?1", [id], |r| r.get(0) + ).unwrap_or(0); + stats.push(serde_json::json!({ + "id": id, "name": name, "description": desc, "icon": icon, + "color1": c1, "color2": c2, "unlock_count": count, + })); + } + stats.sort_by(|a, b| b["unlock_count"].as_i64().cmp(&a["unlock_count"].as_i64())); + Ok(Json(serde_json::json!({ "achievements": stats }))) +} + +pub async fn test_webhook( + State(state): State>, + headers: HeaderMap, + Path(id): Path, +) -> Result, Response> { + check_admin(&headers).await?; + let dbi = state.db.lock().await; + let hook = dbi.db.query_row( + "SELECT url, format FROM webhooks WHERE id = ?1", [&id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ).ok(); + drop(dbi); + if let Some((_url, _format)) = hook { + crate::webhooks::fire_event(&state, "test", serde_json::json!({ + "description": "Test webhook from cruciverb admin panel", + })).await; + let dbi = state.db.lock().await; + db::log_admin_action(&dbi.db, "test_webhook", Some(&id), None); + Ok(Json(serde_json::json!({ "ok": true }))) + } else { + Ok(Json(serde_json::json!({ "ok": false, "error": "webhook not found" }))) + } +} + +pub async fn purge_old_sessions( + State(state): State>, + headers: HeaderMap, + Json(req): Json, +) -> Result, Response> { + check_admin(&headers).await?; + let days = req.get("older_than_days").and_then(|v| v.as_i64()).unwrap_or(90); + let dbi = state.db.lock().await; + let cutoff = chrono::Utc::now() - chrono::Duration::days(days); + let cutoff_str = cutoff.to_rfc3339(); + let deleted: i64 = dbi.db.execute( + "DELETE FROM sessions WHERE last_seen < ?1", [&cutoff_str] + ).map(|n| n as i64).unwrap_or(0); + db::log_admin_action(&dbi.db, "purge_sessions", None, Some(&format!("deleted {} older than {} days", deleted, days))); + invalidate_stats_cache(); + Ok(Json(serde_json::json!({ "deleted": deleted }))) +} + +pub async fn connect_peer( + State(state): State>, + headers: HeaderMap, + Json(req): Json, +) -> Result, Response> { + check_admin(&headers).await?; + let domain = req.get("domain").and_then(|v| v.as_str()) + .ok_or_else(|| (StatusCode::BAD_REQUEST, "missing domain").into_response())?; + + // #7: reject http:// domains - must always use HTTPS for federation + if domain.starts_with("http://") { + return Err((StatusCode::BAD_REQUEST, "federation requires HTTPS - plain HTTP is not allowed").into_response()); + } + + // check not blocked + { + let dbi = state.db.lock().await; + let blocked: bool = dbi.db.query_row( + "SELECT COUNT(*) > 0 FROM ap_blocked WHERE domain = ?1", [domain], |r| r.get(0) + ).unwrap_or(false); + if blocked { + return Err((StatusCode::BAD_REQUEST, "this instance is blocked").into_response()); + } + } + + // fetch the remote actor document + let actor_uri = format!("https://{}/@cruciverb", domain); + let client = reqwest::Client::new(); + let resp = client.get(&actor_uri) + .header("Accept", "application/activity+json") + .send().await + .map_err(|e| (StatusCode::BAD_GATEWAY, format!("failed to reach {}: {}", domain, e)).into_response())?; + + if !resp.status().is_success() { + return Err((StatusCode::BAD_GATEWAY, format!("{} returned {}", domain, resp.status())).into_response()); + } + + let actor: serde_json::Value = resp.json().await + .map_err(|_| (StatusCode::BAD_GATEWAY, "invalid actor document").into_response())?; + + let inbox = actor.get("inbox").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let public_key_pem = actor.get("publicKey") + .and_then(|pk| pk.get("publicKeyPem")) + .and_then(|v| v.as_str()) + .unwrap_or("").to_string(); + + if inbox.is_empty() || public_key_pem.is_empty() { + return Err((StatusCode::BAD_GATEWAY, "actor missing inbox or public key").into_response()); + } + + let dbi = state.db.lock().await; + let now = chrono::Utc::now().to_rfc3339(); + dbi.db.execute( + "INSERT OR REPLACE INTO ap_peers (domain, actor_uri, inbox_uri, public_pem, last_seen, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?5)", + rusqlite::params![domain, actor_uri, inbox, public_key_pem, now], + ).ok(); + + // also send a Follow to initiate mutual federation + if let Some(ref ap) = state.ap { + let follow = serde_json::json!({ + "@context": "https://www.w3.org/ns/activitystreams", + "id": format!("{}/follow/{}", ap.actor_uri, uuid::Uuid::new_v4()), + "type": "Follow", + "actor": ap.actor_uri, + "object": actor_uri, + }); + let payload = serde_json::to_string(&follow).unwrap(); + let retry = chrono::Utc::now().to_rfc3339(); + dbi.db.execute( + "INSERT INTO ap_delivery_queue (inbox_uri, payload, next_retry, created_at) VALUES (?1, ?2, ?3, ?3)", + rusqlite::params![inbox, payload, retry], + ).ok(); + } + + db::log_admin_action(&dbi.db, "connect_peer", Some(domain), None); + Ok(Json(serde_json::json!({ "ok": true, "domain": domain, "inbox": inbox }))) +} + +pub async fn disconnect_peer( + State(state): State>, + headers: HeaderMap, + Path(domain): Path, +) -> Result, Response> { + check_admin(&headers).await?; + let dbi = state.db.lock().await; + dbi.db.execute("DELETE FROM ap_peers WHERE domain = ?1", [&domain]).ok(); + dbi.db.execute("DELETE FROM ap_followers WHERE actor_uri LIKE '%' || ?1 || '%'", [&domain]).ok(); + dbi.db.execute("DELETE FROM ap_delivery_queue WHERE inbox_uri LIKE '%' || ?1 || '%'", [&domain]).ok(); + db::log_admin_action(&dbi.db, "disconnect_peer", Some(&domain), None); + Ok(Json(serde_json::json!({ "ok": true }))) +} + +pub async fn block_instance( + State(state): State>, + headers: HeaderMap, + Json(req): Json, +) -> Result, Response> { + check_admin(&headers).await?; + let domain = req.get("domain").and_then(|v| v.as_str()) + .ok_or_else(|| (StatusCode::BAD_REQUEST, "missing domain").into_response())?; + let reason = req.get("reason").and_then(|v| v.as_str()).unwrap_or(""); + + let dbi = state.db.lock().await; + let now = chrono::Utc::now().to_rfc3339(); + dbi.db.execute( + "INSERT OR REPLACE INTO ap_blocked (domain, reason, blocked_at) VALUES (?1, ?2, ?3)", + rusqlite::params![domain, reason, now], + ).ok(); + // also remove as peer and follower + dbi.db.execute("DELETE FROM ap_peers WHERE domain = ?1", [domain]).ok(); + dbi.db.execute("DELETE FROM ap_followers WHERE actor_uri LIKE '%' || ?1 || '%'", [domain]).ok(); + dbi.db.execute("DELETE FROM ap_delivery_queue WHERE inbox_uri LIKE '%' || ?1 || '%'", [domain]).ok(); + // remove any federated clues from this domain + dbi.db.execute("DELETE FROM community_clues WHERE origin_domain = ?1", [domain]).ok(); + + db::log_admin_action(&dbi.db, "block_instance", Some(domain), Some(reason)); + let domain_owned = domain.to_string(); + drop(dbi); + crate::activitypub::outbox::publish_blocklist_update(&state, &domain_owned, "block").await; + Ok(Json(serde_json::json!({ "ok": true }))) +} + +pub async fn unblock_instance( + State(state): State>, + headers: HeaderMap, + Path(domain): Path, +) -> Result, Response> { + check_admin(&headers).await?; + let dbi = state.db.lock().await; + dbi.db.execute("DELETE FROM ap_blocked WHERE domain = ?1", [&domain]).ok(); + db::log_admin_action(&dbi.db, "unblock_instance", Some(&domain), None); + drop(dbi); + crate::activitypub::outbox::publish_blocklist_update(&state, &domain, "unblock").await; + Ok(Json(serde_json::json!({ "ok": true }))) +} + +pub async fn list_peers( + State(state): State>, + headers: HeaderMap, +) -> Result, Response> { + check_admin(&headers).await?; + let dbi = state.db.lock().await; + + let mut stmt = dbi.db.prepare( + "SELECT domain, actor_uri, last_seen FROM ap_peers ORDER BY last_seen DESC" + ).unwrap(); + let peers: Vec = stmt.query_map([], |row| { + Ok(serde_json::json!({ + "domain": row.get::<_, String>(0)?, + "actor_uri": row.get::<_, String>(1)?, + "last_seen": row.get::<_, Option>(2)?, + })) + }).unwrap().filter_map(|r| r.ok()).collect(); + + let mut stmt2 = dbi.db.prepare( + "SELECT domain, reason, blocked_at FROM ap_blocked ORDER BY blocked_at DESC" + ).unwrap(); + let blocked: Vec = stmt2.query_map([], |row| { + Ok(serde_json::json!({ + "domain": row.get::<_, String>(0)?, + "reason": row.get::<_, Option>(1)?, + "blocked_at": row.get::<_, String>(2)?, + })) + }).unwrap().filter_map(|r| r.ok()).collect(); + + let reputations = db::get_all_instance_reputations(&dbi.db); + let rep_result: Vec = reputations.iter().map(|(domain, score, total, rejected, reports, updated)| { + serde_json::json!({ + "domain": domain, "score": (*score * 10.0).round() / 10.0, + "total_clues": total, "rejected_clues": rejected, + "reports_against": reports, "last_updated": updated, + }) + }).collect(); + + Ok(Json(serde_json::json!({ "peers": peers, "blocked": blocked, "reputations": rep_result }))) +} + +pub async fn export_blocklist( + State(state): State>, + headers: HeaderMap, +) -> Result { + check_admin(&headers).await?; + let dbi = state.db.lock().await; + let mut stmt = dbi.db.prepare("SELECT domain, reason, blocked_at FROM ap_blocked ORDER BY domain").unwrap(); + let entries: Vec = stmt.query_map([], |row| { + Ok(serde_json::json!({ + "domain": row.get::<_, String>(0)?, + "reason": row.get::<_, Option>(1)?, + "blocked_at": row.get::<_, String>(2)?, + })) + }).unwrap().filter_map(|r| r.ok()).collect(); + + let body = serde_json::json!({ + "format": "cruciverb-blocklist-v1", + "exported_at": chrono::Utc::now().to_rfc3339(), + "count": entries.len(), + "blocked": entries, + }); + + Ok(( + [(axum::http::header::CONTENT_TYPE, "application/json"), + (axum::http::header::CONTENT_DISPOSITION, "attachment; filename=\"blocklist.json\"")], + serde_json::to_string_pretty(&body).unwrap(), + ).into_response()) +} + +pub async fn import_blocklist( + State(state): State>, + headers: HeaderMap, + Json(req): Json, +) -> Result, Response> { + check_admin(&headers).await?; + + let entries = req.get("blocked").and_then(|v| v.as_array()) + .or_else(|| req.get("domains").and_then(|v| v.as_array())) + .ok_or_else(|| (StatusCode::BAD_REQUEST, "missing blocked or domains array").into_response())?; + + let dbi = state.db.lock().await; + let now = chrono::Utc::now().to_rfc3339(); + let mut imported = 0; + + for entry in entries { + let domain = if let Some(s) = entry.as_str() { + s.to_string() + } else if let Some(d) = entry.get("domain").and_then(|v| v.as_str()) { + d.to_string() + } else { + continue; + }; + + let domain = domain.trim().to_lowercase(); + if domain.is_empty() { continue; } + + let reason = entry.get("reason").and_then(|v| v.as_str()).unwrap_or("imported from blocklist"); + + dbi.db.execute( + "INSERT OR IGNORE INTO ap_blocked (domain, reason, blocked_at) VALUES (?1, ?2, ?3)", + rusqlite::params![domain, reason, now], + ).ok(); + // also remove from peers/followers + dbi.db.execute("DELETE FROM ap_peers WHERE domain = ?1", [&domain]).ok(); + dbi.db.execute("DELETE FROM ap_followers WHERE actor_uri LIKE '%' || ?1 || '%'", [&domain]).ok(); + dbi.db.execute("DELETE FROM ap_delivery_queue WHERE inbox_uri LIKE '%' || ?1 || '%'", [&domain]).ok(); + dbi.db.execute("DELETE FROM community_clues WHERE origin_domain = ?1", [&domain]).ok(); + imported += 1; + } + + db::log_admin_action(&dbi.db, "import_blocklist", None, Some(&format!("imported {} domains", imported))); + Ok(Json(serde_json::json!({ "imported": imported }))) +} + +pub async fn audit_log( + State(state): State>, + headers: HeaderMap, +) -> Result, Response> { + check_admin(&headers).await?; + let dbi = state.db.lock().await; + let entries = db::get_audit_log(&dbi.db, 100); + let result: Vec = entries.iter().map(|(id, action, target, detail, ts)| { + serde_json::json!({ + "id": id, "action": action, "target": target, "detail": detail, "timestamp": ts, + }) + }).collect(); + Ok(Json(serde_json::json!({ "entries": result }))) +} + +pub async fn list_reports( + State(state): State>, + headers: HeaderMap, +) -> Result, Response> { + check_admin(&headers).await?; + let dbi = state.db.lock().await; + let reports = db::get_pending_reports(&dbi.db); + let result: Vec = reports.iter().map(|(id, reporter, ctype, cid, reason, created)| { + serde_json::json!({ + "id": id, "reporter": reporter, "content_type": ctype, + "content_id": cid, "reason": reason, "created_at": created, + }) + }).collect(); + Ok(Json(serde_json::json!({ "reports": result }))) +} + +pub async fn resolve_report( + State(state): State>, + headers: HeaderMap, + Path(id): Path, + Json(req): Json, +) -> Result, Response> { + check_admin(&headers).await?; + let action = req.get("action").and_then(|v| v.as_str()).unwrap_or("dismissed"); + let status = if action == "remove" { "removed" } else { "dismissed" }; + let dbi = state.db.lock().await; + db::resolve_report(&dbi.db, id, status); + db::log_admin_action(&dbi.db, "resolve_report", Some(&id.to_string()), Some(status)); + Ok(Json(serde_json::json!({ "ok": true }))) +} + +// #5: follower approval endpoints +pub async fn list_pending_followers( + State(state): State>, + headers: HeaderMap, +) -> Result, Response> { + check_admin(&headers).await?; + let dbi = state.db.lock().await; + let mut stmt = dbi.db.prepare( + "SELECT actor_uri, inbox_uri, created_at FROM ap_followers WHERE accepted = 0 ORDER BY created_at DESC" + ).unwrap(); + let followers: Vec = stmt.query_map([], |row| { + Ok(serde_json::json!({ + "actor_uri": row.get::<_, String>(0)?, + "inbox_uri": row.get::<_, String>(1)?, + "created_at": row.get::<_, String>(2)?, + })) + }).unwrap().filter_map(|r| r.ok()).collect(); + Ok(Json(serde_json::json!({ "pending_followers": followers }))) +} + +pub async fn approve_follower( + State(state): State>, + headers: HeaderMap, + Json(req): Json, +) -> Result, Response> { + check_admin(&headers).await?; + let actor_uri = req.get("actor_uri").and_then(|v| v.as_str()) + .ok_or_else(|| (StatusCode::BAD_REQUEST, "missing actor_uri").into_response())?; + + let dbi = state.db.lock().await; + dbi.db.execute("UPDATE ap_followers SET accepted = 1 WHERE actor_uri = ?1", [actor_uri]).ok(); + + // send Accept activity + if let Some(ref ap) = state.ap { + let inbox: String = dbi.db.query_row( + "SELECT inbox_uri FROM ap_followers WHERE actor_uri = ?1", + [actor_uri], |r| r.get(0), + ).unwrap_or_default(); + if !inbox.is_empty() { + let accept = serde_json::json!({ + "@context": "https://www.w3.org/ns/activitystreams", + "id": format!("{}/accept/{}", ap.actor_uri, uuid::Uuid::new_v4()), + "type": "Accept", + "actor": &ap.actor_uri, + "object": { "type": "Follow", "actor": actor_uri }, + }); + let payload = serde_json::to_string(&accept).unwrap(); + let retry = chrono::Utc::now().to_rfc3339(); + dbi.db.execute( + "INSERT INTO ap_delivery_queue (inbox_uri, payload, next_retry, created_at) VALUES (?1, ?2, ?3, ?3)", + rusqlite::params![inbox, payload, retry], + ).ok(); + } + } + + db::log_admin_action(&dbi.db, "approve_follower", Some(actor_uri), None); + Ok(Json(serde_json::json!({ "ok": true }))) +} + +pub async fn reject_follower( + State(state): State>, + headers: HeaderMap, + Json(req): Json, +) -> Result, Response> { + check_admin(&headers).await?; + let actor_uri = req.get("actor_uri").and_then(|v| v.as_str()) + .ok_or_else(|| (StatusCode::BAD_REQUEST, "missing actor_uri").into_response())?; + + let dbi = state.db.lock().await; + dbi.db.execute("DELETE FROM ap_followers WHERE actor_uri = ?1", [actor_uri]).ok(); + db::log_admin_action(&dbi.db, "reject_follower", Some(actor_uri), None); + Ok(Json(serde_json::json!({ "ok": true }))) +} + +// #16: federation freeze +pub async fn clear_outbox( + State(state): State>, + headers: HeaderMap, +) -> Result, Response> { + check_admin(&headers).await?; + let items: Vec = { + let dbi = state.db.lock().await; + let mut stmt = dbi.db.prepare("SELECT id FROM ap_outbox").unwrap(); + stmt.query_map([], |row| row.get::<_, String>(0)).unwrap() + .filter_map(|r| r.ok()).collect() + }; + for item_id in &items { + let object_uri = item_id.strip_suffix("/activity").unwrap_or(item_id); + crate::activitypub::outbox::publish_delete(&state, object_uri).await; + } + // ensure outbox is fully empty + let dbi = state.db.lock().await; + let remaining = dbi.db.execute("DELETE FROM ap_outbox", []).unwrap_or(0); + db::log_admin_action(&dbi.db, "clear_outbox", None, Some(&format!("{} deleted, {} remaining cleared", items.len(), remaining))); + Ok(Json(serde_json::json!({ "ok": true, "deleted": items.len() }))) +} + +pub async fn federation_freeze( + State(state): State>, + headers: HeaderMap, +) -> Result, Response> { + check_admin(&headers).await?; + crate::activitypub::set_frozen(true); + let dbi = state.db.lock().await; + db::log_admin_action(&dbi.db, "federation_freeze", None, Some("federation frozen")); + Ok(Json(serde_json::json!({ "ok": true, "frozen": true }))) +} + +pub async fn federation_unfreeze( + State(state): State>, + headers: HeaderMap, +) -> Result, Response> { + check_admin(&headers).await?; + crate::activitypub::set_frozen(false); + let dbi = state.db.lock().await; + db::log_admin_action(&dbi.db, "federation_unfreeze", None, Some("federation unfrozen")); + Ok(Json(serde_json::json!({ "ok": true, "frozen": false }))) +} + +pub async fn federation_status( + State(state): State>, + headers: HeaderMap, +) -> Result, Response> { + check_admin(&headers).await?; + let dbi = state.db.lock().await; + Ok(Json(serde_json::json!({ + "frozen": crate::activitypub::is_frozen(), + "allowlist_mode": db::get_config_bool(&dbi.db, "AP_ALLOWLIST_MODE"), + "approve_followers": db::get_config_bool(&dbi.db, "AP_APPROVE_FOLLOWERS"), + "new_peer_review": db::get_config_bool(&dbi.db, "AP_NEW_PEER_REVIEW"), + "outbox_limit": db::get_config_or_env(&dbi.db, "AP_OUTBOX_LIMIT", "20").parse::().unwrap_or(20), + }))) +} + +pub async fn admin_full_config( + State(state): State>, + headers: HeaderMap, +) -> Result, Response> { + check_admin(&headers).await?; + let dbi = state.db.lock().await; + Ok(Json(db::get_all_config(&dbi.db))) +} + +pub async fn update_config( + State(state): State>, + headers: HeaderMap, + Json(req): Json, +) -> Result, Response> { + check_admin(&headers).await?; + + // only these keys can be changed from the admin panel + let editable = [ + "CORS_ORIGIN", "SECURE_COOKIES", "HSTS", "PUBLIC_MOD_LOG", + "INSTANCE_CONTACT", "INSTANCE_RULES", + "AP_ALLOWLIST_MODE", "AP_APPROVE_FOLLOWERS", "AP_NEW_PEER_REVIEW", + "AP_OUTBOX_LIMIT", "AP_SHARED_BLOCKLIST", + "ap_actor_name", "ap_bio", "instance_rules", + "ap_daily_taglines", "ap_daily_signoffs", "ap_community_middles", + "admin_path", + ]; + let profile_keys = ["ap_actor_name", "ap_bio"]; + + let dbi = state.db.lock().await; + let mut updated = Vec::new(); + + if let Some(obj) = req.as_object() { + for (key, value) in obj { + if !editable.contains(&key.as_str()) { continue; } + let val = match value { + serde_json::Value::Bool(b) => if *b { "true" } else { "false" }.to_string(), + serde_json::Value::Number(n) => n.to_string(), + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Array(_) => serde_json::to_string(value).unwrap_or_default(), + _ => continue, + }; + db::set_server_config(&dbi.db, key, &val); + updated.push(key.clone()); + } + } + + if !updated.is_empty() { + db::log_admin_action(&dbi.db, "update_config", None, Some(&format!("changed: {}", updated.join(", ")))); + } + + let needs_actor_update = updated.iter().any(|k| profile_keys.contains(&k.as_str())); + let result = db::get_all_config(&dbi.db); + drop(dbi); + + if needs_actor_update { + crate::activitypub::outbox::publish_actor_update(&state).await; + } + + Ok(Json(result)) +} + +pub async fn add_blocked_word( + State(state): State>, + headers: HeaderMap, + Json(req): Json, +) -> Result, Response> { + check_admin(&headers).await?; + let word = req.get("word").and_then(|v| v.as_str()).unwrap_or(""); + if word.trim().is_empty() { + return Err((StatusCode::BAD_REQUEST, "word is required").into_response()); + } + { + let mut bl = state.blocklist.write().unwrap(); + bl.add_word(word); + } + let data_dir = std::env::var("DATA_DIR").unwrap_or_else(|_| "./data".into()); + let path = std::path::PathBuf::from(data_dir).join("banned_words.txt"); + if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&path) { + use std::io::Write; + let _ = writeln!(f, "{}", word.trim().to_uppercase()); + } + let dbi = state.db.lock().await; + db::log_admin_action(&dbi.db, "add_blocked_word", None, Some(word)); + Ok(Json(serde_json::json!({ "ok": true }))) +} + +fn allowed_image_ext(content_type: &str) -> Option<&'static str> { + match content_type { + "image/jpeg" | "image/jpg" => Some("jpg"), + "image/png" => Some("png"), + "image/gif" => Some("gif"), + "image/webp" => Some("webp"), + _ => None, + } +} + +async fn handle_image_upload( + state: &Arc, + headers: &HeaderMap, + mut multipart: Multipart, + kind: &str, +) -> Result, Response> { + check_admin(headers).await?; + + while let Some(field) = multipart.next_field().await.map_err(|e| { + (StatusCode::BAD_REQUEST, format!("multipart error: {}", e)).into_response() + })? { + let ct = field.content_type().unwrap_or("application/octet-stream").to_string(); + let ext = allowed_image_ext(&ct) + .ok_or_else(|| (StatusCode::BAD_REQUEST, "unsupported image type - use jpeg, png, gif, or webp").into_response())?; + + let data = field.bytes().await.map_err(|e| { + (StatusCode::BAD_REQUEST, format!("read error: {}", e)).into_response() + })?; + + if data.len() > 2 * 1024 * 1024 { + return Err((StatusCode::BAD_REQUEST, "file too large - max 2MB").into_response()); + } + + let data_dir = std::env::var("DATA_DIR").unwrap_or_else(|_| "./data".into()); + let dir = std::path::PathBuf::from(&data_dir); + std::fs::create_dir_all(&dir).ok(); + + // remove old files + for old_ext in ["jpg", "png", "gif", "webp"] { + let old = dir.join(format!("{}.{}", kind, old_ext)); + std::fs::remove_file(&old).ok(); + } + + let filename = format!("{}.{}", kind, ext); + let path = dir.join(&filename); + std::fs::write(&path, &data).map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, format!("write error: {}", e)).into_response() + })?; + + let config_key = format!("ap_{}_path", kind); + { + let dbi = state.db.lock().await; + db::set_server_config(&dbi.db, &config_key, &filename); + db::log_admin_action(&dbi.db, &format!("upload_{}", kind), None, Some(&filename)); + } + + // publish actor update to followers + crate::activitypub::outbox::publish_actor_update(state).await; + + return Ok(Json(serde_json::json!({ "ok": true, "filename": filename }))); + } + + Err((StatusCode::BAD_REQUEST, "no file in request").into_response()) +} + +pub async fn upload_avatar( + State(state): State>, + headers: HeaderMap, + multipart: Multipart, +) -> Result, Response> { + handle_image_upload(&state, &headers, multipart, "avatar").await +} + +pub async fn upload_banner( + State(state): State>, + headers: HeaderMap, + multipart: Multipart, +) -> Result, Response> { + handle_image_upload(&state, &headers, multipart, "banner").await +} + +pub async fn serve_image( + State(state): State>, + kind: &str, +) -> Result { + let config_key = format!("ap_{}_path", kind); + let filename = { + let dbi = state.db.lock().await; + db::get_server_config(&dbi.db, &config_key) + }; + + let filename = filename.ok_or_else(|| (StatusCode::NOT_FOUND, "not found").into_response())?; + + let data_dir = std::env::var("DATA_DIR").unwrap_or_else(|_| "./data".into()); + let path = std::path::PathBuf::from(&data_dir).join(&filename); + + let data = std::fs::read(&path) + .map_err(|_| (StatusCode::NOT_FOUND, "not found").into_response())?; + + let ct = if filename.ends_with(".png") { "image/png" } + else if filename.ends_with(".gif") { "image/gif" } + else if filename.ends_with(".webp") { "image/webp" } + else { "image/jpeg" }; + + Ok(( + [ + (axum::http::header::CONTENT_TYPE, ct), + (axum::http::header::CACHE_CONTROL, "public, max-age=86400"), + ], + data, + ).into_response()) +} + +pub async fn serve_avatar( + State(state): State>, +) -> Result { + serve_image(State(state), "avatar").await +} + +pub async fn serve_banner( + State(state): State>, +) -> Result { + serve_image(State(state), "banner").await +} + +pub async fn ap_humor_defaults() -> Json { + let taglines: Vec<&str> = crate::activitypub::outbox::TAGLINES.to_vec(); + let signoffs: Vec<&str> = crate::activitypub::outbox::SIGNOFFS.to_vec(); + let middles: Vec<&str> = crate::activitypub::outbox::COMMUNITY_MIDDLES.to_vec(); + Json(serde_json::json!({ + "taglines": taglines, + "signoffs": signoffs, + "middles": middles, + })) +} + +pub async fn list_community_puzzles( + State(state): State>, + headers: HeaderMap, +) -> Result, Response> { + check_admin(&headers).await?; + let dbi = state.db.lock().await; + let mut stmt = dbi.db.prepare( + "SELECT up.id, up.title, s.display_name, up.width, up.height, up.created_at, up.updated_at + FROM user_puzzles up LEFT JOIN sessions s ON up.session_token = s.token + WHERE up.status = 'published' ORDER BY up.updated_at DESC LIMIT 200" + ).unwrap(); + let puzzles: Vec = stmt.query_map([], |row| { + Ok(serde_json::json!({ + "id": row.get::<_, String>(0)?, + "title": row.get::<_, String>(1)?, + "author": row.get::<_, Option>(2)?.unwrap_or_else(|| "Anonymous".into()), + "width": row.get::<_, i64>(3)?, + "height": row.get::<_, i64>(4)?, + "created_at": row.get::<_, String>(5)?, + "updated_at": row.get::<_, String>(6)?, + })) + }).unwrap().filter_map(|r| r.ok()).collect(); + Ok(Json(serde_json::json!({ "puzzles": puzzles }))) +} + +pub async fn delete_community_puzzle( + State(state): State>, + headers: HeaderMap, + Path(id): Path, +) -> Result, Response> { + check_admin(&headers).await?; + let dbi = state.db.lock().await; + dbi.db.execute("DELETE FROM user_puzzles WHERE id = ?1 AND status = 'published'", [&id]).ok(); + dbi.db.execute("DELETE FROM puzzles WHERE id = ?1", [&id]).ok(); + db::log_admin_action(&dbi.db, "delete_community_puzzle", Some(&id), None); + drop(dbi); + if let Ok(uuid) = uuid::Uuid::parse_str(&id) { + state.puzzles.write().await.remove(&uuid); + } + invalidate_stats_cache(); + Ok(Json(serde_json::json!({ "ok": true }))) +} + +pub async fn batch_delete_community_puzzles( + State(state): State>, + headers: HeaderMap, + Json(req): Json, +) -> Result, Response> { + check_admin(&headers).await?; + let ids = req.get("ids").and_then(|v| v.as_array()) + .ok_or_else(|| (StatusCode::BAD_REQUEST, "missing ids array").into_response())?; + let dbi = state.db.lock().await; + let mut deleted = 0; + let mut deleted_ids = Vec::new(); + for id_val in ids { + if let Some(id) = id_val.as_str() { + let n = dbi.db.execute("DELETE FROM user_puzzles WHERE id = ?1 AND status = 'published'", [id]).unwrap_or(0); + dbi.db.execute("DELETE FROM puzzles WHERE id = ?1", [id]).ok(); + if n > 0 { deleted_ids.push(id.to_string()); } + deleted += n; + } + } + db::log_admin_action(&dbi.db, "batch_delete_community_puzzles", None, Some(&format!("{} deleted", deleted))); + drop(dbi); + let mut puzzles = state.puzzles.write().await; + for id in &deleted_ids { + if let Ok(uuid) = uuid::Uuid::parse_str(id) { + puzzles.remove(&uuid); + } + } + drop(puzzles); + invalidate_stats_cache(); + Ok(Json(serde_json::json!({ "ok": true, "deleted": deleted }))) +} + +pub async fn list_all_puzzles_filtered( + State(state): State>, + headers: HeaderMap, + Query(params): Query>, +) -> Result, Response> { + check_admin(&headers).await?; + let ptype = params.get("type").map(|s| s.as_str()).unwrap_or("all"); + let dbi = state.db.lock().await; + + let query = match ptype { + "daily" => "SELECT p.id, p.short_id, p.daily_date, p.preset, p.width, p.height, p.difficulty, p.created_at, + (SELECT COUNT(*) FROM solve_records sr WHERE sr.puzzle_id = p.id AND sr.is_complete = 1), + (SELECT COALESCE(AVG(CAST(pr.rating AS REAL)), 0) FROM puzzle_ratings pr WHERE pr.puzzle_id = p.id) + FROM puzzles p WHERE p.daily_date IS NOT NULL ORDER BY p.created_at DESC LIMIT 200", + "custom" => "SELECT p.id, p.short_id, p.daily_date, p.preset, p.width, p.height, p.difficulty, p.created_at, + (SELECT COUNT(*) FROM solve_records sr WHERE sr.puzzle_id = p.id AND sr.is_complete = 1), + (SELECT COALESCE(AVG(CAST(pr.rating AS REAL)), 0) FROM puzzle_ratings pr WHERE pr.puzzle_id = p.id) + FROM puzzles p WHERE p.daily_date IS NULL ORDER BY p.created_at DESC LIMIT 200", + _ => "SELECT p.id, p.short_id, p.daily_date, p.preset, p.width, p.height, p.difficulty, p.created_at, + (SELECT COUNT(*) FROM solve_records sr WHERE sr.puzzle_id = p.id AND sr.is_complete = 1), + (SELECT COALESCE(AVG(CAST(pr.rating AS REAL)), 0) FROM puzzle_ratings pr WHERE pr.puzzle_id = p.id) + FROM puzzles p ORDER BY p.created_at DESC LIMIT 200", + }; + + let mut stmt = dbi.db.prepare(query).unwrap(); + let puzzles: Vec = stmt.query_map([], |row| { + Ok(serde_json::json!({ + "id": row.get::<_, String>(0)?, + "short_id": row.get::<_, String>(1)?, + "daily_date": row.get::<_, Option>(2)?, + "preset": row.get::<_, Option>(3)?, + "width": row.get::<_, i64>(4)?, + "height": row.get::<_, i64>(5)?, + "difficulty": row.get::<_, i64>(6)?, + "created_at": row.get::<_, String>(7)?, + "solves": row.get::<_, i64>(8)?, + "rating": (row.get::<_, f64>(9)? * 10.0).round() / 10.0, + })) + }).unwrap().filter_map(|r| r.ok()).collect(); + Ok(Json(serde_json::json!({ "puzzles": puzzles }))) +} + +pub async fn batch_delete_puzzles( + State(state): State>, + headers: HeaderMap, + Json(req): Json, +) -> Result, Response> { + check_admin(&headers).await?; + let ids = req.get("ids").and_then(|v| v.as_array()) + .ok_or_else(|| (StatusCode::BAD_REQUEST, "missing ids array").into_response())?; + let dbi = state.db.lock().await; + let mut deleted = 0; + let mut deleted_ids = Vec::new(); + for id_val in ids { + if let Some(id) = id_val.as_str() { + let n = dbi.db.execute("DELETE FROM puzzles WHERE id = ?1", [id]).unwrap_or(0); + if n > 0 { deleted_ids.push(id.to_string()); } + deleted += n; + } + } + db::log_admin_action(&dbi.db, "batch_delete_puzzles", None, Some(&format!("{} deleted", deleted))); + drop(dbi); + let mut puzzles = state.puzzles.write().await; + for id in &deleted_ids { + if let Ok(uuid) = uuid::Uuid::parse_str(id) { + puzzles.remove(&uuid); + } + } + drop(puzzles); + invalidate_stats_cache(); + Ok(Json(serde_json::json!({ "ok": true, "deleted": deleted }))) +}