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} "##, 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")); } }