From 74cd920301e7133f7435ab18ddd6143ddc1289b9 Mon Sep 17 00:00:00 2001 From: lashman Date: Tue, 7 Oct 2025 21:29:55 +0300 Subject: [PATCH] main route handlers --- cruciverb-server/src/routes.rs | 3677 ++++++++++++++++++++++++++++++++ 1 file changed, 3677 insertions(+) create mode 100644 cruciverb-server/src/routes.rs diff --git a/cruciverb-server/src/routes.rs b/cruciverb-server/src/routes.rs new file mode 100644 index 0000000..dc056ca --- /dev/null +++ b/cruciverb-server/src/routes.rs @@ -0,0 +1,3677 @@ +use std::sync::Arc; + +use axum::Json; +use axum::extract::{ConnectInfo, Path, Query, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use uuid::Uuid; + +use cruciverb_core::config::{DifficultyPreset, GenerationConfig}; +use cruciverb_core::grid::{Grid, Cell}; +use cruciverb_core::grid::slot::{Direction, extract_slots}; +use cruciverb_core::puzzle::{Puzzle, generate_full_puzzle, assemble_puzzle}; +use cruciverb_core::solver; +use sha2::{Sha256, Digest}; + +use crate::api::*; +use crate::db; +use crate::session::Session; +use crate::state::AppState; + +pub struct AppError { + msg: String, + status: StatusCode, +} + +#[allow(non_snake_case)] +fn AppError(msg: String) -> AppError { + AppError { msg, status: StatusCode::INTERNAL_SERVER_ERROR } +} + +impl AppError { + pub fn with_status(code: StatusCode, msg: &str) -> Self { + Self { msg: msg.to_string(), status: code } + } +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + (self.status, self.msg).into_response() + } +} + +async fn check_banned(state: &AppState, token: &str) -> Result<(), AppError> { + let dbi = state.db.lock().await; + if db::is_banned(&dbi.db, token) { + Err(AppError::with_status(StatusCode::FORBIDDEN, "account suspended")) + } else { + Ok(()) + } +} + +fn sanitize_filename(name: &str) -> String { + name.chars() + .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_' || *c == '.') + .take(100) + .collect() +} + +fn with_session_cookie(session: &Session, json: Json) -> Response { + let mut resp = json.into_response(); + if let Ok(val) = session.cookie_header().parse() { + resp.headers_mut().insert(axum::http::header::SET_COOKIE, val); + } + resp +} + +pub async fn generate_puzzle( + State(state): State>, + headers: axum::http::HeaderMap, + session: Session, + Json(req): Json, +) -> Result { + check_banned(&state, &session.token).await?; + crate::security::check_rate_limit(&session.token, "generate", 5, 60) + .map_err(|s| AppError::with_status(s, "rate limit exceeded"))?; + let ip = crate::security::extract_client_ip(&headers); + crate::security::check_rate_limit_ip(&ip, "generate", 20, 60) + .map_err(|s| AppError::with_status(s, "rate limit exceeded"))?; + let _permit = state.generation_semaphore.try_acquire() + .map_err(|_| AppError::with_status(StatusCode::SERVICE_UNAVAILABLE, "server busy - try again shortly"))?; + let clue_db = state.clue_db.clone(); + let word_diffs = state.word_diffs.clone(); + + let (difficulty_level, config) = match req.difficulty.as_deref() { + Some("glyph") => (req.clue_difficulty.unwrap_or(0), GenerationConfig::from_preset(DifficultyPreset::Glyph)), + Some("rune") => (req.clue_difficulty.unwrap_or(1), GenerationConfig::from_preset(DifficultyPreset::Rune)), + Some("scroll") => (req.clue_difficulty.unwrap_or(2), GenerationConfig::from_preset(DifficultyPreset::Scroll)), + Some("codex") => (req.clue_difficulty.unwrap_or(3), GenerationConfig::from_preset(DifficultyPreset::Codex)), + Some("grimoire") => (req.clue_difficulty.unwrap_or(3), GenerationConfig::from_preset(DifficultyPreset::Grimoire)), + _ => { + let size = req.size.unwrap_or(9); + if size < 3 || size > 25 { return Err(AppError("grid size must be between 3 and 25".into())); } + let density = req.density.unwrap_or(0.22); + let diff = req.clue_difficulty.unwrap_or(1); + let freq_threshold = match diff { + 0 => 0.1, + 1 => 0.05, + _ => 0.0, + }; + let (bt, restarts) = if size >= 19 { (30000, 30) } + else if size >= 13 { (15000, 20) } + else { (5000, 10) }; + (diff, GenerationConfig { + width: size, + height: size, + black_cell_density: density, + frequency_threshold: freq_threshold, + obscurity_budget: match diff { 1 => 0.0, 2 => 0.10, _ => 0.20 }, + max_backtrack: bt, + max_restarts: restarts, + seed: req.seed, + rand_top_base: GenerationConfig::rand_top_for_size(size), + ..Default::default() + }) + } + }; + + let mut config = config; + if let Some(seed) = req.seed { + config.seed = Some(seed); + } + if req.cryptic.unwrap_or(false) { + config.cryptic_grid = true; + // slightly sparser than standard, with longer slots preferred + config.black_cell_density = (config.black_cell_density + 0.08).min(0.40); + } + + let mut dict = (*state.dict).clone(); + let is_cryptic = req.cryptic.unwrap_or(false); + if !is_cryptic { + dict.apply_difficulty(&word_diffs, difficulty_level); + let wq = state.word_quality.read().await.clone(); + if !wq.is_empty() { dict.apply_quality(&wq); } + } + + // theme support + let theme_name = req.theme.clone(); + let theme_words = theme_name.as_ref().and_then(|t| load_theme_words(t)); + if let Some(ref tw) = theme_words { + let count = dict.boost_theme(tw); + tracing::info!("theme '{}': boosted {} words", theme_name.as_deref().unwrap_or(""), count); + } + + // pack support - boost pack words in dictionary + if let Some(ref pack_id) = req.pack { + let dbi = state.db.lock().await; + if let Some(pack) = db::get_pack(&dbi.db, pack_id) { + let pack_json: serde_json::Value = serde_json::from_str(&pack.pack_data).unwrap_or_default(); + if let Some(words) = pack_json.get("words").and_then(|w| w.as_array()) { + let mut boosted = 0; + for w in words { + let word = w.get("word").and_then(|w| w.as_str()).unwrap_or("").to_uppercase(); + if !word.is_empty() { + dict.boost_word(&word, 2.0); + boosted += 1; + } + } + tracing::info!("pack '{}': boosted {} words", pack_id, boosted); + } + db::increment_pack_downloads(&dbi.db, pack_id); + } + drop(dbi); + } + + // apply staleness penalties so recently used words get deprioritized + config.staleness = state.build_staleness_penalties().await; + + let tw_clone = theme_words.clone(); + let tn_clone = theme_name.clone(); + let puzzle = tokio::task::spawn_blocking(move || { + cruciverb_core::puzzle::generate_themed_puzzle( + &config, &dict, &clue_db, difficulty_level, + tn_clone.as_deref(), tw_clone.as_ref(), + ) + }) + .await + .map_err(|e| AppError(format!("task join error: {}", e)))? + .map_err(|e| AppError(e.to_string()))?; + + // record words so future puzzles deprioritize them + let used_words: Vec = puzzle.words.iter().map(|w| w.word.clone()).collect(); + state.record_puzzle_words(&used_words).await; + + // cryptic mode: swap clues with cryptic versions + let mut puzzle = puzzle; + if is_cryptic { + if let Some(ref cdb) = state.cryptic_db { + let count = puzzle.apply_cryptic_clues(cdb); + tracing::info!("applied {} cryptic clues", count); + } + } + + let id = Uuid::new_v4(); + let puzzle_json = puzzle.to_json().map_err(|e| AppError(e.to_string()))?; + + let content_hash = hex::encode(Sha256::digest(puzzle_json.as_bytes())); + let short_id = { + let dbi = state.db.lock().await; + let short_id = db::store_puzzle(&dbi.db, &id.to_string(), &puzzle_json, + puzzle.width, puzzle.height, difficulty_level, None, + req.difficulty.as_deref()); + db::store_puzzle_hash(&dbi.db, &id.to_string(), &content_hash); + short_id + }; + + let client = to_client_puzzle(&puzzle, id); + state.cache_puzzle(id, puzzle).await; + + Ok(with_session_cookie(&session, Json(GenerateResponse { + id: id.to_string(), + short_id: short_id.clone(), + puzzle: client, + }))) +} + +#[axum::debug_handler] +pub async fn get_puzzle( + State(state): State>, + Path(id): Path, + session: Session, +) -> Result { + let uuid = Uuid::parse_str(&id).map_err(|_| AppError("invalid puzzle id".into()))?; + + // try in-memory first + let puzzles = state.puzzles.read().await; + if let Some((puzzle, _)) = puzzles.get(&uuid) { + let short_id = { + let dbi = state.db.lock().await; + db::get_puzzle_json(&dbi.db, &id).map(|(_, s)| s).unwrap_or_default() + }; + return Ok(with_session_cookie(&session, Json(ClientPuzzleResponse { + id: id.clone(), + short_id, + puzzle: to_client_puzzle(puzzle, uuid), + }))); + } + drop(puzzles); + + // try database + let dbi = state.db.lock().await; + let (puzzle_json, short_id) = db::get_puzzle_json(&dbi.db, &id) + .ok_or(AppError("puzzle not found".into()))?; + let puzzle: Puzzle = serde_json::from_str(&puzzle_json) + .map_err(|e| AppError(e.to_string()))?; + drop(dbi); + + let client = to_client_puzzle(&puzzle, uuid); + state.cache_puzzle(uuid, puzzle).await; + + Ok(with_session_cookie(&session, Json(ClientPuzzleResponse { + id, + short_id, + puzzle: client, + }))) +} + +pub async fn get_puzzle_by_short_id( + State(state): State>, + Path(short_id): Path, + session: Session, +) -> Result { + let dbi = state.db.lock().await; + let (id, puzzle_json) = db::get_puzzle_by_short_id(&dbi.db, &short_id) + .ok_or(AppError("puzzle not found".into()))?; + let puzzle: Puzzle = serde_json::from_str(&puzzle_json) + .map_err(|e| AppError(e.to_string()))?; + drop(dbi); + + let uuid = Uuid::parse_str(&id).unwrap_or(Uuid::new_v4()); + let client = to_client_puzzle(&puzzle, uuid); + state.cache_puzzle(uuid, puzzle).await; + + Ok(with_session_cookie(&session, Json(ClientPuzzleResponse { + id, + short_id, + puzzle: client, + }))) +} + +pub async fn get_daily_puzzle( + State(state): State>, + Path(date): Path, + session: Session, +) -> Result { + get_daily_impl(state, session, &date, "scroll").await +} + +pub async fn get_daily_preset( + State(state): State>, + Path((date, preset)): Path<(String, String)>, + session: Session, +) -> Result { + get_daily_impl(state, session, &date, &preset).await +} + +async fn get_daily_impl( + state: Arc, + session: Session, + date: &str, + preset: &str, +) -> Result { + // serialize generation with the pre-generator, then re-check under the lock + let _gen_lock = state.daily_gen_lock(date, preset).await; + + // check if already generated + { + let dbi = state.db.lock().await; + if let Some((id, short_id, puzzle_json)) = db::get_daily_puzzle(&dbi.db, date, preset) { + let puzzle: Puzzle = serde_json::from_str(&puzzle_json) + .map_err(|e| AppError(e.to_string()))?; + let uuid = Uuid::parse_str(&id).unwrap_or(Uuid::new_v4()); + let client = to_client_puzzle(&puzzle, uuid); + let solve_info = db::get_solve_info(&dbi.db, &session.token, &id); + state.cache_puzzle(uuid, puzzle).await; + let mut resp = serde_json::to_value(ClientPuzzleResponse { + id, short_id, puzzle: client, + }).unwrap(); + if let Some((time, hints, cell_results)) = solve_info { + resp["already_solved"] = serde_json::json!(true); + resp["solve_time"] = serde_json::json!(time); + resp["solve_hints"] = serde_json::json!(hints); + if let Some(cr) = cell_results { + resp["cell_results"] = serde_json::json!(cr); + } + } + return Ok(with_session_cookie(&session, Json(resp))); + } + } + + // acquire generation permit before the heavy work + let _permit = state.generation_semaphore.try_acquire() + .map_err(|_| AppError::with_status(StatusCode::SERVICE_UNAVAILABLE, "server busy - try again shortly"))?; + + // generate with secret-seeded RNG + let seed = daily_seed(date, preset, &state.daily_secret); + let (difficulty_level, mut config) = match preset { + "glyph" => (0u8, GenerationConfig::from_preset(DifficultyPreset::Glyph)), + "rune" => (1, GenerationConfig::from_preset(DifficultyPreset::Rune)), + "scroll" => (2, GenerationConfig::from_preset(DifficultyPreset::Scroll)), + "codex" => (3, GenerationConfig::from_preset(DifficultyPreset::Codex)), + "grimoire" => (3, GenerationConfig::from_preset(DifficultyPreset::Grimoire)), + _ => (2, GenerationConfig::from_preset(DifficultyPreset::Scroll)), + }; + config.seed = Some(seed); + + let clue_db = state.clue_db.clone(); + let word_diffs = state.word_diffs.clone(); + let mut dict = (*state.dict).clone(); + dict.apply_difficulty(&word_diffs, difficulty_level); + let wq = state.word_quality.read().await.clone(); + if !wq.is_empty() { dict.apply_quality(&wq); } + + config.staleness = state.build_staleness_penalties().await; + + let puzzle = tokio::task::spawn_blocking(move || { + generate_full_puzzle(&config, &dict, &clue_db, difficulty_level) + }) + .await + .map_err(|e| AppError(format!("task join error: {}", e)))? + .map_err(|e| AppError(e.to_string()))?; + + let used_words: Vec = puzzle.words.iter().map(|w| w.word.clone()).collect(); + state.record_puzzle_words(&used_words).await; + + let id = Uuid::new_v4(); + let puzzle_json = puzzle.to_json().map_err(|e| AppError(e.to_string()))?; + + let short_id = { + let dbi = state.db.lock().await; + db::store_puzzle(&dbi.db, &id.to_string(), &puzzle_json, + puzzle.width, puzzle.height, difficulty_level, + Some(date), Some(preset)) + }; + + crate::webhooks::fire_event(&state, "daily_puzzle.published", serde_json::json!({ + "description": format!("New {} puzzle ({}x{}) - /p/{}", preset, puzzle.width, puzzle.height, short_id), + "preset": preset, "date": date, "short_id": short_id, + })).await; + + // daily AP post is now handled by the pre-generator, not per-preset + + let client = to_client_puzzle(&puzzle, id); + state.cache_puzzle(id, puzzle).await; + + Ok(with_session_cookie(&session, Json(ClientPuzzleResponse { + id: id.to_string(), + short_id, + puzzle: client, + }))) +} + +pub fn daily_seed(date: &str, preset: &str, secret: &str) -> u64 { + use sha2::{Sha256, Digest}; + let input = format!("{}-{}-{}", secret, preset, date); + let hash = Sha256::digest(input.as_bytes()); + u64::from_le_bytes(hash[..8].try_into().unwrap()) +} + +pub async fn validate_solution( + State(state): State>, + session: Session, + Path(id): Path, + Json(req): Json, +) -> Result { + check_banned(&state, &session.token).await?; + let uuid = Uuid::parse_str(&id).map_err(|_| AppError("invalid puzzle id".into()))?; + let puzzles = state.puzzles.read().await; + let (puzzle, _) = puzzles.get(&uuid).ok_or(AppError("puzzle not found".into()))?; + + let mut all_correct = true; + let mut cell_results = Vec::with_capacity(puzzle.height); + + for r in 0..puzzle.height { + let mut row_results = Vec::with_capacity(puzzle.width); + for c in 0..puzzle.width { + let actual = puzzle.grid.get(r, c).letter(); + let submitted = req.cells.get(r) + .and_then(|row| row.get(c)) + .and_then(|cell| cell.as_ref()) + .and_then(|s| s.chars().next()) + .filter(|c| c.is_alphabetic()) + .map(|c| c.to_ascii_uppercase()); + + let result = match (actual, submitted) { + (Some(a), Some(s)) if a == s => "correct", + (Some(_), Some(_)) => { all_correct = false; "incorrect" } + (Some(_), None) => { all_correct = false; "empty" } + _ => "black", + }; + row_results.push(result.to_string()); + } + cell_results.push(row_results); + } + + // record solve if correct (respecting privacy settings) + let mut new_achievements = Vec::new(); + let mut flagged = false; + if all_correct { + let dbi = state.db.lock().await; + + // skip if already solved - don't let re-submissions overwrite the record + if db::has_solve_record(&dbi.db, &session.token, &id) { + drop(dbi); + return Ok(with_session_cookie(&session, Json(serde_json::json!({ + "correct": true, + "already_solved": true, + "cells": cell_results, + "new_achievements": [], + })))); + } + + // use server-tracked hint count instead of trusting the client + let server_hints = db::get_hint_count(&dbi.db, &id, &session.token); + + // minimum solve time check - flag suspiciously fast solves (raw client + // time; anonymized rounding below happens only for what gets stored) + let elapsed = req.elapsed.unwrap_or(0); + let daily_info = db::get_puzzle_daily_info(&dbi.db, &id); + let min_time: i64 = match &daily_info { + Some((_, preset)) => match preset.as_str() { + "glyph" => 3, + "rune" => 5, + "scroll" => 15, + "codex" => 30, + "grimoire" => 60, + _ => 3, + }, + None => 3, + }; + if elapsed < min_time { + flagged = true; + } + + let settings_json = db::get_settings(&dbi.db, &session.token); + let settings: serde_json::Value = serde_json::from_str(&settings_json).unwrap_or_default(); + let anon_mode = settings.get("anonymousSolveMode").and_then(|v| v.as_bool()).unwrap_or(false); + let no_track = settings.get("noTrackMode").and_then(|v| v.as_bool()).unwrap_or(false); + let anonymize_times = settings.get("anonymizeTimes").and_then(|v| v.as_bool()).unwrap_or(false); + + // anonymizeTimes: round stored solve times to the minute + let elapsed = if anonymize_times { (elapsed / 60) * 60 } else { elapsed }; + + let cr = req.cell_results.as_deref(); + if !no_track && !anon_mode { + db::record_solve(&dbi.db, &session.token, &id, elapsed, server_hints, flagged, cr); + let today = chrono::Utc::now().format("%Y-%m-%d").to_string(); + // archived dailies count toward history and stats, but not the + // daily streak (matches the archive page copy) + let is_past_daily = daily_info + .as_ref() + .map(|(d, _)| d < &today) + .unwrap_or(false); + if !is_past_daily { + db::update_streak(&dbi.db, &session.token, &today); + } + new_achievements = crate::achievements::check_achievements(&dbi.db, &session.token); + } else if !no_track { + db::record_solve(&dbi.db, &session.token, &id, elapsed, server_hints, flagged, cr); + } + + // auto-purge check + let purge_enabled = settings.get("autoPurgeEnabled").and_then(|v| v.as_bool()).unwrap_or(false); + if purge_enabled { + let days = settings.get("autoPurgeDays").and_then(|v| v.as_i64()).unwrap_or(90).max(7); + db::auto_purge(&dbi.db, &session.token, days); + } + + // compute percentile and rank before dropping dbi + let percentile = if !no_track { + Some(db::get_solve_percentile(&dbi.db, &id, elapsed)) + } else { None }; + let rank = if !no_track { + Some(db::get_solve_rank(&dbi.db, &id, elapsed)) + } else { None }; + let total_solvers = if !no_track { + Some(db::get_total_solvers(&dbi.db, &id)) + } else { None }; + + // generate receipt if this is a room solve + let receipt_json = if req.room_code.is_some() { + let (pj, _) = db::get_puzzle_json(&dbi.db, &id).unwrap_or_default(); + let puzzle_hash = hex::encode(Sha256::digest(pj.as_bytes())); + let player_name = session.display_name.clone().unwrap_or_else(|| "Anonymous".into()); + + // get other players from the room + let other_players = if let Some(code) = &req.room_code { + let rooms = state.rooms.read().await; + rooms.get(code).map(|room| { + room.players.values() + .filter(|p| p.id != session.token) + .map(|p| p.name.clone()) + .collect::>() + }).unwrap_or_default() + } else { + Vec::new() + }; + + let mode_str = if let Some(code) = &req.room_code { + let rooms = state.rooms.read().await; + rooms.get(code).map(|room| { + match room.mode { + crate::rooms::RoomMode::Coop => "coop".to_string(), + crate::rooms::RoomMode::Competitive => "competitive".to_string(), + crate::rooms::RoomMode::Party => "party".to_string(), + } + }) + } else { + None + }; + + crate::receipts::generate_receipt( + &dbi.db, + &puzzle_hash, + elapsed, + server_hints, + &player_name, + req.room_code.as_deref(), + mode_str.as_deref(), + other_players, + ).map(|sr| serde_json::json!({ + "receipt": sr.receipt, + "signature": sr.signature, + "key_id": sr.key_id, + })) + } else { + None + }; + + drop(dbi); + + // fire webhooks (unless no-track) + if !no_track { + crate::webhooks::fire_event(&state, "puzzle.completed", serde_json::json!({ + "description": format!("Puzzle solved in {}s", elapsed), + })).await; + for ach in &new_achievements { + crate::webhooks::fire_event(&state, "achievement.unlocked", serde_json::json!({ + "description": format!("Achievement unlocked: {}", ach), + })).await; + } + + // publish leaderboard to peers via direct API (not AP Notes) + if let Some((daily_date, daily_preset)) = daily_info.clone() { + let state2 = state.clone(); + tokio::spawn(async move { + let entries = { + let dbi = state2.db.lock().await; + db::get_daily_leaderboard_filtered(&dbi.db, &daily_date, &daily_preset, 10) + .into_iter().map(|(name, time, _)| (name, time)).collect() + }; + crate::activitypub::outbox::publish_leaderboard( + &state2, &daily_date, &daily_preset, entries, + ).await; + }); + } + } + + return Ok(with_session_cookie(&session, Json(serde_json::json!({ + "correct": true, + "cells": cell_results, + "new_achievements": new_achievements, + "flagged": flagged, + "percentile": percentile, + "rank": rank, + "total_solvers": total_solvers, + "receipt": receipt_json, + })))); + } + + Ok(with_session_cookie(&session, Json(serde_json::json!({ + "correct": all_correct, + "cells": cell_results, + "new_achievements": new_achievements, + "flagged": flagged, + })))) +} + +pub async fn get_hint( + State(state): State>, + session: Session, + Path(id): Path, + Json(req): Json, +) -> Result { + Uuid::parse_str(&id).map_err(|_| AppError("invalid puzzle id".into()))?; + + // rate limit check_cell to prevent brute-force solution extraction + if req.kind == "check_cell" { + crate::security::check_rate_limit(&session.token, &format!("check_cell:{}", id), 50, 60) + .map_err(|s| AppError::with_status(s, "too many check requests"))?; + } else { + let dbi = state.db.lock().await; + let hint_count = db::get_hint_count(&dbi.db, &id, &session.token); + if hint_count >= 20 { + return Err(AppError::with_status(StatusCode::TOO_MANY_REQUESTS, "hint limit reached for this puzzle")); + } + db::increment_hint_count(&dbi.db, &id, &session.token); + drop(dbi); + } + + let puzzle = load_puzzle(&state, &id).await?; + + match req.kind.as_str() { + "check_cell" => { + let actual = puzzle.grid.get(req.row, req.col).letter(); + let submitted = req.letter.as_ref() + .and_then(|s| s.chars().next()) + .map(|c| c.to_ascii_uppercase()); + let is_correct = actual == submitted; + Ok(with_session_cookie(&session, Json(HintResponse::CheckCell { is_correct }))) + } + "reveal_letter" => { + let letter = puzzle.grid.get(req.row, req.col) + .letter() + .ok_or(AppError("not a letter cell".into()))?; + Ok(with_session_cookie(&session, Json(HintResponse::RevealLetter { + row: req.row, col: req.col, letter: letter.to_string(), + }))) + } + "reveal_word" => { + let dir = match req.direction.as_deref() { + Some("down") => Direction::Down, + _ => Direction::Across, + }; + let word = puzzle.words.iter() + .find(|w| w.direction == dir && word_contains(w, req.row, req.col)) + .ok_or(AppError("no word at that position".into()))?; + let letters: Vec = word.word.chars().enumerate().map(|(i, ch)| { + let (r, c) = match word.direction { + Direction::Across => (word.row, word.col + i), + Direction::Down => (word.row + i, word.col), + }; + LetterPosition { row: r, col: c, letter: ch.to_string() } + }).collect(); + Ok(with_session_cookie(&session, Json(HintResponse::RevealWord { letters }))) + } + _ => Err(AppError("unknown hint kind".into())), + } +} + +pub async fn save_progress( + State(state): State>, + session: Session, + Path(id): Path, + Json(req): Json, +) -> Result { + let grid_json = serde_json::to_string(&req.cells).map_err(|e| AppError(e.to_string()))?; + let dbi = state.db.lock().await; + + // #9: encrypt if user has encryptSolveData enabled + let settings_json = db::get_settings(&dbi.db, &session.token); + let encrypt = serde_json::from_str::(&settings_json) + .ok() + .and_then(|v| v.get("encryptSolveData")?.as_bool()) + .unwrap_or(false); + + if encrypt { + match encrypt_progress(grid_json.as_bytes()) { + Some(encrypted) => { + let stored = format!("ENC2:{}", hex::encode(&encrypted)); + db::save_progress(&dbi.db, &session.token, &id, &stored); + } + None => { + tracing::warn!("encryptSolveData enabled but DATA_ENC_KEY missing or invalid - storing plaintext"); + db::save_progress(&dbi.db, &session.token, &id, &grid_json); + } + } + } else { + db::save_progress(&dbi.db, &session.token, &id, &grid_json); + } + + Ok(with_session_cookie(&session, Json(serde_json::json!({"ok": true})))) +} + +pub async fn load_progress( + State(state): State>, + session: Session, + Path(id): Path, +) -> Result { + let dbi = state.db.lock().await; + let progress = db::load_progress(&dbi.db, &session.token, &id); + + // #9: decrypt if data is encrypted (ENC2 = AES-256-GCM, ENC = legacy XOR) + let cells = progress.and_then(|p| { + if let Some(hex_data) = p.strip_prefix("ENC2:") { + let encrypted = hex::decode(hex_data).ok()?; + let decrypted = decrypt_progress(&encrypted)?; + let json_str = String::from_utf8(decrypted).ok()?; + serde_json::from_str::(&json_str).ok() + } else if let Some(hex_data) = p.strip_prefix("ENC:") { + let encrypted = hex::decode(hex_data).ok()?; + let key = derive_key(&session.token); + let decrypted = xor_crypt(&encrypted, &key); + let json_str = String::from_utf8(decrypted).ok()?; + serde_json::from_str::(&json_str).ok() + } else { + serde_json::from_str::(&p).ok() + } + }); + + Ok(with_session_cookie(&session, Json(serde_json::json!({ + "cells": cells + })))) +} + +pub async fn get_me( + State(state): State>, + session: Session, +) -> Result { + let dbi = state.db.lock().await; + let (current, best, last_date) = db::get_streak(&dbi.db, &session.token); + let has_passkey = db::has_passkey(&dbi.db, &session.token); + let preferred_preset = db::get_preferred_preset(&dbi.db, &session.token); + let settings_json = db::get_settings(&dbi.db, &session.token); + let settings: serde_json::Value = serde_json::from_str(&settings_json).unwrap_or_default(); + let private = settings.get("privateProfile").and_then(|v| v.as_bool()).unwrap_or(false); + let display = if private { None } else { session.display_name.clone() }; + + // auto-purge on session load + let purge_enabled = settings.get("autoPurgeEnabled").and_then(|v| v.as_bool()).unwrap_or(false); + if purge_enabled { + let days = settings.get("autoPurgeDays").and_then(|v| v.as_i64()).unwrap_or(90); + db::auto_purge(&dbi.db, &session.token, days); + } + + let freezes = db::get_streak_freezes(&dbi.db, &session.token); + + Ok(with_session_cookie(&session, Json(serde_json::json!({ + "display_name": display, + "is_new": session.is_new, + "has_passkey": has_passkey, + "preferred_preset": preferred_preset, + "streak": { + "current": current, + "best": best, + "last_date": last_date, + }, + "streak_freezes": freezes, + })))) +} + +pub async fn update_me( + State(state): State>, + session: Session, + Json(req): Json, +) -> Result { + if let Some(name) = &req.display_name { + if name.len() > 100 { + return Err(AppError::with_status(axum::http::StatusCode::BAD_REQUEST, "name too long")); + } + let clean = crate::security::sanitize_html(name); + if state.blocklist.read().unwrap().contains_blocked(&clean) { + return Err(AppError::with_status(axum::http::StatusCode::BAD_REQUEST, "name contains inappropriate content")); + } + let dbi = state.db.lock().await; + db::set_display_name(&dbi.db, &session.token, &clean); + } + Ok(with_session_cookie(&session, Json(serde_json::json!({"ok": true})))) +} + +pub async fn get_stats( + State(state): State>, + session: Session, +) -> Result { + let dbi = state.db.lock().await; + let (current, best, last_date) = db::get_streak(&dbi.db, &session.token); + let solves = db::get_stats(&dbi.db, &session.token); + let total = solves.len(); + + Ok(with_session_cookie(&session, Json(serde_json::json!({ + "streak": { "current": current, "best": best, "last_date": last_date }, + "total_solved": total, + "recent": solves.iter().map(|(pid, preset, time, hints)| { + serde_json::json!({ + "puzzle_id": pid, + "preset": preset, + "time": time, + "hints": hints, + }) + }).collect::>(), + })))) +} + +pub async fn get_leaderboard( + State(state): State>, + Path(id): Path, + session: Session, +) -> Result { + let dbi = state.db.lock().await; + let entries = db::get_leaderboard_filtered(&dbi.db, &id, 20); + Ok(with_session_cookie(&session, Json(serde_json::json!({ + "entries": entries.iter().map(|(name, time, hints)| { + serde_json::json!({ "name": name, "time": time, "hints": hints }) + }).collect::>() + })))) +} + +pub async fn get_daily_leaderboard( + State(state): State>, + Path((date, preset)): Path<(String, String)>, + Query(params): Query>, + session: Session, +) -> Result { + let clean_only = params.get("clean").map(|v| v == "true").unwrap_or(false); + let dbi = state.db.lock().await; + let entries = if clean_only { + db::get_daily_leaderboard_by_date(&dbi.db, &date, &preset, true, 20) + } else { + db::get_daily_leaderboard_filtered(&dbi.db, &date, &preset, 20) + }; + Ok(with_session_cookie(&session, Json(serde_json::json!({ + "entries": entries.iter().map(|(name, time, hints)| { + serde_json::json!({ "name": name, "time": time, "hints": hints }) + }).collect::>() + })))) +} + +pub async fn get_federated_leaderboard( + State(state): State>, + Path((date, preset)): Path<(String, String)>, + session: Session, +) -> Result { + let dbi = state.db.lock().await; + let entries = db::get_federated_leaderboard(&dbi.db, &date, &preset, 20); + Ok(with_session_cookie(&session, Json(serde_json::json!({ + "entries": entries.iter().map(|(name, origin, time)| { + serde_json::json!({ "name": name, "instance": origin, "time": time }) + }).collect::>() + })))) +} + +pub async fn get_alltime_leaderboard( + State(state): State>, + Path(preset): Path, + session: Session, +) -> Result { + let dbi = state.db.lock().await; + let entries = db::get_alltime_leaderboard(&dbi.db, &preset, 20); + Ok(with_session_cookie(&session, Json(serde_json::json!({ + "entries": entries.iter().map(|(name, time, hints, date)| { + serde_json::json!({ "name": name, "time": time, "hints": hints, "date": date }) + }).collect::>() + })))) +} + +pub async fn get_weekly_leaderboard( + State(state): State>, + Path(preset): Path, + session: Session, +) -> Result { + let dbi = state.db.lock().await; + let entries = db::get_weekly_leaderboard(&dbi.db, &preset, 20); + Ok(with_session_cookie(&session, Json(serde_json::json!({ + "entries": entries.iter().map(|(name, time, hints)| { + serde_json::json!({ "name": name, "time": time, "hints": hints }) + }).collect::>() + })))) +} + +pub async fn get_streak_leaderboard( + State(state): State>, + session: Session, +) -> Result { + let dbi = state.db.lock().await; + let entries = db::get_streak_leaderboard(&dbi.db, 20); + Ok(with_session_cookie(&session, Json(serde_json::json!({ + "entries": entries.iter().map(|(name, current, best)| { + serde_json::json!({ "name": name, "current": current, "best": best }) + }).collect::>() + })))) +} + +pub async fn get_completionist_leaderboard( + State(state): State>, + session: Session, +) -> Result { + let dbi = state.db.lock().await; + let entries = db::get_completionist_leaderboard(&dbi.db, 20); + Ok(with_session_cookie(&session, Json(serde_json::json!({ + "entries": entries.iter().map(|(name, total)| { + serde_json::json!({ "name": name, "total_solves": total }) + }).collect::>() + })))) +} + +pub async fn get_clean_leaderboard( + State(state): State>, + Path((date, preset)): Path<(String, String)>, + session: Session, +) -> Result { + let dbi = state.db.lock().await; + let entries = db::get_clean_leaderboard(&dbi.db, &date, &preset, 20); + Ok(with_session_cookie(&session, Json(serde_json::json!({ + "entries": entries.iter().map(|(name, time)| { + serde_json::json!({ "name": name, "time": time }) + }).collect::>() + })))) +} + +pub async fn get_detailed_stats( + State(state): State>, + session: Session, +) -> Result { + crate::security::check_rate_limit(&session.token, "stats", 10, 60) + .map_err(|s| AppError::with_status(s, "rate limit exceeded"))?; + let dbi = state.db.lock().await; + let stats = db::get_personal_stats(&dbi.db, &session.token); + Ok(with_session_cookie(&session, Json(stats))) +} + +pub async fn get_analytics( + State(state): State>, + session: Session, +) -> Result { + crate::security::check_rate_limit(&session.token, "analytics", 10, 60) + .map_err(|s| AppError::with_status(s, "rate limit exceeded"))?; + let dbi = state.db.lock().await; + let data = db::get_analytics_data(&dbi.db, &session.token); + Ok(with_session_cookie(&session, Json(data))) +} + +pub async fn get_history( + State(state): State>, + session: Session, +) -> Result { + crate::security::check_rate_limit(&session.token, "history", 10, 60) + .map_err(|s| AppError::with_status(s, "rate limit exceeded"))?; + let dbi = state.db.lock().await; + let history = db::get_solve_history(&dbi.db, &session.token, 50); + Ok(with_session_cookie(&session, Json(serde_json::json!({ + "history": history + })))) +} + +pub async fn get_solve_percentile( + State(state): State>, + Path((id, time)): Path<(String, i64)>, + session: Session, +) -> Result { + let dbi = state.db.lock().await; + let percentile = db::get_solve_percentile(&dbi.db, &id, time); + Ok(with_session_cookie(&session, Json(serde_json::json!({ + "percentile": percentile + })))) +} + +pub async fn get_settings( + State(state): State>, + session: Session, +) -> Result { + let dbi = state.db.lock().await; + let settings = db::get_settings(&dbi.db, &session.token); + let parsed: serde_json::Value = serde_json::from_str(&settings).unwrap_or_default(); + Ok(with_session_cookie(&session, Json(parsed))) +} + +pub async fn save_settings( + State(state): State>, + session: Session, + Json(settings): Json, +) -> Result { + let json = serde_json::to_string(&settings).map_err(|e| AppError(e.to_string()))?; + let dbi = state.db.lock().await; + db::save_settings(&dbi.db, &session.token, &json); + Ok(with_session_cookie(&session, Json(serde_json::json!({"ok": true})))) +} + +// ---- achievement endpoints ---- + +pub async fn get_achievements( + State(state): State>, + session: Session, +) -> Result { + let dbi = state.db.lock().await; + let unlocked = db::get_achievements(&dbi.db, &session.token); + let all = crate::achievements::all_achievements(); + + let result: Vec = all.iter().map(|(id, name, desc, icon, c1, c2)| { + let is_unlocked = unlocked.iter().any(|(uid, _)| uid == id); + let unlocked_at = unlocked.iter().find(|(uid, _)| uid == id).map(|(_, t)| t.clone()); + serde_json::json!({ + "id": id, "name": name, "description": desc, + "icon": icon, "color1": c1, "color2": c2, + "unlocked": is_unlocked, "unlocked_at": unlocked_at, + }) + }).collect(); + + Ok(with_session_cookie(&session, Json(serde_json::json!({ "achievements": result })))) +} + +pub async fn list_achievements( + State(_state): State>, +) -> Result, AppError> { + let all = crate::achievements::all_achievements(); + let result: Vec = all.iter().map(|(id, name, desc, icon, c1, c2)| { + serde_json::json!({ + "id": id, "name": name, "description": desc, + "icon": icon, "color1": c1, "color2": c2, + }) + }).collect(); + Ok(Json(serde_json::json!({ "achievements": result }))) +} + +// ---- import puzzle from .puz / .ipuz ---- + +pub async fn import_puzzle( + State(state): State>, + headers: axum::http::HeaderMap, + session: Session, + body: axum::body::Bytes, +) -> Result { + check_banned(&state, &session.token).await?; + crate::security::check_rate_limit(&session.token, "import", 10, 60) + .map_err(|s| AppError::with_status(s, "rate limit exceeded"))?; + let ip = crate::security::extract_client_ip(&headers); + crate::security::check_rate_limit_ip(&ip, "import", 30, 60) + .map_err(|s| AppError::with_status(s, "rate limit exceeded"))?; + + if body.len() > 1_000_000 { + return Err(AppError::with_status(StatusCode::PAYLOAD_TOO_LARGE, "file too large (max 1MB)")); + } + + let puzzle = cruciverb_core::import::detect_and_parse(&body) + .map_err(|e| AppError::with_status(StatusCode::BAD_REQUEST, &e))?; + + let id = Uuid::new_v4(); + let puzzle_json = puzzle.to_json().map_err(|e| AppError(e.to_string()))?; + + let short_id = { + let dbi = state.db.lock().await; + db::store_puzzle(&dbi.db, &id.to_string(), &puzzle_json, + puzzle.width, puzzle.height, 0, None, Some("imported")) + }; + + let client = to_client_puzzle(&puzzle, id); + state.cache_puzzle(id, puzzle).await; + + Ok(with_session_cookie(&session, Json(GenerateResponse { + id: id.to_string(), + short_id: short_id.clone(), + puzzle: client, + }))) +} + +// ---- export endpoints ---- + +pub async fn export_puz( + State(state): State>, + session: Session, + Path(id): Path, +) -> Result { + crate::security::check_rate_limit(&session.token, "export", 10, 60) + .map_err(|s| AppError::with_status(s, "rate limit exceeded"))?; + let puzzle = load_puzzle(&state, &id).await?; + let data = cruciverb_core::export::puz::to_puz(&puzzle, "cruciverb puzzle", "cruciverb"); + Ok(( + [(axum::http::header::CONTENT_TYPE, "application/octet-stream"), + (axum::http::header::CONTENT_DISPOSITION, "attachment; filename=\"puzzle.puz\"")], + data, + ).into_response()) +} + +pub async fn export_ipuz( + State(state): State>, + session: Session, + Path(id): Path, +) -> Result { + crate::security::check_rate_limit(&session.token, "export", 10, 60) + .map_err(|s| AppError::with_status(s, "rate limit exceeded"))?; + let puzzle = load_puzzle(&state, &id).await?; + let data = cruciverb_core::export::ipuz::to_ipuz(&puzzle, "cruciverb puzzle"); + Ok(( + [(axum::http::header::CONTENT_TYPE, "application/json"), + (axum::http::header::CONTENT_DISPOSITION, "attachment; filename=\"puzzle.ipuz\"")], + serde_json::to_string_pretty(&data).unwrap_or_default(), + ).into_response()) +} + +#[derive(serde::Deserialize)] +pub struct PdfQuery { + pub variant: Option, +} + +pub async fn export_pdf( + State(state): State>, + session: Session, + Path(id): Path, + Query(q): Query, +) -> Result { + crate::security::check_rate_limit(&session.token, "export", 10, 60) + .map_err(|s| AppError::with_status(s, "rate limit exceeded"))?; + let puzzle = load_puzzle(&state, &id).await?; + let variant = match q.variant.as_deref() { + Some("solution") => "solution", + _ => "puzzle", + }; + let data = cruciverb_core::export::pdf::to_pdf(&puzzle, "cruciverb puzzle", variant); + Ok(( + [(axum::http::header::CONTENT_TYPE, "application/pdf"), + (axum::http::header::CONTENT_DISPOSITION, &format!("attachment; filename=\"puzzle-{}.pdf\"", variant))], + data, + ).into_response()) +} + +async fn load_puzzle(state: &AppState, id: &str) -> Result { + let uuid = Uuid::parse_str(id).map_err(|_| AppError("invalid puzzle id".into()))?; + + // try in-memory + let puzzles = state.puzzles.read().await; + if let Some((p, _)) = puzzles.get(&uuid) { + return Ok(p.clone()); + } + drop(puzzles); + + // try database + let dbi = state.db.lock().await; + let (json, _) = db::get_puzzle_json(&dbi.db, id).ok_or(AppError("puzzle not found".into()))?; + serde_json::from_str(&json).map_err(|e| AppError(e.to_string())) +} + +// ---- puzzle editor endpoints ---- + +pub async fn editor_autofill( + State(state): State>, + session: Session, + Json(req): Json, +) -> Result { + let width = req.get("width").and_then(|v| v.as_u64()).ok_or(AppError("missing width".into()))? as usize; + let height = req.get("height").and_then(|v| v.as_u64()).ok_or(AppError("missing height".into()))? as usize; + let cells = req.get("cells").and_then(|v| v.as_array()).ok_or(AppError("missing cells".into()))?; + + if width > 25 || height > 25 { return Err(AppError("max grid size is 25x25".into())); } + if width < 3 || height < 3 { return Err(AppError("min grid size is 3x3".into())); } + + // check for offensive patterns + let cell_strs: Vec> = cells.iter().map(|row| { + row.as_array().map(|a| a.iter().map(|c| c.as_str().unwrap_or("white")).collect()) + .unwrap_or_default() + }).collect(); + if has_offensive_pattern(&cell_strs, width, height) { + return Err(AppError("this grid pattern is not allowed".into())); + } + + let mut grid = Grid::new(width, height); + for (r, row) in cells.iter().enumerate() { + if let Some(row_arr) = row.as_array() { + for (c, cell) in row_arr.iter().enumerate() { + if cell.as_str() == Some("black") { + grid.set(r, c, Cell::Black); + } + } + } + } + + let locked: Vec<(usize, usize, char)> = req.get("locked") + .and_then(|v| v.as_array()) + .map(|arr| arr.iter().filter_map(|v| { + let r = v.get("row")?.as_u64()? as usize; + let c = v.get("col")?.as_u64()? as usize; + let ch = v.get("letter")?.as_str()?.chars().next()?; + Some((r, c, ch)) + }).collect()) + .unwrap_or_default(); + + for &(r, c, ch) in &locked { + grid.set(r, c, Cell::Letter(ch)); + } + + let word_diffs = state.word_diffs.clone(); + let mut dict = (*state.dict).clone(); + dict.apply_difficulty(&word_diffs, 1); // easy words for editor fills + let wq = state.word_quality.read().await.clone(); + if !wq.is_empty() { dict.apply_quality(&wq); } + + let clue_db = state.clue_db.clone(); + let (bt, restarts) = if width >= 19 { (30000, 30) } + else if width >= 13 { (15000, 20) } + else { (5000, 10) }; + let staleness = state.build_staleness_penalties().await; + let config = GenerationConfig { + width, height, + min_word_length: 3, + frequency_threshold: 0.1, + max_backtrack: bt, + max_restarts: restarts, + timeout_seconds: 60, + rand_top_base: GenerationConfig::rand_top_for_size(width), + staleness, + ..Default::default() + }; + + let result = tokio::task::spawn_blocking(move || { + let slots = extract_slots(&grid, 3); + match solver::solve(&grid, &slots, &dict, &config) { + Ok(solve_result) => { + let puzzle = assemble_puzzle(&solve_result, &slots, &dict, &clue_db, &config, 2, 0, None, None); + Ok(puzzle) + } + Err(e) => Err(e), + } + }).await.map_err(|e| AppError(format!("task error: {}", e)))?; + + match result { + Ok(puzzle) => { + let filled: Vec>> = (0..height).map(|r| { + (0..width).map(|c| puzzle.grid.get(r, c).letter().map(|ch| ch.to_string())).collect() + }).collect(); + + let clues: Vec = puzzle.clues_across.iter().chain(puzzle.clues_down.iter()) + .map(|c| serde_json::json!({ + "number": c.number, "direction": format!("{:?}", c.direction).to_lowercase(), + "text": c.text, "answer": c.answer, + })).collect(); + + Ok(with_session_cookie(&session, Json(serde_json::json!({ + "ok": true, "letters": filled, "clues": clues, + })))) + } + Err(_) => Ok(with_session_cookie(&session, Json(serde_json::json!({ + "ok": false, "error": "could not fill this grid pattern - try adjusting the black cells" + })))) + } +} + +pub async fn editor_clue_suggestions( + State(state): State>, + session: Session, + Path(word): Path, +) -> Result { + let upper = word.to_uppercase(); + if !upper.chars().all(|c| c.is_alphabetic()) { + return Err(AppError("invalid word".into())); + } + + let mut suggestions = state.clue_db.suggestions(&upper, 5); + if let Some(ref cdb) = state.cryptic_db { + for s in cdb.suggestions(&upper, 3) { + if !suggestions.contains(&s) { suggestions.push(s); } + } + } + + Ok(with_session_cookie(&session, Json(serde_json::json!({ "suggestions": suggestions })))) +} + +pub async fn editor_check_words( + State(state): State>, + session: Session, + Json(req): Json, +) -> Result { + let words = req.get("words").and_then(|v| v.as_array()) + .ok_or(AppError("missing words".into()))?; + + // check against the in-memory blocklist (the DATA_DIR-aware one), not a + // hardcoded relative path that ignores the configured data directory + let blocklist = state.blocklist.read().unwrap(); + let mut blocked: Vec = Vec::new(); + for w in words { + if let Some(word) = w.as_str() { + let upper = word.to_uppercase(); + if upper.len() < 2 || !upper.chars().all(|c| c.is_alphabetic()) { continue; } + if blocklist.contains_blocked(&upper) { + blocked.push(upper); + } + } + } + + Ok(with_session_cookie(&session, Json(serde_json::json!({ + "blocked": blocked, + })))) +} + +pub async fn editor_save( + State(state): State>, + session: Session, + Json(req): Json, +) -> Result { + check_banned(&state, &session.token).await?; + let id = req.get("id").and_then(|v| v.as_str()) + .unwrap_or("").to_string(); + let id = if id.is_empty() { Uuid::new_v4().to_string() } else { id }; + let raw_title = req.get("title").and_then(|v| v.as_str()).unwrap_or("Untitled"); + let title = crate::security::sanitize_html(raw_title); + if state.blocklist.read().unwrap().contains_blocked(&title) { + return Err(AppError::with_status(axum::http::StatusCode::BAD_REQUEST, "title contains inappropriate content")); + } + let width = req.get("width").and_then(|v| v.as_u64()).unwrap_or(15) as usize; + let height = req.get("height").and_then(|v| v.as_u64()).unwrap_or(15) as usize; + if width > 25 || height > 25 || width < 3 || height < 3 { + return Err(AppError::with_status(axum::http::StatusCode::BAD_REQUEST, "grid size must be between 3 and 25")); + } + let grid_json = req.get("grid").map(|v| v.to_string()).unwrap_or_else(|| "[]".into()); + + // check for offensive patterns in saved grid + if let Some(grid_arr) = req.get("grid").and_then(|v| v.as_array()) { + let cell_strs: Vec> = grid_arr.iter().map(|row| { + row.as_array().map(|a| a.iter().map(|c| c.as_str().unwrap_or("white")).collect()) + .unwrap_or_default() + }).collect(); + if has_offensive_pattern(&cell_strs, width, height) { + return Err(AppError("this grid pattern is not allowed".into())); + } + } + + let clues_json = req.get("clues").map(|v| v.to_string()); + let puzzle_json = req.get("puzzle").map(|v| v.to_string()); + let status = req.get("status").and_then(|v| v.as_str()).unwrap_or("draft"); + if status == "published" { + return Err(AppError::with_status( + axum::http::StatusCode::BAD_REQUEST, + "use the publish endpoint to publish puzzles", + )); + } + + let dbi = state.db.lock().await; + db::save_user_puzzle(&dbi.db, &id, &session.token, &title, width, height, + &grid_json, clues_json.as_deref(), puzzle_json.as_deref(), status); + Ok(with_session_cookie(&session, Json(serde_json::json!({ "id": id })))) +} + +pub async fn editor_load( + State(state): State>, + session: Session, + Path(id): Path, +) -> Result { + let dbi = state.db.lock().await; + let puzzle = db::get_user_puzzle(&dbi.db, &id, &session.token) + .ok_or(AppError("puzzle not found".into()))?; + Ok(with_session_cookie(&session, Json(serde_json::json!({ + "id": puzzle.0, "title": puzzle.1, "width": puzzle.2, "height": puzzle.3, + "grid": serde_json::from_str::(&puzzle.4).unwrap_or_default(), + "clues": puzzle.5.and_then(|s| serde_json::from_str::(&s).ok()), + "puzzle": puzzle.6.and_then(|s| serde_json::from_str::(&s).ok()), + "status": puzzle.7, + })))) +} + +pub async fn editor_list( + State(state): State>, + session: Session, +) -> Result { + let dbi = state.db.lock().await; + let puzzles = db::list_user_puzzles(&dbi.db, &session.token); + let result: Vec = puzzles.iter().map(|(id, title, w, h, status, updated)| { + serde_json::json!({ "id": id, "title": title, "width": w, "height": h, "status": status, "updated_at": updated }) + }).collect(); + Ok(with_session_cookie(&session, Json(serde_json::json!({ "puzzles": result })))) +} + +pub async fn editor_delete( + State(state): State>, + session: Session, + Path(id): Path, +) -> Result { + // check if puzzle was published before deleting (need to send delete to federation) + let was_published = { + let dbi = state.db.lock().await; + let status: Option = dbi.db.query_row( + "SELECT status FROM user_puzzles WHERE id = ?1 AND session_token = ?2", + rusqlite::params![id, session.token], |row| row.get(0), + ).ok(); + status.as_deref() == Some("published") + }; + + { + let dbi = state.db.lock().await; + db::delete_user_puzzle(&dbi.db, &id, &session.token); + } + + // notify federation of deletion + if was_published { + if let Some(ap) = state.ap.as_ref() { + let object_uri = format!("https://{}/ap/community-puzzles/{}", ap.domain, id); + crate::activitypub::outbox::publish_delete(&state, &object_uri).await; + } + } + + Ok(with_session_cookie(&session, Json(serde_json::json!({ "ok": true })))) +} + +pub async fn editor_publish( + State(state): State>, + session: Session, + Path(id): Path, +) -> Result { + check_banned(&state, &session.token).await?; + { + let dbi = state.db.lock().await; + let ok = db::publish_user_puzzle(&dbi.db, &id, &session.token); + if !ok { return Err(AppError("puzzle needs clues before publishing".into())); } + } + + Ok(with_session_cookie(&session, Json(serde_json::json!({ "ok": true })))) +} + +pub async fn editor_export( + State(state): State>, + session: Session, + Path(id): Path, +) -> Result { + let dbi = state.db.lock().await; + let (title, w, h, grid_json, clues_json) = db::get_user_puzzle(&dbi.db, &id, &session.token) + .map(|(_, title, w, h, grid, clues, _, _)| (title, w, h, grid, clues)) + .ok_or(AppError("puzzle not found".into()))?; + + let grid: Vec> = serde_json::from_str(&grid_json).unwrap_or_default(); + let clues_data: Vec = clues_json.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default(); + + // build ipuz + let mut puzzle_grid = Vec::new(); + let mut solution_grid = Vec::new(); + let mut cell_numbers = std::collections::HashMap::new(); + for c in &clues_data { + let num = c.get("number").and_then(|v| v.as_u64()).unwrap_or(0); + let sr = c.get("startRow").and_then(|v| v.as_u64()).unwrap_or(0) as usize; + let sc = c.get("startCol").and_then(|v| v.as_u64()).unwrap_or(0) as usize; + cell_numbers.entry((sr, sc)).or_insert(num); + } + + // build letter map from clues + let mut letter_map: Vec>> = vec![vec![None; w as usize]; h as usize]; + for c in &clues_data { + let answer = c.get("answer").and_then(|v| v.as_str()).unwrap_or(""); + let dir = c.get("direction").and_then(|v| v.as_str()).unwrap_or(""); + let sr = c.get("startRow").and_then(|v| v.as_u64()).unwrap_or(0) as usize; + let sc = c.get("startCol").and_then(|v| v.as_u64()).unwrap_or(0) as usize; + for (i, ch) in answer.chars().enumerate() { + let (r, c) = if dir == "across" { (sr, sc + i) } else { (sr + i, sc) }; + if r < h as usize && c < w as usize { letter_map[r][c] = Some(ch); } + } + } + + for r in 0..h as usize { + let mut pr = Vec::new(); + let mut sr = Vec::new(); + for c in 0..w as usize { + if grid.get(r).and_then(|row| row.get(c)).map(|v| v.as_str()) == Some("black") { + pr.push(serde_json::json!("#")); + sr.push(serde_json::json!("#")); + } else { + let num = cell_numbers.get(&(r, c)); + pr.push(if let Some(&n) = num { serde_json::json!(n) } else { serde_json::json!(0) }); + sr.push(if let Some(ch) = letter_map[r][c] { serde_json::json!(ch.to_string()) } else { serde_json::json!(null) }); + } + } + puzzle_grid.push(pr); + solution_grid.push(sr); + } + + let across: Vec = clues_data.iter() + .filter(|c| c.get("direction").and_then(|v| v.as_str()) == Some("across")) + .map(|c| serde_json::json!([c.get("number").and_then(|v| v.as_u64()).unwrap_or(0), c.get("text").and_then(|v| v.as_str()).unwrap_or("")])) + .collect(); + let down: Vec = clues_data.iter() + .filter(|c| c.get("direction").and_then(|v| v.as_str()) == Some("down")) + .map(|c| serde_json::json!([c.get("number").and_then(|v| v.as_u64()).unwrap_or(0), c.get("text").and_then(|v| v.as_str()).unwrap_or("")])) + .collect(); + + let ipuz = serde_json::json!({ + "version": "http://ipuz.org/v2", + "kind": ["http://ipuz.org/crossword#1"], + "title": title, + "dimensions": { "width": w, "height": h }, + "puzzle": puzzle_grid, + "solution": solution_grid, + "clues": { "Across": across, "Down": down }, + }); + + let body = serde_json::to_string_pretty(&ipuz).map_err(|e| AppError(e.to_string()))?; + let safe_title = sanitize_filename(&title.replace(' ', "_").to_lowercase()); + let filename = if safe_title.is_empty() { "puzzle.ipuz".to_string() } else { format!("{}.ipuz", safe_title) }; + Ok(( + [(axum::http::header::CONTENT_TYPE, "application/json"), + (axum::http::header::CONTENT_DISPOSITION, &format!("attachment; filename=\"{}\"", filename))], + body, + ).into_response()) +} + +pub async fn editor_export_all( + State(state): State>, + session: Session, +) -> Result { + let dbi = state.db.lock().await; + let puzzles = db::list_user_puzzles(&dbi.db, &session.token); + let mut files: Vec<(String, String)> = Vec::new(); + + for (id, _title, _, _, _, _) in &puzzles { + if let Some((_, t, w, h, grid, clues, _, _)) = db::get_user_puzzle(&dbi.db, id, &session.token) { + let ipuz = serde_json::json!({ + "version": "http://ipuz.org/v2", + "kind": ["http://ipuz.org/crossword#1"], + "title": t, + "dimensions": { "width": w, "height": h }, + "grid": grid, + "clues_data": clues, + }); + let fname = format!("{}.ipuz", t.replace(' ', "_").to_lowercase()); + files.push((fname, serde_json::to_string_pretty(&ipuz).unwrap_or_default())); + } + } + + // build a simple concatenated JSON array as export (zip would need another dep) + let export = serde_json::json!({ "puzzles": files.iter().map(|(name, data)| serde_json::json!({ "filename": name, "data": data })).collect::>() }); + Ok(( + [(axum::http::header::CONTENT_TYPE, "application/json"), + (axum::http::header::CONTENT_DISPOSITION, "attachment; filename=\"cruciverb-puzzles-export.json\"")], + serde_json::to_string_pretty(&export).unwrap_or_default(), + ).into_response()) +} + +pub async fn editor_import( + State(state): State>, + session: Session, + Json(req): Json, +) -> Result { + let puzzles = req.get("puzzles").and_then(|v| v.as_array()); + let single = if puzzles.is_none() { Some(&req) } else { None }; + + let items: Vec<&serde_json::Value> = if let Some(arr) = puzzles { + arr.iter().collect() + } else if let Some(s) = single { + vec![s] + } else { + return Err(AppError("no puzzle data found".into())); + }; + + let mut imported = 0; + let dbi = state.db.lock().await; + + for item in items { + // parse ipuz format + let ipuz = if let Some(data) = item.get("data") { + // bulk export format: { filename, data } + if let Some(s) = data.as_str() { + serde_json::from_str::(s).unwrap_or_default() + } else { + data.clone() + } + } else { + item.clone() + }; + + let title = ipuz.get("title").and_then(|v| v.as_str()).unwrap_or("Imported puzzle"); + let dims = ipuz.get("dimensions"); + let w = dims.and_then(|d| d.get("width")).and_then(|v| v.as_u64()).unwrap_or(15) as usize; + let h = dims.and_then(|d| d.get("height")).and_then(|v| v.as_u64()).unwrap_or(15) as usize; + + // build grid from ipuz puzzle array + let mut grid: Vec> = Vec::new(); + let mut letters: Vec>> = Vec::new(); + if let Some(puzzle_arr) = ipuz.get("puzzle").and_then(|v| v.as_array()) { + for row in puzzle_arr { + let mut gr = Vec::new(); + let mut lr = Vec::new(); + if let Some(cells) = row.as_array() { + for cell in cells { + if cell.as_str() == Some("#") { + gr.push("black".to_string()); + lr.push(None); + } else { + gr.push("white".to_string()); + lr.push(None); + } + } + } + grid.push(gr); + letters.push(lr); + } + } + + // extract solution letters + if let Some(sol) = ipuz.get("solution").and_then(|v| v.as_array()) { + for (r, row) in sol.iter().enumerate() { + if let Some(cells) = row.as_array() { + for (c, cell) in cells.iter().enumerate() { + if let Some(s) = cell.as_str() { + if s != "#" && r < letters.len() && c < letters[r].len() { + letters[r][c] = Some(s.to_string()); + } + } + } + } + } + } + + // extract clues, recovering each answer from the solution grid so + // imported puzzles are playable instead of shipping empty answers + let mut editor_clues: Vec = Vec::new(); + let answer_at = |r: usize, c: usize, dir_key: &str| -> String { + let mut answer = String::new(); + if dir_key == "across" { + let mut cc = c; + while cc < w { + if let Some(Some(ch)) = letters.get(r).and_then(|row| row.get(cc)) { + answer.push_str(ch); + cc += 1; + } else { + break; + } + } + } else { + let mut rr = r; + while rr < h { + if let Some(Some(ch)) = letters.get(rr).and_then(|row| row.get(c)) { + answer.push_str(ch); + rr += 1; + } else { + break; + } + } + } + answer + }; + for (dir_name, dir_key) in [("Across", "across"), ("Down", "down")] { + if let Some(clue_arr) = ipuz.get("clues").and_then(|c| c.get(dir_name)).and_then(|v| v.as_array()) { + for clue in clue_arr { + if let Some(arr) = clue.as_array() { + if arr.len() >= 2 { + let num = arr[0].as_u64().unwrap_or(0); + let text = arr[1].as_str().unwrap_or(""); + // find the numbered cell in the ipuz puzzle grid + let mut answer = String::new(); + 'find: { + if let Some(puzzle_arr) = ipuz.get("puzzle").and_then(|v| v.as_array()) { + for (r, row) in puzzle_arr.iter().enumerate() { + if let Some(cells) = row.as_array() { + for (c, cell) in cells.iter().enumerate() { + if cell.as_u64() == Some(num) { + answer = answer_at(r, c, dir_key); + break 'find; + } + } + } + } + } + } + editor_clues.push(serde_json::json!({ + "number": num, "direction": dir_key, "text": text, "answer": answer + })); + } + } + } + } + } + + let id = Uuid::new_v4().to_string(); + let grid_str = serde_json::to_string(&grid).unwrap_or_default(); + let clues_str = if editor_clues.is_empty() { None } else { Some(serde_json::to_string(&editor_clues).unwrap_or_default()) }; + db::save_user_puzzle(&dbi.db, &id, &session.token, title, w, h, + &grid_str, clues_str.as_deref(), None, "draft"); + imported += 1; + } + + Ok(with_session_cookie(&session, Json(serde_json::json!({ "imported": imported })))) +} + +// ---- webhook endpoints ---- + +pub async fn create_webhook( + State(state): State>, + session: Session, + Json(req): Json, +) -> Result { + let url = req.get("url").and_then(|v| v.as_str()).ok_or(AppError("missing url".into()))?; + if crate::security::is_internal_url(url) { + return Err(AppError("webhook URL cannot target internal addresses".into())); + } + let events = req.get("events").and_then(|v| v.as_str()).unwrap_or("daily_puzzle.published"); + let secret = req.get("secret").and_then(|v| v.as_str()); + let format = req.get("format").and_then(|v| v.as_str()).unwrap_or("json"); + let id = Uuid::new_v4().to_string(); + + let dbi = state.db.lock().await; + db::create_webhook(&dbi.db, &id, url, events, secret, format); + Ok(with_session_cookie(&session, Json(serde_json::json!({ "id": id })))) +} + +pub async fn list_webhooks( + State(state): State>, + session: Session, +) -> Result { + let dbi = state.db.lock().await; + let hooks = db::list_webhooks(&dbi.db); + let result: Vec = hooks.iter().map(|(id, url, events, _, format, enabled, failures)| { + serde_json::json!({ "id": id, "url": url, "events": events, "format": format, "enabled": enabled, "failure_count": failures }) + }).collect(); + Ok(with_session_cookie(&session, Json(serde_json::json!({ "webhooks": result })))) +} + +pub async fn delete_webhook( + State(state): State>, + session: Session, + Path(id): Path, +) -> Result { + let dbi = state.db.lock().await; + db::delete_webhook(&dbi.db, &id); + Ok(with_session_cookie(&session, Json(serde_json::json!({ "ok": true })))) +} + +pub async fn browse_community_puzzles( + State(state): State>, + Query(params): Query>, + session: Session, +) -> Result { + let limit: usize = params.get("limit").and_then(|l| l.parse().ok()).unwrap_or(50).min(100); + let offset: usize = params.get("offset").and_then(|o| o.parse().ok()).unwrap_or(0); + let dbi = state.db.lock().await; + let puzzles = db::list_published_puzzles(&dbi.db, limit, offset); + let result: Vec = puzzles.iter().map(|(id, title, author, w, h, updated, origin)| { + let check_domain = origin.as_deref().unwrap_or(""); + let rep = if !check_domain.is_empty() { + Some(crate::db::get_instance_reputation(&dbi.db, check_domain)) + } else { + None + }; + let low_rep = rep.map(|s| s < 50.0).unwrap_or(false); + serde_json::json!({ + "id": id, "title": title, "author": author, + "width": w, "height": h, "updated_at": updated, + "origin": origin, "origin_reputation": rep, "low_reputation": low_rep, + }) + }).collect(); + Ok(with_session_cookie(&session, Json(serde_json::json!({ "puzzles": result })))) +} + +pub async fn community_puzzles_today( + State(state): State>, + session: Session, +) -> Result { + let dbi = state.db.lock().await; + let mut stmt = dbi.db.prepare( + "SELECT id, title, session_token, width, height, updated_at FROM user_puzzles + WHERE status = 'published' AND created_at >= datetime('now', '-24 hours') + ORDER BY created_at DESC LIMIT 50" + ).map_err(|e| AppError(e.to_string()))?; + let puzzles: Vec = stmt.query_map([], |row| { + let id: String = row.get(0)?; + let title: String = row.get(1)?; + let author: String = row.get(2)?; + let w: i64 = row.get(3)?; + let h: i64 = row.get(4)?; + let updated: String = row.get(5)?; + Ok(serde_json::json!({ + "id": id, "title": title, "author": author, + "width": w, "height": h, "updated_at": updated, + })) + }).map_err(|e| AppError(e.to_string()))?.filter_map(|r| r.ok()).collect(); + Ok(with_session_cookie(&session, Json(serde_json::json!({ "puzzles": puzzles })))) +} + +pub async fn upload_daily_image( + State(_state): State>, + ConnectInfo(source): ConnectInfo, + headers: axum::http::HeaderMap, + Json(body): Json, +) -> Result, AppError> { + // the daily image script runs on the local machine; the socket address is + // the only trustworthy way to prove that (request headers are not) + let is_local = source.ip().is_loopback(); + let has_admin = headers.get("x-admin-session") + .and_then(|v| v.to_str().ok()) + .map(|t| crate::admin::verify_admin_session(t)) + .unwrap_or(false); + if !is_local && !has_admin { + return Err(AppError::with_status(StatusCode::FORBIDDEN, "not authorized")); + } + let data = body.get("data").and_then(|d| d.as_str()) + .ok_or_else(|| AppError("missing data field".into()))?; + // strip data:image/jpeg;base64, prefix if present + let b64 = if let Some(idx) = data.find(",") { &data[idx + 1..] } else { data }; + use base64::Engine; + let bytes = base64::engine::general_purpose::STANDARD.decode(b64) + .map_err(|e| AppError(format!("invalid base64: {}", e)))?; + let data_dir = std::env::var("DATA_DIR").unwrap_or_else(|_| "../data".into()); + let path = std::path::PathBuf::from(&data_dir).join("daily-image.jpg"); + std::fs::write(&path, &bytes).map_err(|e| AppError(format!("failed to write image: {}", e)))?; + tracing::info!("saved daily image ({} bytes)", bytes.len()); + Ok(Json(serde_json::json!({ "ok": true }))) +} + +pub async fn serve_daily_image( + State(_state): State>, +) -> Result { + let data_dir = std::env::var("DATA_DIR").unwrap_or_else(|_| "../data".into()); + let path = std::path::PathBuf::from(&data_dir).join("daily-image.jpg"); + if !path.exists() { + return Err(AppError::with_status(StatusCode::NOT_FOUND, "no daily image")); + } + let bytes = std::fs::read(&path).map_err(|e| AppError(e.to_string()))?; + Ok(( + [(axum::http::header::CONTENT_TYPE, "image/jpeg"), + (axum::http::header::CACHE_CONTROL, "public, max-age=3600")], + bytes, + ).into_response()) +} + +pub async fn ap_post_permalink( + State(state): State>, + headers: axum::http::HeaderMap, + axum::extract::OriginalUri(uri): axum::extract::OriginalUri, +) -> Result { + let path = uri.path().to_string(); + + // if the request wants AP JSON, look up the outbox activity + let accept = headers.get("accept").and_then(|v| v.to_str().ok()).unwrap_or(""); + if accept.contains("application/activity+json") || accept.contains("application/ld+json") { + let dbi = state.db.lock().await; + let domain = state.ap.as_ref().map(|a| a.domain.as_str()).unwrap_or(""); + let note_uri = format!("https://{}{}", domain, path); + let activity_id = format!("{}/activity", note_uri); + if let Ok(json) = dbi.db.query_row( + "SELECT activity FROM ap_outbox WHERE id = ?1", [&activity_id], |r| r.get::<_, String>(0), + ) { + return Ok(( + [(axum::http::header::CONTENT_TYPE, "application/activity+json")], + json, + ).into_response()); + } + return Err(AppError::with_status(StatusCode::NOT_FOUND, "post not found")); + } + + // HTML permalink page + let domain = state.ap.as_ref().map(|a| a.domain.as_str()).unwrap_or(""); + let note_uri = format!("https://{}{}", domain, path); + let activity_id = format!("{}/activity", note_uri); + + let dbi = state.db.lock().await; + let activity_json = dbi.db.query_row( + "SELECT activity FROM ap_outbox WHERE id = ?1", [&activity_id], |r| r.get::<_, String>(0), + ).ok(); + drop(dbi); + + let (content, published, image_url) = if let Some(json) = &activity_json { + let v: serde_json::Value = serde_json::from_str(json).unwrap_or_default(); + let content = v.pointer("/object/content").and_then(|c| c.as_str()).unwrap_or("").to_string(); + let content = crate::security::sanitize_note_html(&content); + let published = v.get("published").and_then(|p| p.as_str()).unwrap_or("").to_string(); + let image = v.pointer("/object/attachment/0/url").and_then(|u| u.as_str()).unwrap_or("").to_string(); + (content, published, image) + } else { + // post not in outbox - might be an old deleted post, show a minimal page + (String::new(), String::new(), String::new()) + }; + + let title = "cruciverb - daily puzzles"; + let description = "Five crossword puzzles in five sizes, from very easy to expert."; + let og_image = if image_url.is_empty() { format!("https://{}/og-image.png", domain) } else { image_url.clone() }; + + let html = format!(r#" + + + + +{title} + + + + + + + + + + +
+
+

cruciverb

+ {published_html} +
+ {content_html} + {image_html} + +
+ +"#, + title = crate::security::sanitize_html(title), + description = crate::security::sanitize_html(description), + og_image = crate::security::sanitize_html(&og_image), + note_uri = crate::security::sanitize_html(¬e_uri), + domain = crate::security::sanitize_html(domain), + published_html = if published.is_empty() { String::new() } else { + format!("
{}
", crate::security::sanitize_html(&published)) + }, + content_html = if content.is_empty() { + "

This post is no longer available.

".to_string() + } else { + format!("
{}
", content) + }, + image_html = if image_url.is_empty() { String::new() } else { + format!("
\"Daily
", crate::security::sanitize_html(&image_url)) + }, + ); + + Ok(( + [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")], + html, + ).into_response()) +} + +pub async fn daily_post_page( + State(state): State>, + Path(date): Path, +) -> Result { + // validate date format + if chrono::NaiveDate::parse_from_str(&date, "%Y-%m-%d").is_err() { + return Err(AppError::with_status(StatusCode::BAD_REQUEST, "invalid date")); + } + + let domain = state.ap.as_ref().map(|a| a.domain.as_str()).unwrap_or("localhost"); + let parsed_date = chrono::NaiveDate::parse_from_str(&date, "%Y-%m-%d").unwrap(); + let today = chrono::Utc::now().date_naive(); + let is_past = parsed_date < today; + let days_ago = (today - parsed_date).num_days(); + let formatted_date = parsed_date.format("%A, %B %-d, %Y").to_string(); + + // get tagline/signoff for this date + let (tagline, signoff) = { + let dbi = state.db.lock().await; + let ct = crate::activitypub::outbox::load_custom_lines(&dbi.db, "ap_daily_taglines"); + let cs = crate::activitypub::outbox::load_custom_lines(&dbi.db, "ap_daily_signoffs"); + let t = crate::activitypub::outbox::pick_line(&date, crate::activitypub::outbox::TAGLINES, &ct, "tagline").to_string(); + let s = crate::activitypub::outbox::pick_line(&date, crate::activitypub::outbox::SIGNOFFS, &cs, "signoff").to_string(); + (t, s) + }; + + let page_url = format!("https://{}/post/{}", domain, date); + let image_url = format!("https://{}/daily-image", domain); + let site_url = format!("https://{}", domain); + let title = format!("cruciverb - {}", formatted_date); + let description = format!("{}. {}", tagline, signoff); + + let archive_banner = if is_past { + let ago = if days_ago == 1 { "yesterday".to_string() } else { format!("{} days ago", days_ago) }; + let today_url = format!("https://{}/post/{}", domain, today.format("%Y-%m-%d")); + format!( + r#"
From the archive
These puzzles were published {}. Solving them counts toward your history and stats, but not your daily streak.
See today's puzzles instead
"#, + crate::security::sanitize_html(&ago), + crate::security::sanitize_html(&today_url), + ) + } else { + String::new() + }; + + let html = format!(r##" + + + + +{title} + + + + + + + + + + + + + + + + + + + + + + +
+
+
+

cruciverb

+
{formatted_date}
+
daily puzzles
+
+
+ {archive_banner} +
{tagline}
+ +
{signoff}
+
Five crossword puzzle grids side by side representing today's daily puzzles from very easy to expert.
+ + + +
+ +"##, + title = crate::security::sanitize_html(&title), + og_desc = crate::security::sanitize_html(&description), + image_url = crate::security::sanitize_html(&image_url), + page_url = crate::security::sanitize_html(&page_url), + site_url = crate::security::sanitize_html(&site_url), + formatted_date = crate::security::sanitize_html(&formatted_date), + date = crate::security::sanitize_html(&date), + tagline = crate::security::sanitize_html(&tagline), + signoff = crate::security::sanitize_html(&signoff), + archive_banner = archive_banner, + ); + + Ok(( + [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8"), + (axum::http::header::CACHE_CONTROL, "public, max-age=86400")], + html, + ).into_response()) +} + +pub async fn list_federated_puzzles( + State(state): State>, + session: Session, +) -> Result { + let dbi = state.db.lock().await; + let puzzles = db::list_federated_puzzles(&dbi.db, 20, 0); + Ok(with_session_cookie(&session, Json(serde_json::json!({ + "puzzles": puzzles.iter().map(|(id, title, w, h, origin, date)| { + serde_json::json!({ "id": id, "title": title, "width": w, "height": h, "origin": origin, "updated_at": date }) + }).collect::>() + })))) +} + +pub async fn play_community_puzzle( + State(state): State>, + session: Session, + Path(id): Path, +) -> Result { + let dbi = state.db.lock().await; + let (title, w, h, grid_json, clues_json) = db::get_published_puzzle(&dbi.db, &id) + .ok_or(AppError("puzzle not found".into()))?; + + let grid: Vec> = serde_json::from_str(&grid_json).unwrap_or_default(); + let clues: Vec = serde_json::from_str(&clues_json).unwrap_or_default(); + + // build cells array for client + let cells: Vec> = grid.iter().map(|row| { + row.iter().map(|c| if c == "black" { "black" } else { "empty" }).collect() + }).collect(); + + // build cell_numbers and extract letters for solution checking + let mut cell_numbers = std::collections::HashMap::new(); + let mut solution: Vec>> = vec![vec![None; w as usize]; h as usize]; + + for clue in &clues { + let num = clue.get("number").and_then(|v| v.as_u64()).unwrap_or(0) as u32; + let dir = clue.get("direction").and_then(|v| v.as_str()).unwrap_or(""); + let answer = clue.get("answer").and_then(|v| v.as_str()).unwrap_or(""); + let start_row = clue.get("startRow").and_then(|v| v.as_u64()).unwrap_or(0) as usize; + let start_col = clue.get("startCol").and_then(|v| v.as_u64()).unwrap_or(0) as usize; + + cell_numbers.insert(format!("{},{}", start_row, start_col), num); + + for (i, ch) in answer.chars().enumerate() { + let (r, c) = if dir == "across" { (start_row, start_col + i) } else { (start_row + i, start_col) }; + if r < h as usize && c < w as usize { + solution[r][c] = Some(ch.to_string()); + } + } + } + + let client_clues_across: Vec = clues.iter() + .filter(|c| c.get("direction").and_then(|v| v.as_str()) == Some("across")) + .map(|c| serde_json::json!({ + "number": c.get("number"), "direction": "across", + "text": c.get("text").and_then(|v| v.as_str()).unwrap_or(""), + })).collect(); + + let client_clues_down: Vec = clues.iter() + .filter(|c| c.get("direction").and_then(|v| v.as_str()) == Some("down")) + .map(|c| serde_json::json!({ + "number": c.get("number"), "direction": "down", + "text": c.get("text").and_then(|v| v.as_str()).unwrap_or(""), + })).collect(); + + // store as a playable puzzle so validate/hint routes work + let puzzle_id = Uuid::new_v4(); + let mut grid_obj = cruciverb_core::grid::Grid::new(w as usize, h as usize); + for (r, row) in grid.iter().enumerate() { + for (c, cell) in row.iter().enumerate() { + if cell == "black" { + grid_obj.set(r, c, cruciverb_core::grid::Cell::Black); + } else if let Some(ref letter) = solution[r][c] { + if let Some(ch) = letter.chars().next() { + grid_obj.set(r, c, cruciverb_core::grid::Cell::Letter(ch)); + } + } + } + } + + // build the placed-words list as well, so hint reveal-word and other + // word-based routes behave like generated puzzles + let mut placed_words: Vec = Vec::new(); + for c in clues.iter() { + let num = c.get("number").and_then(|v| v.as_u64()).unwrap_or(0) as u32; + let answer = c.get("answer").and_then(|v| v.as_str()).unwrap_or(""); + if answer.is_empty() { + continue; + } + let sr = c.get("startRow").and_then(|v| v.as_u64()).unwrap_or(0) as usize; + let sc = c.get("startCol").and_then(|v| v.as_u64()).unwrap_or(0) as usize; + let direction = if c.get("direction").and_then(|v| v.as_str()) == Some("down") { + cruciverb_core::grid::slot::Direction::Down + } else { + cruciverb_core::grid::slot::Direction::Across + }; + placed_words.push(cruciverb_core::puzzle::PlacedWord { + word: answer.to_string(), + row: sr, + col: sc, + direction, + clue_number: num, + }); + } + + let puzzle = cruciverb_core::puzzle::Puzzle { + grid: grid_obj, + words: placed_words, + clues_across: clues.iter() + .filter(|c| c.get("direction").and_then(|v| v.as_str()) == Some("across")) + .map(|c| cruciverb_core::puzzle::Clue { + number: c.get("number").and_then(|v| v.as_u64()).unwrap_or(0) as u32, + direction: cruciverb_core::grid::slot::Direction::Across, + text: c.get("text").and_then(|v| v.as_str()).unwrap_or("").to_string(), + answer: c.get("answer").and_then(|v| v.as_str()).unwrap_or("").to_string(), + definition: None, wordplay_type: None, + }).collect(), + clues_down: clues.iter() + .filter(|c| c.get("direction").and_then(|v| v.as_str()) == Some("down")) + .map(|c| cruciverb_core::puzzle::Clue { + number: c.get("number").and_then(|v| v.as_u64()).unwrap_or(0) as u32, + direction: cruciverb_core::grid::slot::Direction::Down, + text: c.get("text").and_then(|v| v.as_str()).unwrap_or("").to_string(), + answer: c.get("answer").and_then(|v| v.as_str()).unwrap_or("").to_string(), + definition: None, wordplay_type: None, + }).collect(), + width: w as usize, height: h as usize, + symmetry: cruciverb_core::config::SymmetryType::None, + difficulty_score: 0, generation_time_ms: 0, + theme: None, theme_entries: vec![], is_cryptic: false, + }; + + let short_id = db::make_short_id(&puzzle_id); + state.cache_puzzle(puzzle_id, puzzle).await; + + Ok(with_session_cookie(&session, Json(serde_json::json!({ + "id": puzzle_id.to_string(), + "short_id": short_id, + "puzzle": { + "width": w, "height": h, + "cells": cells, + "clues_across": client_clues_across, + "clues_down": client_clues_down, + "difficulty_score": 0, + "cell_numbers": cell_numbers, + }, + "title": title, + "community": true, + })))) +} + +fn has_offensive_pattern(cells: &[Vec<&str>], width: usize, height: usize) -> bool { + // detect swastika-like patterns by checking for the characteristic + // bent cross shape at various positions and scales + let is_black = |r: i32, c: i32| -> bool { + if r < 0 || c < 0 || r >= height as i32 || c >= width as i32 { return false; } + cells[r as usize][c as usize] == "black" + }; + + // check at different scales (arm length 2-5) + for arm in 2..=5i32 { + for cr in 0..height as i32 { + for cc in 0..width as i32 { + // a swastika has 4 arms from a center, each bending at a right angle + // check both clockwise and counterclockwise variants + for &dir in &[1i32, -1] { + // center must be black + if !is_black(cr, cc) { continue; } + + // 4 arms: up-right, right-down, down-left, left-up (or mirrored) + let arms_ok = (1..=arm).all(|i| is_black(cr - i, cc)) // up + && is_black(cr - arm, cc + dir) // up arm bends + && (1..=arm).all(|i| is_black(cr, cc + i)) // right + && is_black(cr + dir, cc + arm) // right arm bends + && (1..=arm).all(|i| is_black(cr + i, cc)) // down + && is_black(cr + arm, cc - dir) // down arm bends + && (1..=arm).all(|i| is_black(cr, cc - i)) // left + && is_black(cr - dir, cc - arm); // left arm bends + + if arms_ok { return true; } + } + } + } + } + // also check for SS bolts (two parallel zigzag / lightning bolt shapes) + // the sig rune looks like: go down, shift right, go down (or mirrored) + for arm in 2..=4i32 { + for cr in 0..height as i32 - arm * 2 { + for cc in 0..width as i32 - arm - 2 { + for &dir in &[1i32, -1] { + // single bolt: vertical-diagonal-vertical pattern + let bolt = |sc: i32| -> bool { + (0..arm).all(|i| is_black(cr + i, sc)) + && (1..=arm).all(|i| is_black(cr + arm - 1 + i, sc + dir * i)) + && (0..arm).all(|i| is_black(cr + arm * 2 - 1 + i, sc + dir * arm)) + }; + // two parallel bolts = SS + if bolt(cc) && bolt(cc + arm + 1) { + return true; + } + } + } + } + } + + false +} + +fn load_theme_words(theme: &str) -> Option> { + let safe_name: String = theme.chars().filter(|c| c.is_alphanumeric() || *c == '_' || *c == '-').collect(); + let data_dir = std::env::var("DATA_DIR").unwrap_or_else(|_| "../data".into()); + let path = std::path::PathBuf::from(&data_dir).join("themes").join(format!("{}.txt", safe_name)); + let content = std::fs::read_to_string(&path).ok()?; + let words: std::collections::HashSet = content.lines() + .map(|l| l.trim().to_uppercase()) + .filter(|l| !l.is_empty() && l.chars().all(|c| c.is_alphabetic())) + .collect(); + if words.is_empty() { None } else { Some(words) } +} + +pub async fn list_themes() -> Json { + let data_dir = std::env::var("DATA_DIR").unwrap_or_else(|_| "../data".into()); + let themes_dir = std::path::PathBuf::from(&data_dir).join("themes"); + let mut themes = Vec::new(); + if let Ok(entries) = std::fs::read_dir(&themes_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("txt") { continue; } + if let Some(name) = path.file_stem().and_then(|s| s.to_str()) { + let count = match std::fs::read_to_string(&path) { + Ok(c) => c.lines().filter(|l| !l.trim().is_empty()).count(), + Err(e) => { + tracing::warn!("failed to read theme {}: {}", name, e); + 0 + } + }; + themes.push(serde_json::json!({ + "id": name, + "name": name.replace('_', " "), + "word_count": count, + })); + } + } + } + themes.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str())); + Json(serde_json::json!({ "themes": themes })) +} + +fn word_contains(word: &cruciverb_core::puzzle::PlacedWord, row: usize, col: usize) -> bool { + match word.direction { + Direction::Across => row == word.row && col >= word.col && col < word.col + word.word.len(), + Direction::Down => col == word.col && row >= word.row && row < word.row + word.word.len(), + } +} + +// ---- puzzle rating endpoints ---- + +pub async fn rate_puzzle( + State(state): State>, + session: Session, + Path(id): Path, + Json(req): Json, +) -> Result { + check_banned(&state, &session.token).await?; + let rating = req.get("rating").and_then(|v| v.as_i64()) + .ok_or(AppError("missing rating".into()))?; + if !(1..=5).contains(&rating) { + return Err(AppError("rating must be 1-5".into())); + } + + let dbi = state.db.lock().await; + let prev = db::get_my_rating(&dbi.db, &session.token, &id); + db::rate_puzzle(&dbi.db, &session.token, &id, rating as i32); + // first rating counts fully; changing an existing rating applies only the delta + let delta = rating as i64 - prev.unwrap_or(0) as i64; + db::update_quality_from_rating(&dbi.db, &id, delta, prev.is_none()); + let (avg, count) = db::get_puzzle_rating(&dbi.db, &id); + + // refresh quality caches + *state.word_quality.write().await = db::get_word_quality_scores(&dbi.db); + *state.clue_quality.write().await = db::get_clue_quality_scores(&dbi.db); + + Ok(with_session_cookie(&session, Json(serde_json::json!({ + "average": (avg * 10.0).round() / 10.0, + "count": count, + "my_rating": rating, + })))) +} + +pub async fn get_rating( + State(state): State>, + session: Session, + Path(id): Path, +) -> Result { + let dbi = state.db.lock().await; + let (avg, count) = db::get_puzzle_rating(&dbi.db, &id); + let my = db::get_my_rating(&dbi.db, &session.token, &id); + Ok(with_session_cookie(&session, Json(serde_json::json!({ + "average": (avg * 10.0).round() / 10.0, + "count": count, + "my_rating": my, + })))) +} + +pub async fn get_daily_ratings( + State(state): State>, + session: Session, + Path(date): Path, +) -> Result { + let dbi = state.db.lock().await; + let ratings = db::get_daily_ratings(&dbi.db, &date); + let result: serde_json::Map = ratings.iter().map(|(preset, avg, count)| { + (preset.clone(), serde_json::json!({ + "average": (*avg * 10.0).round() / 10.0, + "count": count, + })) + }).collect(); + Ok(with_session_cookie(&session, Json(serde_json::json!(result)))) +} + +// ---- community clue endpoints ---- + +pub async fn submit_community_clue( + State(state): State>, + session: Session, + Json(req): Json, +) -> Result { + check_banned(&state, &session.token).await?; + crate::security::check_rate_limit(&session.token, "submit_clue", 10, 60) + .map_err(|s| AppError::with_status(s, "rate limit exceeded"))?; + let word = req.get("word").and_then(|v| v.as_str()) + .ok_or(AppError("missing word".into()))?; + let clue_text = req.get("clue").and_then(|v| v.as_str()) + .ok_or(AppError("missing clue".into()))?; + + if word.is_empty() || clue_text.is_empty() { + return Err(AppError("word and clue must not be empty".into())); + } + if !word.chars().all(|c| c.is_alphabetic()) { + return Err(AppError("word must contain only letters".into())); + } + if clue_text.len() > 500 { + return Err(AppError("clue too long (max 500 characters)".into())); + } + + if db::text_contains_url(clue_text) { + return Err(AppError("clues cannot contain URLs".into())); + } + + let clean_clue = crate::security::sanitize_html(clue_text); + if state.blocklist.read().unwrap().contains_blocked(&clean_clue) { + return Err(AppError::with_status(axum::http::StatusCode::BAD_REQUEST, "clue contains inappropriate content")); + } + let dbi = state.db.lock().await; + let id = db::submit_clue(&dbi.db, word, &clean_clue, &session.token); + Ok(with_session_cookie(&session, Json(serde_json::json!({ "id": id })))) +} + +pub async fn get_community_clues( + State(state): State>, + session: Session, + Path(word): Path, +) -> Result { + if !word.chars().all(|c| c.is_alphabetic()) { + return Err(AppError("invalid word".into())); + } + + let dbi = state.db.lock().await; + + // check federated content filter + let settings_json = db::get_settings(&dbi.db, &session.token); + let settings: serde_json::Value = serde_json::from_str(&settings_json).unwrap_or_default(); + let filter = settings.get("federatedContentFilter").and_then(|v| v.as_str()).unwrap_or("all"); + let blocked_instances: Vec = settings.get("blockedInstances") + .and_then(|v| v.as_array()) + .map(|a| a.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect()) + .unwrap_or_default(); + + let clues = db::get_community_clues(&dbi.db, &word); + let clue_ids: Vec = clues.iter().map(|(id, ..)| *id).collect(); + let user_votes = db::get_user_votes(&dbi.db, &session.token, &clue_ids); + + let result: Vec = clues.iter() + .filter(|(_, _, _, _, _, _, origin)| { + match filter { + "local" => origin.is_none(), + "approved" => origin.is_none() || true, // show all but could filter more + _ => true, + } + }) + .filter(|(_, _, _, _, _, _, origin)| { + if let Some(d) = origin { !blocked_instances.contains(d) } else { true } + }) + .map(|(id, text, up, down, status, created, origin)| { + let my_vote = user_votes.iter().find(|(cid, _)| cid == id).map(|(_, v)| *v).unwrap_or(0); + serde_json::json!({ + "id": id, "clue": text, "upvotes": up, "downvotes": down, + "status": status, "created_at": created, "my_vote": my_vote, + "origin_domain": origin, + }) + }).collect(); + + Ok(with_session_cookie(&session, Json(serde_json::json!({ "clues": result })))) +} + +pub async fn vote_community_clue( + State(state): State>, + session: Session, + headers: axum::http::HeaderMap, + Path(id): Path, + Json(req): Json, +) -> Result { + check_banned(&state, &session.token).await?; + crate::security::check_rate_limit(&session.token, "vote_clue", 30, 60) + .map_err(|s| AppError::with_status(s, "rate limit exceeded"))?; + let ip = crate::security::extract_client_ip(&headers); + crate::security::check_rate_limit_ip(&ip, "vote_clue", 10, 60) + .map_err(|s| AppError::with_status(s, "rate limit exceeded"))?; + let vote = req.get("vote").and_then(|v| v.as_i64()) + .ok_or(AppError("missing vote".into()))?; + if vote != 1 && vote != -1 { + return Err(AppError("vote must be 1 or -1".into())); + } + + let dbi = state.db.lock().await; + db::vote_clue(&dbi.db, &session.token, id, vote as i32); + Ok(with_session_cookie(&session, Json(serde_json::json!({ "ok": true })))) +} + +pub async fn my_submissions( + State(state): State>, + session: Session, +) -> Result { + let dbi = state.db.lock().await; + let subs = db::get_my_submissions(&dbi.db, &session.token); + let result: Vec = subs.iter().map(|(id, word, clue, up, down, status)| { + serde_json::json!({ + "id": id, "word": word, "clue": clue, + "upvotes": up, "downvotes": down, "status": status, + }) + }).collect(); + Ok(with_session_cookie(&session, Json(serde_json::json!({ "submissions": result })))) +} + +pub async fn delete_submission( + State(state): State>, + session: Session, + Path(id): Path, +) -> Result { + let dbi = state.db.lock().await; + db::delete_submission(&dbi.db, id, &session.token); + Ok(with_session_cookie(&session, Json(serde_json::json!({ "ok": true })))) +} + +// ---- passkey endpoints ---- + +pub async fn passkey_register_start( + State(state): State>, + session: Session, +) -> Result { + let challenge = crate::webauthn::generate_challenge(); + let name = session.display_name.as_deref().unwrap_or("cruciverb player"); + let options = crate::webauthn::create_registration_challenge( + &challenge, &state.rp_id, "cruciverb", &session.token, name, + ); + + let now = chrono::Utc::now().timestamp(); + state.reg_challenges.lock().await.insert(challenge, (session.token.clone(), now)); + + Ok(with_session_cookie(&session, Json(serde_json::json!({ "options": options })))) +} + +pub async fn passkey_register_finish( + State(state): State>, + session: Session, + Json(response): Json, +) -> Result { + // find and consume the challenge (clean expired first) + let mut challenges = state.reg_challenges.lock().await; + let now = chrono::Utc::now().timestamp(); + challenges.retain(|_, (_, ts)| now - *ts < 300); + let challenge = challenges.iter() + .find(|(_, (tok, _))| tok == &session.token) + .map(|(c, _)| c.clone()); + let challenge = challenge.ok_or(AppError("no pending registration".into()))?; + challenges.remove(&challenge); + drop(challenges); + + let credential = crate::webauthn::verify_registration( + &response, &challenge, &state.rp_origin, &state.rp_id, + ).map_err(|e| AppError(format!("registration failed: {}", e)))?; + + let cred_id = base64::Engine::encode( + &base64::engine::general_purpose::URL_SAFE_NO_PAD, + &credential.credential_id, + ); + let passkey_json = serde_json::to_string(&credential) + .map_err(|e| AppError(e.to_string()))?; + + let dbi = state.db.lock().await; + db::save_passkey(&dbi.db, &cred_id, &session.token, &passkey_json, "My passkey"); + + Ok(with_session_cookie(&session, Json(serde_json::json!({ "ok": true })))) +} + +pub async fn passkey_auth_start( + State(state): State>, +) -> Result, AppError> { + let challenge = crate::webauthn::generate_challenge(); + let now = chrono::Utc::now().timestamp(); + + // for discoverable credentials, we send an empty allow list + let options = crate::webauthn::create_authentication_challenge(&challenge, &state.rp_id, &[]); + + state.auth_challenges.lock().await.insert(challenge, now); + + Ok(Json(serde_json::json!({ "options": options }))) +} + +pub async fn passkey_auth_finish( + State(state): State>, + Json(response): Json, +) -> Result { + // look up the credential by ID + let dbi = state.db.lock().await; + let (passkey_json, session_token) = db::get_passkey_json(&dbi.db, &response.id) + .ok_or(AppError("unknown credential".into()))?; + drop(dbi); + + let credential: crate::webauthn::StoredCredential = serde_json::from_str(&passkey_json) + .map_err(|e| AppError(format!("corrupt credential: {}", e)))?; + + // find and consume a valid challenge + let mut challenges = state.auth_challenges.lock().await; + let now = chrono::Utc::now().timestamp(); + // clean up expired challenges (> 2 min) + challenges.retain(|_, ts| now - *ts < 120); + + // try each challenge until one matches (the client embeds the challenge in clientDataJSON) + let mut matched: Option<(String, u32)> = None; + for (ch, _) in challenges.iter() { + match crate::webauthn::verify_authentication( + &response, &credential, ch, &state.rp_origin, &state.rp_id, + ) { + Ok(counter) => { + matched = Some((ch.clone(), counter)); + break; + } + Err(_) => continue, + } + } + + let (challenge, auth_counter) = matched.ok_or(AppError("authentication failed".into()))?; + challenges.remove(&challenge); + drop(challenges); + + // signature counter: catch clones of device-bound keys without breaking + // synced passkeys, which always report zero + let mut credential = credential; + { + let dbi = state.db.lock().await; + if auth_counter > 0 && credential.counter > 0 && auth_counter <= credential.counter { + tracing::warn!("possible cloned authenticator: counter did not advance"); + crate::db::log_admin_action(&dbi.db, "possible_cloned_key", None, None); + return Err(AppError::with_status( + axum::http::StatusCode::FORBIDDEN, + "authentication failed - possible cloned authenticator", + )); + } + if auth_counter > credential.counter { + credential.counter = auth_counter; + if let Ok(json) = serde_json::to_string(&credential) { + crate::db::update_passkey_json(&dbi.db, &response.id, &json); + } + } + } + + // create a session with the original token + let dbi = state.db.lock().await; + let (token, name) = db::get_or_create_session(&dbi.db, &session_token); + let settings_json = db::get_settings(&dbi.db, &token); + let cookie_max_age = serde_json::from_str::(&settings_json) + .ok() + .and_then(|v| v.get("cookieLifetime")?.as_u64()) + .unwrap_or(2_592_000); + + let session = Session { + token, display_name: name, is_new: false, cookie_max_age, + }; + + Ok(with_session_cookie(&session, Json(serde_json::json!({ + "ok": true, + "display_name": session.display_name, + })))) +} + +pub async fn list_passkeys( + State(state): State>, + session: Session, +) -> Result { + let dbi = state.db.lock().await; + let passkeys = db::get_passkeys(&dbi.db, &session.token); + let result: Vec = passkeys.iter().map(|(id, name, created)| { + serde_json::json!({ "id": id, "name": name, "created_at": created }) + }).collect(); + Ok(with_session_cookie(&session, Json(serde_json::json!({ "passkeys": result })))) +} + +// ---- privacy / account endpoints ---- + +pub async fn get_session_info( + State(state): State>, + session: Session, +) -> Result { + let dbi = state.db.lock().await; + let info = dbi.db.query_row( + "SELECT created_at, last_seen FROM sessions WHERE token = ?1", + [&session.token], + |row| Ok(serde_json::json!({ + "created_at": row.get::<_, String>(0)?, + "last_seen": row.get::<_, String>(1)?, + })), + ).unwrap_or(serde_json::json!(null)); + Ok(with_session_cookie(&session, Json(serde_json::json!({ "session": info })))) +} + +pub async fn delete_account( + State(state): State>, + session: Session, +) -> Result { + let puzzle_ids = { + let dbi = state.db.lock().await; + db::get_user_published_puzzles(&dbi.db, &session.token) + }; + + // send federation deletes for published puzzles + 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; + } + } + + let dbi = state.db.lock().await; + db::cleanup_user_content(&dbi.db, &session.token); + db::delete_account(&dbi.db, &session.token); + Ok(with_session_cookie(&session, Json(serde_json::json!({ "ok": true })))) +} + +pub async fn export_data( + State(state): State>, + session: Session, +) -> Result { + crate::security::check_rate_limit(&session.token, "export_data", 3, 60) + .map_err(|s| AppError::with_status(s, "rate limit exceeded"))?; + let dbi = state.db.lock().await; + let data = db::export_user_data(&dbi.db, &session.token); + Ok(( + [(axum::http::header::CONTENT_TYPE, "application/json"), + (axum::http::header::CONTENT_DISPOSITION, "attachment; filename=\"cruciverb-export.json\"")], + serde_json::to_string_pretty(&data).unwrap_or_default(), + ).into_response()) +} + +pub async fn import_data( + State(state): State>, + session: Session, + Json(data): Json, +) -> Result { + crate::security::check_rate_limit(&session.token, "import_data", 3, 60) + .map_err(|s| AppError::with_status(s, "rate limit exceeded"))?; + + let dbi = state.db.lock().await; + let (imported, skipped) = db::import_user_data(&dbi.db, &session.token, &data); + + Ok(with_session_cookie(&session, Json(serde_json::json!({ + "imported": imported, + "skipped": skipped, + })))) +} + +// ---- user migration ---- + +pub async fn migrate_account( + State(state): State>, + session: Session, + Json(req): Json, +) -> Result { + crate::security::check_rate_limit(&session.token, "migrate", 2, 300) + .map_err(|s| AppError::with_status(s, "rate limit exceeded"))?; + + let target_domain = req.get("target_domain").and_then(|v| v.as_str()) + .ok_or_else(|| AppError::with_status(StatusCode::BAD_REQUEST, "missing target_domain"))?; + + if target_domain.is_empty() || !target_domain.contains('.') { + return Err(AppError::with_status(StatusCode::BAD_REQUEST, "invalid target domain")); + } + + // get published puzzles before cleanup + let puzzle_ids = { + let dbi = state.db.lock().await; + db::get_user_published_puzzles(&dbi.db, &session.token) + }; + + // send federation deletes for published puzzles + 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; + } + } + + // send Move activity to all peers + crate::activitypub::outbox::publish_move(&state, target_domain).await; + + // export user data before cleanup + let dbi = state.db.lock().await; + let data = db::export_user_data(&dbi.db, &session.token); + db::cleanup_user_content(&dbi.db, &session.token); + drop(dbi); + + Ok(with_session_cookie(&session, Json(serde_json::json!({ + "ok": true, + "target": target_domain, + "export": data, + })))) +} + +// ---- user blocklist import/export ---- + +pub async fn export_user_blocklist( + State(state): State>, + session: Session, +) -> Result { + let dbi = state.db.lock().await; + let settings_json = db::get_settings(&dbi.db, &session.token); + let settings: serde_json::Value = serde_json::from_str(&settings_json).unwrap_or_default(); + let blocked = settings.get("blockedInstances").and_then(|v| v.as_array()) + .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect::>()) + .unwrap_or_default(); + + let body = serde_json::json!({ + "format": "cruciverb-user-blocklist-v1", + "exported_at": chrono::Utc::now().to_rfc3339(), + "count": blocked.len(), + "domains": blocked, + }); + + Ok(( + [(axum::http::header::CONTENT_TYPE, "application/json"), + (axum::http::header::CONTENT_DISPOSITION, "attachment; filename=\"my-blocklist.json\"")], + serde_json::to_string_pretty(&body).unwrap_or_default(), + ).into_response()) +} + +pub async fn import_user_blocklist( + State(state): State>, + session: Session, + Json(req): Json, +) -> Result { + let domains = req.get("domains").and_then(|v| v.as_array()) + .or_else(|| req.get("blocked").and_then(|v| v.as_array())); + + let new_domains: Vec = match domains { + Some(arr) => arr.iter().filter_map(|v| { + let d = if let Some(s) = v.as_str() { s.to_string() } + else if let Some(s) = v.get("domain").and_then(|d| d.as_str()) { s.to_string() } + else { return None }; + let d = d.trim().to_lowercase(); + if d.is_empty() { None } else { Some(d) } + }).collect(), + None => return Err(AppError("missing domains array".into())), + }; + + let dbi = state.db.lock().await; + let settings_json = db::get_settings(&dbi.db, &session.token); + let mut settings: serde_json::Value = serde_json::from_str(&settings_json).unwrap_or_default(); + + let mut existing: Vec = settings.get("blockedInstances") + .and_then(|v| v.as_array()) + .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()) + .unwrap_or_default(); + + let mut added = 0; + for d in &new_domains { + if !existing.contains(d) { existing.push(d.clone()); added += 1; } + } + + settings["blockedInstances"] = serde_json::to_value(&existing).unwrap_or_default(); + db::save_settings(&dbi.db, &session.token, &settings.to_string()); + + Ok(with_session_cookie(&session, Json(serde_json::json!({ "imported": added, "total": existing.len() })))) +} + +// ---- word of the day ---- + +pub async fn word_of_the_day( + State(state): State>, +) -> Json { + let today = chrono::Utc::now().format("%Y-%m-%d").to_string(); + + if state.wotd_words.is_empty() { + return Json(serde_json::json!({ + "word": "CRUCIVERB", + "definition": "One who creates or solves crossword puzzles.", + "clue": "Crossword enthusiast", + "length": 9 + })); + } + + // deterministic pick based on date hash + let hash = Sha256::digest(format!("wotd-{}", today).as_bytes()); + let idx = u64::from_le_bytes(hash[..8].try_into().unwrap()) as usize % state.wotd_words.len(); + let word = &state.wotd_words[idx]; + + let definition = state.definitions.get(word) + .or_else(|| state.definitions.get(&word.to_lowercase())) + .or_else(|| { + // try case-insensitive lookup + state.definitions.iter() + .find(|(k, _)| k.to_uppercase() == *word) + .map(|(_, v)| v) + }) + .cloned() + .unwrap_or_default(); + + // trim long definitions to something readable + let def_short = if definition.len() > 300 { + let cut = definition[..300].rfind(|c: char| c == '.' || c == ';').unwrap_or(300); + format!("{}...", &definition[..cut]) + } else { + definition + }; + + let clue = state.clue_db.get_clue(word, 1).unwrap_or_else(|_| "Crossword word".into()); + + Json(serde_json::json!({ + "word": word, + "definition": def_short, + "clue": clue, + "length": word.len() + })) +} + +// ---- clue trainer ---- + +#[derive(serde::Deserialize)] +pub struct TrainerQuery { + difficulty: Option, +} + +pub async fn trainer_clue( + State(state): State>, + Query(q): Query, +) -> Json { + let difficulty = q.difficulty.as_deref().unwrap_or("easy"); + + let clues = state.clue_db.random_clues(1); + if clues.is_empty() { + return Json(serde_json::json!({ + "clue": "No clues available", + "length": 0, + "pattern": "", + "difficulty": difficulty + })); + } + + let (clue_text, word) = &clues[0]; + let chars: Vec = word.chars().collect(); + + let pattern = match difficulty { + "easy" => { + // show first and last letter + chars.iter().enumerate().map(|(i, c)| { + if i == 0 || i == chars.len() - 1 { c.to_string() } else { "_".to_string() } + }).collect::>().join(" ") + } + "medium" => { + // just underscores + chars.iter().map(|_| "_".to_string()).collect::>().join(" ") + } + _ => String::new(), // hard: no pattern + }; + + Json(serde_json::json!({ + "clue": clue_text, + "length": word.len(), + "pattern": pattern, + "difficulty": difficulty + })) +} + +#[derive(serde::Deserialize)] +pub struct TrainerCheckRequest { + clue: String, + answer: String, +} + +pub async fn trainer_check( + State(state): State>, + Json(req): Json, +) -> Json { + let answer_upper = req.answer.trim().to_uppercase(); + + // look up what word goes with this clue + // we stored the clue text, so search for a matching word + let mut correct_word = None; + if let Some(ref db) = state.clue_db.db_ref() { + if let Ok(mut stmt) = db.prepare( + "SELECT word FROM clues WHERE clue = ?1 LIMIT 1" + ) { + if let Ok(rows) = stmt.query_map(rusqlite::params![req.clue], |row| row.get::<_, String>(0)) { + for w in rows.flatten() { + correct_word = Some(w); + break; + } + } + } + } + + // fallback: search json clues + if correct_word.is_none() { + correct_word = state.clue_db.find_word_for_clue(&req.clue); + } + + let answer = correct_word.unwrap_or_default(); + let is_correct = answer_upper == answer.to_uppercase(); + + Json(serde_json::json!({ + "correct": is_correct, + "answer": answer.to_uppercase() + })) +} + +// ---- content reporting ---- + +pub async fn submit_report( + State(state): State>, + session: Session, + Json(req): Json, +) -> Result { + check_banned(&state, &session.token).await?; + crate::security::check_rate_limit(&session.token, "report", 10, 60) + .map_err(|s| AppError::with_status(s, "rate limit exceeded"))?; + let content_type = req.get("content_type").and_then(|v| v.as_str()) + .ok_or(AppError("missing content_type".into()))?; + let content_id = req.get("content_id").and_then(|v| v.as_str()) + .ok_or(AppError("missing content_id".into()))?; + let reason = req.get("reason").and_then(|v| v.as_str()); + + if content_type != "clue" && content_type != "puzzle" { + return Err(AppError("content_type must be 'clue' or 'puzzle'".into())); + } + + let dbi = state.db.lock().await; + let id = db::submit_report(&dbi.db, &session.token, content_type, content_id, reason); + + // if reporting a federated clue, update instance reputation + if content_type == "clue" { + if let Ok(domain) = dbi.db.query_row( + "SELECT origin_domain FROM community_clues WHERE id = ?1", + [content_id], |row| row.get::<_, Option>(0), + ) { + if let Some(d) = domain { + db::increment_instance_reports(&dbi.db, &d); + } + } + } + + Ok(with_session_cookie(&session, Json(serde_json::json!({ "id": id })))) +} + +// #9: XOR encryption helpers (legacy format, kept for reading old rows) +fn xor_crypt(data: &[u8], key: &[u8]) -> Vec { + data.iter().enumerate().map(|(i, b)| b ^ key[i % key.len()]).collect() +} + +fn derive_key(token: &str) -> Vec { + Sha256::digest(token.as_bytes()).to_vec() +} + +fn enc_key() -> Option<[u8; 32]> { + let raw = std::env::var("DATA_ENC_KEY").ok()?; + let bytes = hex::decode(raw.trim()).ok()?; + bytes.try_into().ok() +} + +/// AES-256-GCM with the nonce prepended to the ciphertext. Returns None when +/// the server key is not configured, so callers can fall back to plaintext. +fn encrypt_progress(plain: &[u8]) -> Option> { + use aes_gcm::aead::{Aead, AeadCore, OsRng}; + use aes_gcm::{Aes256Gcm, KeyInit}; + let key = enc_key()?; + let cipher = Aes256Gcm::new_from_slice(&key).ok()?; + let nonce = Aes256Gcm::generate_nonce(&mut OsRng); + let ciphertext = cipher.encrypt(&nonce, plain).ok()?; + let mut out = Vec::with_capacity(nonce.len() + ciphertext.len()); + out.extend_from_slice(&nonce); + out.extend_from_slice(&ciphertext); + Some(out) +} + +fn decrypt_progress(data: &[u8]) -> Option> { + use aes_gcm::aead::Aead; + use aes_gcm::{Aes256Gcm, KeyInit}; + let key = enc_key()?; + let cipher = Aes256Gcm::new_from_slice(&key).ok()?; + let (nonce, ciphertext) = data.split_at(12); + cipher.decrypt(nonce.into(), ciphertext).ok() +} + +// #18: instance about page +pub async fn about_page( + State(state): State>, +) -> Json { + let contact = std::env::var("INSTANCE_CONTACT").unwrap_or_default(); + let (rules, peer_count) = { + let dbi = state.db.lock().await; + let r = db::get_server_config(&dbi.db, "instance_rules") + .or_else(|| std::env::var("INSTANCE_RULES").ok()) + .unwrap_or_else(|| "Be respectful. Submit quality clues. No spam.".into()); + let pc: i64 = dbi.db.query_row("SELECT COUNT(*) FROM ap_peers", [], |row| row.get(0)).unwrap_or(0); + (r, pc) + }; + let ap_enabled = state.ap.is_some(); + + Json(serde_json::json!({ + "software": "cruciverb", + "version": env!("CARGO_PKG_VERSION"), + "contact": contact, + "federation": ap_enabled, + "connected_instances": peer_count, + "rules": rules, + })) +} + +// #17: public moderation log +pub async fn public_moderation_log( + State(state): State>, +) -> Result, StatusCode> { + if std::env::var("PUBLIC_MOD_LOG").as_deref() != Ok("true") { + return Err(StatusCode::NOT_FOUND); + } + + let dbi = state.db.lock().await; + let entries = db::get_audit_log(&dbi.db, 50); + let moderation_actions = ["moderate_clue", "approve_follower", "reject_follower", + "block_instance", "unblock_instance", "auto_block_instance", "federation_freeze", "federation_unfreeze"]; + + let filtered: Vec = entries.iter() + .filter(|(_, action, _, _, _)| moderation_actions.contains(&action.as_str())) + .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": filtered }))) +} + +// #19: federation health dashboard +pub async fn federation_health( + State(state): State>, +) -> Json { + let ap_enabled = state.ap.is_some(); + if !ap_enabled { + return Json(serde_json::json!({ "enabled": false })); + } + + let dbi = state.db.lock().await; + let queue_depth: i64 = dbi.db.query_row( + "SELECT COUNT(*) FROM ap_delivery_queue", [], |r| r.get(0) + ).unwrap_or(0); + + let total_outbound: i64 = dbi.db.query_row( + "SELECT COALESCE(SUM(count), 0) FROM ap_stats WHERE direction = 'outbound'", + [], |r| r.get(0), + ).unwrap_or(0); + let total_inbound: i64 = dbi.db.query_row( + "SELECT COALESCE(SUM(count), 0) FROM ap_stats WHERE direction = 'inbound'", + [], |r| r.get(0), + ).unwrap_or(0); + + let peer_count: i64 = dbi.db.query_row( + "SELECT COUNT(*) FROM ap_peers", [], |r| r.get(0) + ).unwrap_or(0); + + let last_delivery: String = dbi.db.query_row( + "SELECT COALESCE(MAX(last_activity), '') FROM ap_stats WHERE direction = 'outbound'", + [], |r| r.get(0), + ).unwrap_or_default(); + + let failed_deliveries: i64 = dbi.db.query_row( + "SELECT COUNT(*) FROM ap_delivery_queue WHERE attempts > 0", [], |r| r.get(0) + ).unwrap_or(0); + + let success_rate = if total_outbound + failed_deliveries > 0 { + (total_outbound as f64 / (total_outbound + failed_deliveries) as f64 * 100.0).round() + } else { + 100.0 + }; + + Json(serde_json::json!({ + "enabled": true, + "frozen": crate::activitypub::is_frozen(), + "peers": peer_count, + "queue_depth": queue_depth, + "total_inbound": total_inbound, + "total_outbound": total_outbound, + "delivery_success_rate": success_rate, + "failed_in_queue": failed_deliveries, + "last_delivery": last_delivery, + })) +} + +pub async fn receive_federated_leaderboard( + State(state): State>, + headers: axum::http::HeaderMap, + body: String, +) -> Result, Response> { + // require HTTP signature from a known peer + let sig_header = headers.iter() + .find(|(k, _)| k.as_str().to_lowercase() == "signature") + .and_then(|(_, v)| v.to_str().ok()) + .ok_or_else(|| (StatusCode::UNAUTHORIZED, "missing signature").into_response())?; + + let key_domain = sig_header.split("keyId=\"") + .nth(1).and_then(|s| s.split('"').next()) + .and_then(|uri| uri.split("//").nth(1)) + .and_then(|s| s.split('/').next()) + .ok_or_else(|| (StatusCode::BAD_REQUEST, "bad signature format").into_response())? + .to_string(); + + let public_pem = { + let dbi = state.db.lock().await; + let blocked: bool = dbi.db.query_row( + "SELECT COUNT(*) > 0 FROM ap_blocked WHERE domain = ?1", + [&key_domain], |r| r.get(0), + ).unwrap_or(false); + if blocked { + return Err((StatusCode::FORBIDDEN, "blocked").into_response()); + } + dbi.db.query_row( + "SELECT public_pem FROM ap_peers WHERE domain = ?1", + [&key_domain], |r| r.get::<_, String>(0), + ).ok() + }; + + let public_pem = public_pem + .ok_or_else(|| (StatusCode::FORBIDDEN, "unknown peer").into_response())?; + + let header_pairs: Vec<(String, String)> = headers.iter() + .filter_map(|(k, v)| Some((k.as_str().to_string(), v.to_str().ok()?.to_string()))) + .collect(); + + crate::activitypub::signatures::verify_request( + &public_pem, "POST", "/api/federation/leaderboard", + &header_pairs, Some(body.as_bytes()), + ).map_err(|e| (StatusCode::UNAUTHORIZED, format!("signature error: {}", e)).into_response())?; + + let req: serde_json::Value = serde_json::from_str(&body) + .map_err(|_| (StatusCode::BAD_REQUEST, "invalid JSON").into_response())?; + + let origin = req.get("origin_domain").and_then(|v| v.as_str()) + .ok_or_else(|| (StatusCode::BAD_REQUEST, "missing origin_domain").into_response())?; + + if origin != key_domain { + return Err((StatusCode::FORBIDDEN, "origin mismatch").into_response()); + } + + let date = req.get("puzzle_date").and_then(|v| v.as_str()) + .ok_or_else(|| (StatusCode::BAD_REQUEST, "missing puzzle_date").into_response())?; + let preset = req.get("preset").and_then(|v| v.as_str()) + .ok_or_else(|| (StatusCode::BAD_REQUEST, "missing preset").into_response())?; + let entries = req.get("entries").and_then(|v| v.as_array()) + .ok_or_else(|| (StatusCode::BAD_REQUEST, "missing entries").into_response())?; + + if entries.len() > 100 { + return Err((StatusCode::BAD_REQUEST, "too many entries").into_response()); + } + + let dbi = state.db.lock().await; + let mut count = 0; + for entry in entries { + let name = entry.get("name").and_then(|n| n.as_str()).unwrap_or("Anonymous"); + let name = if name.len() > 100 { &name[..100] } else { name }; + let time = entry.get("time").and_then(|t| t.as_i64()).unwrap_or(0); + if time > 0 && time < 172_800 { + db::insert_federated_leaderboard(&dbi.db, origin, date, preset, name, time); + count += 1; + } + } + + Ok(Json(serde_json::json!({ "received": count }))) +} + +pub async fn get_puzzle_replies( + State(state): State>, + Path(id): Path, +) -> Json { + let dbi = state.db.lock().await; + let replies = db::get_ap_replies(&dbi.db, &id, 50); + let items: Vec = replies.into_iter().map(|(uri, name, content, published)| { + // turn actor URI into user@domain format + let author = uri.split("//").nth(1) + .map(|s| { + let domain = s.split('/').next().unwrap_or(""); + let user = s.rsplit('/').next().unwrap_or(""); + let user = user.trim_start_matches('@'); + format!("{}@{}", user, domain) + }) + .unwrap_or(uri); + serde_json::json!({ + "author": author, + "name": name, + "content": content, + "published": published, + }) + }).collect(); + Json(serde_json::json!({ "replies": items })) +} + +// wordlist packs + +pub async fn list_packs( + State(state): State>, + Query(params): Query>, +) -> Json { + let search = params.get("search").map(|s| s.as_str()); + let limit: usize = params.get("limit").and_then(|l| l.parse().ok()).unwrap_or(50); + let dbi = state.db.lock().await; + let rows = db::list_packs(&dbi.db, search, limit); + let packs: Vec = rows.into_iter().map(|(id, name, desc, author, tags, wc, dl, avg, rc, created)| { + let tag_list: Vec<&str> = tags.as_deref().unwrap_or("").split(',').filter(|s| !s.is_empty()).collect(); + serde_json::json!({ + "id": id, "name": name, "description": desc, + "author": author, "tags": tag_list, "word_count": wc, + "downloads": dl, "rating": avg, "rating_count": rc, + "created_at": created, + }) + }).collect(); + Json(serde_json::json!({ "packs": packs })) +} + +pub async fn get_pack_detail( + State(state): State>, + Path(id): Path, +) -> Result, AppError> { + let dbi = state.db.lock().await; + let pack = db::get_pack(&dbi.db, &id) + .ok_or(AppError("pack not found".into()))?; + let tag_list: Vec<&str> = pack.tags.as_deref().unwrap_or("").split(',').filter(|s| !s.is_empty()).collect(); + let parsed: serde_json::Value = serde_json::from_str(&pack.pack_data).unwrap_or_default(); + Ok(Json(serde_json::json!({ + "id": pack.id, "name": pack.name, "description": pack.description, + "author": pack.author_name, "tags": tag_list, "word_count": pack.word_count, + "downloads": pack.download_count, "rating": pack.rating_avg, "rating_count": pack.rating_count, + "words": parsed.get("words"), "created_at": pack.created_at, + }))) +} + +pub async fn create_pack( + State(state): State>, + session: Session, + Json(req): Json, +) -> Result { + check_banned(&state, &session.token).await?; + crate::security::check_rate_limit(&session.token, "pack_upload", 5, 3600) + .map_err(|s| AppError::with_status(s, "rate limit exceeded"))?; + + let name = req.get("name").and_then(|v| v.as_str()).unwrap_or(""); + if name.len() < 3 || name.len() > 100 { + return Err(AppError::with_status(StatusCode::BAD_REQUEST, "name must be 3-100 characters")); + } + let desc = req.get("description").and_then(|v| v.as_str()); + let tags_arr = req.get("tags").and_then(|v| v.as_array()); + let tags_str = tags_arr.map(|arr| { + arr.iter().filter_map(|v| v.as_str()).collect::>().join(",") + }); + + let words = req.get("words").and_then(|v| v.as_array()) + .ok_or(AppError::with_status(StatusCode::BAD_REQUEST, "missing words array"))?; + if words.is_empty() || words.len() > 5000 { + return Err(AppError::with_status(StatusCode::BAD_REQUEST, "words array must have 1-5000 entries")); + } + + // validate each word + let mut cleaned_words = Vec::new(); + for w in words { + let word = w.get("word").and_then(|v| v.as_str()).unwrap_or(""); + let upper = word.to_uppercase(); + if upper.len() < 3 || upper.len() > 21 { + return Err(AppError::with_status(StatusCode::BAD_REQUEST, + &format!("word '{}' must be 3-21 characters", word))); + } + if !upper.chars().all(|c| c.is_ascii_alphabetic()) { + return Err(AppError::with_status(StatusCode::BAD_REQUEST, + &format!("word '{}' must be alphabetic only", word))); + } + let clues = w.get("clues").and_then(|v| v.as_array()); + if clues.is_none() || clues.unwrap().is_empty() { + return Err(AppError::with_status(StatusCode::BAD_REQUEST, + &format!("word '{}' must have at least one clue", word))); + } + for clue in clues.unwrap() { + let text = clue.get("text").and_then(|v| v.as_str()).unwrap_or(""); + if text.len() < 3 || text.len() > 200 { + return Err(AppError::with_status(StatusCode::BAD_REQUEST, "each clue must be 3-200 characters")); + } + } + cleaned_words.push(serde_json::json!({ + "word": upper, + "clues": clues.unwrap(), + })); + } + + let pack_data = serde_json::json!({ "words": cleaned_words }); + let word_count = cleaned_words.len() as i64; + let id = Uuid::new_v4().to_string(); + + let dbi = state.db.lock().await; + let (_, author_name) = db::get_or_create_session(&dbi.db, &session.token); + let success = db::create_pack( + &dbi.db, &id, + &crate::security::sanitize_html(name), + desc.map(|d| crate::security::sanitize_html(d)).as_deref(), + &session.token, + author_name.as_deref(), + tags_str.as_deref(), + word_count, + &pack_data.to_string(), + ); + if !success { + return Err(AppError("failed to create pack".into())); + } + + Ok(with_session_cookie(&session, Json(serde_json::json!({ + "id": id, "word_count": word_count, + })))) +} + +pub async fn delete_pack_handler( + State(state): State>, + session: Session, + Path(id): Path, +) -> Result { + let dbi = state.db.lock().await; + if db::delete_pack(&dbi.db, &id, &session.token) { + Ok(with_session_cookie(&session, Json(serde_json::json!({"deleted": true})))) + } else { + Err(AppError::with_status(StatusCode::FORBIDDEN, "not your pack or pack not found")) + } +} + +pub async fn rate_pack_handler( + State(state): State>, + session: Session, + Path(id): Path, + Json(req): Json, +) -> Result { + check_banned(&state, &session.token).await?; + let rating = req.get("rating").and_then(|v| v.as_i64()) + .ok_or(AppError("missing rating".into()))?; + if !(1..=5).contains(&rating) { + return Err(AppError::with_status(StatusCode::BAD_REQUEST, "rating must be 1-5")); + } + let dbi = state.db.lock().await; + db::rate_pack(&dbi.db, &id, &session.token, rating); + Ok(with_session_cookie(&session, Json(serde_json::json!({"ok": true})))) +} + +pub async fn editor_puzzle_analytics( + State(state): State>, + session: Session, + Path(id): Path, +) -> Result { + crate::security::check_rate_limit(&session.token, "analytics", 10, 60) + .map_err(|s| AppError::with_status(s, "rate limit exceeded"))?; + let dbi = state.db.lock().await; + match db::get_puzzle_analytics(&dbi.db, &id, &session.token) { + Some(data) => Ok(with_session_cookie(&session, Json(data))), + None => Err(AppError::with_status(StatusCode::NOT_FOUND, "not found or not your puzzle")), + } +} + +pub async fn get_puzzle_archive( + State(state): State>, + session: Session, + Query(params): Query>, +) -> Result { + crate::security::check_rate_limit(&session.token, "archive", 10, 60) + .map_err(|s| AppError::with_status(s, "rate limit exceeded"))?; + let preset = params.get("preset").map(|s| s.as_str()); + let page: usize = params.get("page").and_then(|s| s.parse().ok()).unwrap_or(0); + let dbi = state.db.lock().await; + let entries = db::get_daily_archive(&dbi.db, preset, &session.token, page, 20); + Ok(with_session_cookie(&session, Json(serde_json::json!({ "entries": entries })))) +} + +pub async fn embed_page( + Path(short_id): Path, + Query(params): Query>, +) -> Response { + let theme = params.get("theme").map(|s| s.as_str()).unwrap_or(""); + let timer = params.get("timer").map(|s| s.as_str()).unwrap_or("true"); + let hints = params.get("hints").map(|s| s.as_str()).unwrap_or("true"); + + let html = format!(r#" + + + + +cruciverb + + + +
+ + +"#, short_id, theme, timer, hints); + + let mut resp = (StatusCode::OK, html).into_response(); + let h = resp.headers_mut(); + h.insert(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8".parse().unwrap()); + h.insert(axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN, "*".parse().unwrap()); + h.insert(axum::http::header::X_FRAME_OPTIONS, "ALLOWALL".parse().unwrap()); + h.insert("content-security-policy", "frame-ancestors *".parse().unwrap()); + resp +} + +// streak freezes + +pub async fn get_freezes( + State(state): State>, + session: Session, +) -> Result { + let dbi = state.db.lock().await; + let freezes = db::get_streak_freezes(&dbi.db, &session.token); + Ok(with_session_cookie(&session, Json(serde_json::json!({ "freezes": freezes })))) +} + +// daily challenges + +pub async fn get_daily_challenge( + State(state): State>, + session: Session, + Path((date, preset)): Path<(String, String)>, +) -> Result { + let dbi = state.db.lock().await; + let challenge = db::get_daily_challenge(&dbi.db, &date, &preset); + let completed = db::has_completed_challenge(&dbi.db, &session.token, &date, &preset); + Ok(with_session_cookie(&session, Json(serde_json::json!({ + "challenge": challenge, + "completed": completed, + })))) +} + +pub async fn complete_daily_challenge( + State(state): State>, + session: Session, + Path((date, preset)): Path<(String, String)>, + Json(req): Json, +) -> Result { + let challenge_type = req.get("challenge_type").and_then(|v| v.as_str()) + .ok_or(AppError("missing challenge_type".into()))?; + + let dbi = state.db.lock().await; + // verify the challenge against the server-side solve record where that is + // possible (times, hints, hour of the day); challenges that only the client + // can observe (deletes, pauses, letter streaks) are stored as attested + let mut verified = false; + let mut client_attested = true; + if let Some((elapsed, hints, completed_at)) = + db::get_challenge_solve_info(&dbi.db, &session.token, &date, &preset) + { + verified = challenge_met(challenge_type, elapsed, hints, &completed_at, &preset); + client_attested = !verified; + } + db::complete_challenge( + &dbi.db, + &session.token, + &date, + &preset, + challenge_type, + verified, + client_attested, + ); + Ok(with_session_cookie(&session, Json(serde_json::json!({ "ok": true, "verified": verified })))) +} + +fn challenge_met(challenge: &str, elapsed: i64, hints: i64, completed_at: &str, preset: &str) -> bool { + use chrono::Timelike; + let hour = chrono::DateTime::parse_from_rfc3339(completed_at) + .map(|t| t.hour()) + .unwrap_or(0); + let speed_limit: i64 = match preset { + "glyph" => 45, + "rune" => 60, + "scroll" => 300, + "codex" => 1200, + "grimoire" => 2700, + _ => 300, + }; + match challenge { + "purist" => hints == 0, + "minimalist" => hints > 0 && hints < 3, + "night_owl" => hour < 5, + "early_bird" => (5..8).contains(&hour), + "rush_hour" => (12..13).contains(&hour), + "night_shift" => (20..24).contains(&hour), + "afternoon_tea" => (15..17).contains(&hour), + "blitz" => elapsed < 60, + "sunday_stroll" => elapsed >= 180, + "overtime" => elapsed >= 600, + "lucky_seven" => (elapsed - 420).abs() <= 10, + "even_steven" => elapsed % 2 == 0, + "speed_demon" => elapsed < speed_limit, + _ => false, + } +} + +pub async fn get_today_challenges( + State(state): State>, + session: Session, +) -> Result { + let today = chrono::Utc::now().format("%Y-%m-%d").to_string(); + let dbi = state.db.lock().await; + let completions = db::get_today_challenge_completions(&dbi.db, &session.token, &today); + Ok(with_session_cookie(&session, Json(serde_json::json!(completions)))) +} + +#[cfg(test)] +mod challenge_tests { + use super::challenge_met; + + #[test] + fn time_and_hint_challenges_are_checked() { + assert!(challenge_met("purist", 100, 0, "2026-01-01T10:00:00Z", "scroll")); + assert!(!challenge_met("purist", 100, 1, "2026-01-01T10:00:00Z", "scroll")); + assert!(challenge_met("minimalist", 100, 2, "2026-01-01T10:00:00Z", "scroll")); + assert!(!challenge_met("minimalist", 100, 0, "2026-01-01T10:00:00Z", "scroll")); + assert!(challenge_met("night_owl", 100, 0, "2026-01-01T03:00:00Z", "scroll")); + assert!(!challenge_met("night_owl", 100, 0, "2026-01-01T10:00:00Z", "scroll")); + assert!(challenge_met("blitz", 59, 0, "2026-01-01T10:00:00Z", "scroll")); + assert!(!challenge_met("blitz", 61, 0, "2026-01-01T10:00:00Z", "scroll")); + assert!(challenge_met("speed_demon", 40, 0, "2026-01-01T10:00:00Z", "glyph")); + assert!(!challenge_met("speed_demon", 50, 0, "2026-01-01T10:00:00Z", "glyph")); + assert!(!challenge_met("surgeon", 100, 0, "2026-01-01T10:00:00Z", "scroll")); + } +}