use std::sync::Arc; use axum::Json; use axum::extract::{FromRequestParts, Path, Query, State}; use axum::http::{StatusCode, header, request::Parts}; use axum::response::{IntoResponse, Response}; use rand::Rng; use sha2::{Sha256, Digest}; use uuid::Uuid; use crate::db; use crate::state::AppState; // -- auth extractor -- #[allow(dead_code)] pub struct ApiAuth { pub session_token: String, pub permissions: String, pub is_api_key: bool, } impl ApiAuth { pub fn can(&self, perm: &str) -> bool { self.permissions.contains(perm) || self.permissions.contains("admin") } } impl FromRequestParts> for ApiAuth { type Rejection = Response; async fn from_request_parts( parts: &mut Parts, state: &Arc, ) -> Result { // check Authorization header first if let Some(auth) = parts.headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok()) { if let Some(token) = auth.strip_prefix("Bearer ") { if token.starts_with("cv_") { let hash = hex::encode(Sha256::digest(token.as_bytes())); let dbi = state.db.lock().await; if let Some((id, session_token, permissions, rate_limit, is_active, expires_at)) = db::get_api_key_by_hash(&dbi.db, &hash) { if is_active == 0 { return Err(api_err(StatusCode::UNAUTHORIZED, "key_disabled", "API key is disabled")); } if let Some(exp) = expires_at { let now = chrono::Utc::now().to_rfc3339(); if now > exp { return Err(api_err(StatusCode::UNAUTHORIZED, "key_expired", "API key has expired")); } } // rate limit let rl = rate_limit as u32; if let Err(_) = crate::security::check_rate_limit(&id, "api", rl, 60) { return Err(api_err(StatusCode::TOO_MANY_REQUESTS, "rate_limited", "rate limit exceeded")); } if db::is_banned(&dbi.db, &session_token) { return Err(api_err(StatusCode::FORBIDDEN, "suspended", "account suspended")); } db::touch_api_key(&dbi.db, &id); return Ok(ApiAuth { session_token, permissions, is_api_key: true }); } return Err(api_err(StatusCode::UNAUTHORIZED, "invalid_key", "invalid API key")); } } } // fall back to session cookie let token = parts.headers.get(header::COOKIE) .and_then(|v| v.to_str().ok()) .and_then(|cookies| { cookies.split(';') .map(|c| c.trim()) .find(|c| c.starts_with("cruciverb_session=")) .map(|c| c["cruciverb_session=".len()..].to_string()) }); match token { Some(t) if !t.is_empty() => { let dbi = state.db.lock().await; let (tok, _) = db::get_or_create_session(&dbi.db, &t); if db::is_banned(&dbi.db, &tok) { return Err(api_err(StatusCode::FORBIDDEN, "suspended", "account suspended")); } Ok(ApiAuth { session_token: tok, permissions: "read,write,generate".into(), is_api_key: false }) } _ => Err(api_err(StatusCode::UNAUTHORIZED, "no_auth", "provide an API key via Authorization header or a session cookie")), } } } // -- response helpers -- fn api_ok(data: serde_json::Value) -> Response { Json(serde_json::json!({ "data": data, "meta": { "timestamp": chrono::Utc::now().to_rfc3339() } })).into_response() } fn api_err(code: StatusCode, error_code: &str, message: &str) -> Response { (code, Json(serde_json::json!({ "error": { "code": error_code, "message": message }, "meta": { "timestamp": chrono::Utc::now().to_rfc3339() } }))).into_response() } // -- puzzle endpoints -- pub async fn v1_generate( State(state): State>, auth: ApiAuth, Json(req): Json, ) -> Response { if !auth.can("generate") { return api_err(StatusCode::FORBIDDEN, "forbidden", "generate permission required"); } if let Err(_) = crate::security::check_rate_limit(&auth.session_token, "generate", 5, 60) { return api_err(StatusCode::TOO_MANY_REQUESTS, "rate_limited", "rate limit exceeded"); } let _permit = match state.generation_semaphore.try_acquire() { Ok(p) => p, Err(_) => return api_err(StatusCode::SERVICE_UNAVAILABLE, "busy", "server busy - try again shortly"), }; let clue_db = state.clue_db.clone(); let word_diffs = state.word_diffs.clone(); let (difficulty_level, config) = parse_gen_config(&req); let mut config = config; if let Some(seed) = req.seed { config.seed = Some(seed); } 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); } } 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()) { 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); } } } db::increment_pack_downloads(&dbi.db, pack_id); } } 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 { dict.boost_theme(tw); } config.staleness = state.build_staleness_penalties().await; let tw = theme_words.clone(); let tn = theme_name.clone(); let result = tokio::task::spawn_blocking(move || { cruciverb_core::puzzle::generate_themed_puzzle( &config, &dict, &clue_db, difficulty_level, tn.as_deref(), tw.as_ref(), ) }).await; let puzzle = match result { Ok(Ok(p)) => p, Ok(Err(e)) => return api_err(StatusCode::INTERNAL_SERVER_ERROR, "generation_failed", &e.to_string()), Err(e) => return api_err(StatusCode::INTERNAL_SERVER_ERROR, "task_error", &e.to_string()), }; let mut puzzle = puzzle; if is_cryptic { if let Some(ref cdb) = state.cryptic_db { puzzle.apply_cryptic_clues(cdb); } } 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 = match puzzle.to_json() { Ok(j) => j, Err(e) => return api_err(StatusCode::INTERNAL_SERVER_ERROR, "serialize_error", &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 = crate::api::to_client_puzzle(&puzzle, id); state.cache_puzzle(id, puzzle).await; api_ok(serde_json::json!({ "id": id.to_string(), "short_id": short_id, "puzzle": client, })) } pub async fn v1_get_puzzle( State(state): State>, Path(id): Path, _auth: ApiAuth, ) -> Response { let uuid = match Uuid::parse_str(&id) { Ok(u) => u, Err(_) => return api_err(StatusCode::BAD_REQUEST, "invalid_id", "invalid puzzle id"), }; // try in-memory 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 api_ok(serde_json::json!({ "id": id, "short_id": short_id, "puzzle": crate::api::to_client_puzzle(puzzle, uuid), })); } drop(puzzles); // try database let dbi = state.db.lock().await; let (puzzle_json, short_id) = match db::get_puzzle_json(&dbi.db, &id) { Some(v) => v, None => return api_err(StatusCode::NOT_FOUND, "not_found", "puzzle not found"), }; let puzzle: cruciverb_core::puzzle::Puzzle = match serde_json::from_str(&puzzle_json) { Ok(p) => p, Err(e) => return api_err(StatusCode::INTERNAL_SERVER_ERROR, "parse_error", &e.to_string()), }; drop(dbi); let client = crate::api::to_client_puzzle(&puzzle, uuid); state.cache_puzzle(uuid, puzzle).await; api_ok(serde_json::json!({ "id": id, "short_id": short_id, "puzzle": client, })) } pub async fn v1_get_solution( State(state): State>, Path(id): Path, auth: ApiAuth, ) -> Response { if !auth.can("generate") { return api_err(StatusCode::FORBIDDEN, "forbidden", "generate permission required"); } let uuid = match Uuid::parse_str(&id) { Ok(u) => u, Err(_) => return api_err(StatusCode::BAD_REQUEST, "invalid_id", "invalid puzzle id"), }; let puzzle = match load_puzzle_inner(&state, &id, uuid).await { Ok(p) => p, Err(r) => return r, }; let solution: Vec>> = (0..puzzle.height).map(|r| { (0..puzzle.width).map(|c| puzzle.grid.get(r, c).letter().map(|ch| ch.to_string())).collect() }).collect(); api_ok(serde_json::json!({ "id": id, "solution": solution, })) } pub async fn v1_validate( State(state): State>, Path(id): Path, auth: ApiAuth, Json(req): Json, ) -> Response { let uuid = match Uuid::parse_str(&id) { Ok(u) => u, Err(_) => return api_err(StatusCode::BAD_REQUEST, "invalid_id", "invalid puzzle id"), }; let puzzles = state.puzzles.read().await; let (puzzle, _) = match puzzles.get(&uuid) { Some(p) => p, None => return api_err(StatusCode::NOT_FOUND, "not_found", "puzzle not found - fetch it first"), }; let mut all_correct = true; let mut cell_results = Vec::with_capacity(puzzle.height); for r in 0..puzzle.height { let mut row = 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(|rw| rw.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.push(result.to_string()); } cell_results.push(row); } if all_correct { let dbi = state.db.lock().await; if db::has_solve_record(&dbi.db, &auth.session_token, &id) { return api_ok(serde_json::json!({ "correct": true, "cells": cell_results, })); } let server_hints = db::get_hint_count(&dbi.db, &id, &auth.session_token); let elapsed = req.elapsed.unwrap_or(0); let min_time: i64 = match db::get_puzzle_daily_info(&dbi.db, &id) { Some((_, preset)) => match preset.as_str() { "glyph" => 3, "rune" => 5, "scroll" => 15, "codex" => 30, "grimoire" => 60, _ => 3, }, None => 3, }; let flagged = elapsed < min_time; // anonymizeTimes: round stored solve times to the minute let settings: serde_json::Value = serde_json::from_str(&db::get_settings(&dbi.db, &auth.session_token)).unwrap_or_default(); let elapsed = if settings.get("anonymizeTimes").and_then(|v| v.as_bool()).unwrap_or(false) { (elapsed / 60) * 60 } else { elapsed }; db::record_solve(&dbi.db, &auth.session_token, &id, elapsed, server_hints, flagged, None); let today = chrono::Utc::now().format("%Y-%m-%d").to_string(); db::update_streak(&dbi.db, &auth.session_token, &today); } api_ok(serde_json::json!({ "correct": all_correct, "cells": cell_results, })) } pub async fn v1_hint( State(state): State>, Path(id): Path, _auth: ApiAuth, Json(req): Json, ) -> Response { let uuid = match Uuid::parse_str(&id) { Ok(u) => u, Err(_) => return api_err(StatusCode::BAD_REQUEST, "invalid_id", "invalid puzzle id"), }; let puzzles = state.puzzles.read().await; let (puzzle, _) = match puzzles.get(&uuid) { Some(p) => p, None => return api_err(StatusCode::NOT_FOUND, "not_found", "puzzle not found - fetch it first"), }; 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()); api_ok(serde_json::json!({ "is_correct": actual == submitted })) } "reveal_letter" => { match puzzle.grid.get(req.row, req.col).letter() { Some(ch) => api_ok(serde_json::json!({ "row": req.row, "col": req.col, "letter": ch.to_string() })), None => api_err(StatusCode::BAD_REQUEST, "not_letter", "not a letter cell"), } } "reveal_word" => { let dir = match req.direction.as_deref() { Some("down") => cruciverb_core::grid::slot::Direction::Down, _ => cruciverb_core::grid::slot::Direction::Across, }; let word = puzzle.words.iter().find(|w| { w.direction == dir && word_contains(w, req.row, req.col) }); match word { Some(w) => { let letters: Vec = w.word.chars().enumerate().map(|(i, ch)| { let (r, c) = match w.direction { cruciverb_core::grid::slot::Direction::Across => (w.row, w.col + i), cruciverb_core::grid::slot::Direction::Down => (w.row + i, w.col), }; serde_json::json!({ "row": r, "col": c, "letter": ch.to_string() }) }).collect(); api_ok(serde_json::json!({ "letters": letters })) } None => api_err(StatusCode::BAD_REQUEST, "no_word", "no word at that position"), } } _ => api_err(StatusCode::BAD_REQUEST, "invalid_kind", "unknown hint kind"), } } pub async fn v1_export( State(state): State>, Path((id, fmt)): Path<(String, String)>, auth: ApiAuth, ) -> Response { if let Err(_) = crate::security::check_rate_limit(&auth.session_token, "export", 10, 60) { return api_err(StatusCode::TOO_MANY_REQUESTS, "rate_limited", "rate limit exceeded"); } let uuid = match Uuid::parse_str(&id) { Ok(u) => u, Err(_) => return api_err(StatusCode::BAD_REQUEST, "invalid_id", "invalid puzzle id"), }; let puzzle = match load_puzzle_inner(&state, &id, uuid).await { Ok(p) => p, Err(r) => return r, }; match fmt.as_str() { "puz" => { let data = cruciverb_core::export::puz::to_puz(&puzzle, "cruciverb puzzle", "cruciverb"); ([(header::CONTENT_TYPE, "application/octet-stream"), (header::CONTENT_DISPOSITION, "attachment; filename=\"puzzle.puz\"")], data).into_response() } "ipuz" => { let data = cruciverb_core::export::ipuz::to_ipuz(&puzzle, "cruciverb puzzle"); ([(header::CONTENT_TYPE, "application/json"), (header::CONTENT_DISPOSITION, "attachment; filename=\"puzzle.ipuz\"")], serde_json::to_string_pretty(&data).unwrap_or_default()).into_response() } "pdf" => { let data = cruciverb_core::export::pdf::to_pdf(&puzzle, "cruciverb puzzle", "puzzle"); ([(header::CONTENT_TYPE, "application/pdf"), (header::CONTENT_DISPOSITION, "attachment; filename=\"puzzle.pdf\"")], data).into_response() } "json" => { let client = crate::api::to_client_puzzle(&puzzle, uuid); api_ok(serde_json::json!({ "puzzle": client })) } _ => api_err(StatusCode::BAD_REQUEST, "invalid_format", "format must be puz, ipuz, pdf, or json"), } } // -- daily endpoints -- pub async fn v1_get_daily( State(state): State>, Path(date): Path, auth: ApiAuth, ) -> Response { v1_daily_inner(state, &auth, &date, "scroll").await } pub async fn v1_get_daily_preset( State(state): State>, Path((date, preset)): Path<(String, String)>, auth: ApiAuth, ) -> Response { v1_daily_inner(state, &auth, &date, &preset).await } async fn v1_daily_inner(state: Arc, _auth: &ApiAuth, date: &str, preset: &str) -> Response { // 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: cruciverb_core::puzzle::Puzzle = match serde_json::from_str(&puzzle_json) { Ok(p) => p, Err(e) => return api_err(StatusCode::INTERNAL_SERVER_ERROR, "parse_error", &e.to_string()), }; let uuid = Uuid::parse_str(&id).unwrap_or(Uuid::new_v4()); let client = crate::api::to_client_puzzle(&puzzle, uuid); state.cache_puzzle(uuid, puzzle).await; return api_ok(serde_json::json!({ "id": id, "short_id": short_id, "puzzle": client, })); } } let _permit = match state.generation_semaphore.try_acquire() { Ok(p) => p, Err(_) => return api_err(StatusCode::SERVICE_UNAVAILABLE, "busy", "server busy - try again shortly"), }; let seed = crate::routes::daily_seed(date, preset, &state.daily_secret); let (diff, mut config) = match preset { "glyph" => (0u8, cruciverb_core::config::GenerationConfig::from_preset(cruciverb_core::config::DifficultyPreset::Glyph)), "rune" => (1, cruciverb_core::config::GenerationConfig::from_preset(cruciverb_core::config::DifficultyPreset::Rune)), "scroll" => (2, cruciverb_core::config::GenerationConfig::from_preset(cruciverb_core::config::DifficultyPreset::Scroll)), "codex" => (3, cruciverb_core::config::GenerationConfig::from_preset(cruciverb_core::config::DifficultyPreset::Codex)), "grimoire" => (3, cruciverb_core::config::GenerationConfig::from_preset(cruciverb_core::config::DifficultyPreset::Grimoire)), _ => (2, cruciverb_core::config::GenerationConfig::from_preset(cruciverb_core::config::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, diff); let wq = state.word_quality.read().await.clone(); if !wq.is_empty() { dict.apply_quality(&wq); } let result = tokio::task::spawn_blocking(move || { cruciverb_core::puzzle::generate_full_puzzle(&config, &dict, &clue_db, diff) }).await; let puzzle = match result { Ok(Ok(p)) => p, Ok(Err(e)) => return api_err(StatusCode::INTERNAL_SERVER_ERROR, "generation_failed", &e.to_string()), Err(e) => return api_err(StatusCode::INTERNAL_SERVER_ERROR, "task_error", &e.to_string()), }; let id = Uuid::new_v4(); let puzzle_json = match puzzle.to_json() { Ok(j) => j, Err(e) => return api_err(StatusCode::INTERNAL_SERVER_ERROR, "serialize_error", &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, diff, Some(date), Some(preset)) }; let client = crate::api::to_client_puzzle(&puzzle, id); state.cache_puzzle(id, puzzle).await; api_ok(serde_json::json!({ "id": id.to_string(), "short_id": short_id, "puzzle": client, })) } // -- community endpoints -- pub async fn v1_community_puzzles( State(state): State>, _auth: ApiAuth, ) -> Response { let dbi = state.db.lock().await; let puzzles = db::list_published_puzzles(&dbi.db, 50, 0); let result: Vec = puzzles.iter().map(|(id, title, author, w, h, updated, origin)| { serde_json::json!({ "id": id, "title": title, "author": author, "width": w, "height": h, "updated_at": updated, "origin": origin, }) }).collect(); api_ok(serde_json::json!({ "puzzles": result })) } pub async fn v1_community_clues( State(state): State>, Path(word): Path, _auth: ApiAuth, ) -> Response { if !word.chars().all(|c| c.is_alphabetic()) { return api_err(StatusCode::BAD_REQUEST, "invalid_word", "word must contain only letters"); } let dbi = state.db.lock().await; let clues = db::get_community_clues(&dbi.db, &word); let result: Vec = clues.iter().map(|(id, text, up, down, status, created, origin)| { serde_json::json!({ "id": id, "clue": text, "upvotes": up, "downvotes": down, "status": status, "created_at": created, "origin_domain": origin, }) }).collect(); api_ok(serde_json::json!({ "clues": result })) } pub async fn v1_submit_clue( State(state): State>, auth: ApiAuth, Json(req): Json, ) -> Response { if !auth.can("write") { return api_err(StatusCode::FORBIDDEN, "forbidden", "write permission required"); } if let Err(_) = crate::security::check_rate_limit(&auth.session_token, "submit_clue", 10, 60) { return api_err(StatusCode::TOO_MANY_REQUESTS, "rate_limited", "rate limit exceeded"); } let word = match req.get("word").and_then(|v| v.as_str()) { Some(w) => w, None => return api_err(StatusCode::BAD_REQUEST, "missing_field", "missing word"), }; let clue_text = match req.get("clue").and_then(|v| v.as_str()) { Some(c) => c, None => return api_err(StatusCode::BAD_REQUEST, "missing_field", "missing clue"), }; if word.is_empty() || clue_text.is_empty() { return api_err(StatusCode::BAD_REQUEST, "empty_field", "word and clue must not be empty"); } if !word.chars().all(|c| c.is_alphabetic()) { return api_err(StatusCode::BAD_REQUEST, "invalid_word", "word must contain only letters"); } if clue_text.len() > 500 { return api_err(StatusCode::BAD_REQUEST, "too_long", "clue too long (max 500 characters)"); } if db::text_contains_url(clue_text) { return api_err(StatusCode::BAD_REQUEST, "url_forbidden", "clues cannot contain URLs"); } let clean = crate::security::sanitize_html(clue_text); if state.blocklist.read().unwrap().contains_blocked(&clean) { return api_err(StatusCode::BAD_REQUEST, "inappropriate", "clue contains inappropriate content"); } let dbi = state.db.lock().await; let id = db::submit_clue(&dbi.db, word, &clean, &auth.session_token); api_ok(serde_json::json!({ "id": id })) } pub async fn v1_vote_clue( State(state): State>, Path(id): Path, auth: ApiAuth, Json(req): Json, ) -> Response { if !auth.can("write") { return api_err(StatusCode::FORBIDDEN, "forbidden", "write permission required"); } if let Err(_) = crate::security::check_rate_limit(&auth.session_token, "vote_clue", 30, 60) { return api_err(StatusCode::TOO_MANY_REQUESTS, "rate_limited", "rate limit exceeded"); } let vote = match req.get("vote").and_then(|v| v.as_i64()) { Some(v) if v == 1 || v == -1 => v as i32, _ => return api_err(StatusCode::BAD_REQUEST, "invalid_vote", "vote must be 1 or -1"), }; let dbi = state.db.lock().await; db::vote_clue(&dbi.db, &auth.session_token, id, vote); api_ok(serde_json::json!({ "ok": true })) } // -- packs -- pub async fn v1_list_packs( State(state): State>, Query(params): Query>, _auth: ApiAuth, ) -> Response { 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(); api_ok(serde_json::json!({ "packs": packs })) } pub async fn v1_get_pack( State(state): State>, Path(id): Path, _auth: ApiAuth, ) -> Response { let dbi = state.db.lock().await; let pack = match db::get_pack(&dbi.db, &id) { Some(p) => p, None => return api_err(StatusCode::NOT_FOUND, "not_found", "pack not found"), }; 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(); api_ok(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 v1_create_pack( State(state): State>, auth: ApiAuth, Json(req): Json, ) -> Response { if !auth.can("write") { return api_err(StatusCode::FORBIDDEN, "forbidden", "write permission required"); } if let Err(_) = crate::security::check_rate_limit(&auth.session_token, "pack_upload", 5, 3600) { return api_err(StatusCode::TOO_MANY_REQUESTS, "rate_limited", "rate limit exceeded"); } let name = req.get("name").and_then(|v| v.as_str()).unwrap_or(""); if name.len() < 3 || name.len() > 100 { return api_err(StatusCode::BAD_REQUEST, "invalid_name", "name must be 3-100 characters"); } let desc = req.get("description").and_then(|v| v.as_str()); let tags_str = req.get("tags").and_then(|v| v.as_array()).map(|arr| { arr.iter().filter_map(|v| v.as_str()).collect::>().join(",") }); let words = match req.get("words").and_then(|v| v.as_array()) { Some(w) if !w.is_empty() && w.len() <= 5000 => w, _ => return api_err(StatusCode::BAD_REQUEST, "invalid_words", "words array must have 1-5000 entries"), }; let mut cleaned = Vec::new(); for w in words { let word = w.get("word").and_then(|v| v.as_str()).unwrap_or("").to_uppercase(); if word.len() < 3 || word.len() > 21 || !word.chars().all(|c| c.is_ascii_alphabetic()) { return api_err(StatusCode::BAD_REQUEST, "invalid_word", &format!("word '{}' is invalid", word)); } let clues = match w.get("clues").and_then(|v| v.as_array()) { Some(c) if !c.is_empty() => c, _ => return api_err(StatusCode::BAD_REQUEST, "missing_clues", &format!("word '{}' needs clues", word)), }; for clue in clues { let text = clue.get("text").and_then(|v| v.as_str()).unwrap_or(""); if text.len() < 3 || text.len() > 200 { return api_err(StatusCode::BAD_REQUEST, "invalid_clue", "each clue must be 3-200 characters"); } } cleaned.push(serde_json::json!({ "word": word, "clues": clues })); } let pack_data = serde_json::json!({ "words": cleaned }); let word_count = cleaned.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, &auth.session_token); let ok = db::create_pack(&dbi.db, &id, &crate::security::sanitize_html(name), desc.map(|d| crate::security::sanitize_html(d)).as_deref(), &auth.session_token, author_name.as_deref(), tags_str.as_deref(), word_count, &pack_data.to_string()); if !ok { return api_err(StatusCode::INTERNAL_SERVER_ERROR, "create_failed", "failed to create pack"); } api_ok(serde_json::json!({ "id": id, "word_count": word_count })) } // -- user endpoints -- pub async fn v1_me( State(state): State>, auth: ApiAuth, ) -> Response { let dbi = state.db.lock().await; let (current, best, last_date) = db::get_streak(&dbi.db, &auth.session_token); let has_passkey = db::has_passkey(&dbi.db, &auth.session_token); let (_, name) = db::get_or_create_session(&dbi.db, &auth.session_token); api_ok(serde_json::json!({ "display_name": name, "has_passkey": has_passkey, "streak": { "current": current, "best": best, "last_date": last_date }, })) } pub async fn v1_achievements( State(state): State>, auth: ApiAuth, ) -> Response { let dbi = state.db.lock().await; let unlocked = db::get_achievements(&dbi.db, &auth.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(); api_ok(serde_json::json!({ "achievements": result })) } pub async fn v1_stats( State(state): State>, auth: ApiAuth, ) -> Response { let dbi = state.db.lock().await; let (current, best, last_date) = db::get_streak(&dbi.db, &auth.session_token); let solves = db::get_stats(&dbi.db, &auth.session_token); let total = solves.len(); api_ok(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::>(), })) } // -- instance endpoints -- pub async fn v1_instance( State(state): State>, _auth: ApiAuth, ) -> Response { let ap_enabled = state.ap.is_some(); let peer_count: i64 = { let dbi = state.db.lock().await; dbi.db.query_row("SELECT COUNT(*) FROM ap_peers", [], |r| r.get(0)).unwrap_or(0) }; api_ok(serde_json::json!({ "software": "cruciverb", "version": env!("CARGO_PKG_VERSION"), "federation": ap_enabled, "connected_instances": peer_count, })) } pub async fn v1_themes(_auth: ApiAuth) -> Response { 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 = std::fs::read_to_string(&path) .map(|c| c.lines().filter(|l| !l.trim().is_empty()).count()) .unwrap_or(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())); api_ok(serde_json::json!({ "themes": themes })) } // -- API key management -- pub async fn v1_create_key( State(state): State>, auth: ApiAuth, Json(req): Json, ) -> Response { let name = req.get("name").and_then(|v| v.as_str()).unwrap_or("Unnamed key"); let permissions = req.get("permissions").and_then(|v| v.as_str()).unwrap_or("read"); // validate permissions for perm in permissions.split(',') { match perm.trim() { "read" | "write" | "generate" | "admin" => {} _ => return api_err(StatusCode::BAD_REQUEST, "invalid_permission", &format!("unknown permission: {}", perm)), } } let id = Uuid::new_v4().to_string(); let raw_key = generate_api_key(); let key_hash = hex::encode(Sha256::digest(raw_key.as_bytes())); let dbi = state.db.lock().await; let ok = db::create_api_key(&dbi.db, &id, &auth.session_token, &key_hash, name, permissions); if !ok { return api_err(StatusCode::INTERNAL_SERVER_ERROR, "create_failed", "failed to create API key"); } api_ok(serde_json::json!({ "id": id, "key": raw_key, "name": name, "permissions": permissions, "note": "save this key now - it will not be shown again", })) } pub async fn v1_list_keys( State(state): State>, auth: ApiAuth, ) -> Response { let dbi = state.db.lock().await; let keys = db::list_api_keys(&dbi.db, &auth.session_token); let result: Vec = keys.iter().map(|(id, name, perms, created, active, last_used)| { serde_json::json!({ "id": id, "name": name, "permissions": perms, "created_at": created, "is_active": *active != 0, "last_used_at": last_used, }) }).collect(); api_ok(serde_json::json!({ "keys": result })) } pub async fn v1_delete_key( State(state): State>, Path(id): Path, auth: ApiAuth, ) -> Response { let dbi = state.db.lock().await; if db::delete_api_key(&dbi.db, &id, &auth.session_token) { api_ok(serde_json::json!({ "deleted": true })) } else { api_err(StatusCode::NOT_FOUND, "not_found", "key not found or not yours") } } // -- OpenAPI spec -- pub async fn v1_openapi() -> Response { let spec = openapi_spec(); ([(header::CONTENT_TYPE, "application/json")], serde_json::to_string_pretty(&spec).unwrap_or_default()).into_response() } pub async fn swagger_ui() -> Response { let html = r#" cruciverb API docs
"#; (StatusCode::OK, [(header::CONTENT_TYPE, "text/html; charset=utf-8")], html).into_response() } // -- helpers -- fn generate_api_key() -> String { let mut rng = rand::rng(); let hex: String = (0..16).map(|_| format!("{:02x}", rng.random::())).collect(); format!("cv_{}", hex) } fn word_contains(word: &cruciverb_core::puzzle::PlacedWord, row: usize, col: usize) -> bool { match word.direction { cruciverb_core::grid::slot::Direction::Across => row == word.row && col >= word.col && col < word.col + word.word.len(), cruciverb_core::grid::slot::Direction::Down => col == word.col && row >= word.row && row < word.row + word.word.len(), } } fn load_theme_words(theme: &str) -> Option> { let safe: 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)); 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) } } async fn load_puzzle_inner(state: &AppState, id: &str, uuid: Uuid) -> Result { let puzzles = state.puzzles.read().await; if let Some((p, _)) = puzzles.get(&uuid) { return Ok(p.clone()); } drop(puzzles); let dbi = state.db.lock().await; let (json, _) = db::get_puzzle_json(&dbi.db, id) .ok_or_else(|| api_err(StatusCode::NOT_FOUND, "not_found", "puzzle not found"))?; serde_json::from_str(&json) .map_err(|e| api_err(StatusCode::INTERNAL_SERVER_ERROR, "parse_error", &e.to_string())) } fn parse_gen_config(req: &crate::api::GenerateRequest) -> (u8, cruciverb_core::config::GenerationConfig) { use cruciverb_core::config::{DifficultyPreset, GenerationConfig}; 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); let density = req.density.unwrap_or(0.22); let diff = req.clue_difficulty.unwrap_or(1); let freq = 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, 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() }) } } } fn openapi_spec() -> serde_json::Value { use serde_json::json; let mut paths = serde_json::Map::new(); // helper for simple GET endpoint let simple_get = |summary: &str, tag: &str| json!({ "get": { "summary": summary, "tags": [tag], "responses": { "200": { "description": "OK" } } } }); let id_param = json!({ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }); paths.insert("/api/v1/puzzles/generate".into(), json!({ "post": { "summary": "Generate a new puzzle", "tags": ["Puzzles"], "responses": { "200": { "description": "Generated puzzle" }, "403": { "description": "Generate permission required" } } } })); paths.insert("/api/v1/puzzles/{id}".into(), json!({ "get": { "summary": "Get puzzle by ID (no solution)", "tags": ["Puzzles"], "parameters": [id_param.clone()], "responses": { "200": { "description": "Puzzle data" }, "404": { "description": "Not found" } } } })); paths.insert("/api/v1/puzzles/{id}/solution".into(), json!({ "get": { "summary": "Get puzzle solution", "tags": ["Puzzles"], "parameters": [id_param.clone()], "responses": { "200": { "description": "Solution grid" }, "403": { "description": "Generate permission required" } } } })); paths.insert("/api/v1/puzzles/{id}/validate".into(), json!({ "post": { "summary": "Validate solution attempt", "tags": ["Puzzles"], "parameters": [id_param.clone()], "responses": { "200": { "description": "Validation result" } } } })); paths.insert("/api/v1/puzzles/{id}/hint".into(), json!({ "post": { "summary": "Get a hint", "tags": ["Puzzles"], "parameters": [id_param.clone()], "responses": { "200": { "description": "Hint data" } } } })); paths.insert("/api/v1/puzzles/{id}/export/{fmt}".into(), json!({ "get": { "summary": "Export puzzle (puz, ipuz, pdf, json)", "tags": ["Puzzles"], "parameters": [id_param.clone(), {"name": "fmt", "in": "path", "required": true, "schema": {"type": "string"}}], "responses": { "200": { "description": "Exported file" } } } })); paths.insert("/api/v1/daily/{date}".into(), json!({ "get": { "summary": "Get daily puzzle (default scroll)", "tags": ["Daily"], "parameters": [{"name": "date", "in": "path", "required": true, "schema": {"type": "string", "format": "date"}}], "responses": { "200": { "description": "Daily puzzle" } } } })); paths.insert("/api/v1/daily/{date}/{preset}".into(), json!({ "get": { "summary": "Get daily puzzle by preset", "tags": ["Daily"], "parameters": [ {"name": "date", "in": "path", "required": true, "schema": {"type": "string"}}, {"name": "preset", "in": "path", "required": true, "schema": {"type": "string"}}], "responses": { "200": { "description": "Daily puzzle" } } } })); paths.insert("/api/v1/community/puzzles".into(), simple_get("List community puzzles", "Community")); paths.insert("/api/v1/community/clues/{word}".into(), json!({ "get": { "summary": "Get community clues for a word", "tags": ["Community"], "parameters": [{"name": "word", "in": "path", "required": true, "schema": {"type": "string"}}], "responses": { "200": { "description": "Clue list" } } } })); paths.insert("/api/v1/community/clues".into(), json!({ "post": { "summary": "Submit a community clue", "tags": ["Community"], "responses": { "200": { "description": "Clue ID" }, "403": { "description": "Write permission required" } } } })); paths.insert("/api/v1/community/clues/{id}/vote".into(), json!({ "post": { "summary": "Vote on a community clue", "tags": ["Community"], "parameters": [id_param.clone()], "responses": { "200": { "description": "Vote recorded" }, "403": { "description": "Write permission required" } } } })); paths.insert("/api/v1/packs".into(), json!({ "get": { "summary": "List wordlist packs", "tags": ["Packs"], "parameters": [ {"name": "search", "in": "query", "schema": {"type": "string"}}, {"name": "limit", "in": "query", "schema": {"type": "integer"}}], "responses": { "200": { "description": "Pack list" } } }, "post": { "summary": "Upload a new pack", "tags": ["Packs"], "responses": { "200": { "description": "Pack created" }, "403": { "description": "Write permission required" } } } })); paths.insert("/api/v1/packs/{id}".into(), json!({ "get": { "summary": "Get pack details", "tags": ["Packs"], "parameters": [id_param.clone()], "responses": { "200": { "description": "Pack details" }, "404": { "description": "Not found" } } } })); paths.insert("/api/v1/me".into(), simple_get("Current user info", "User")); paths.insert("/api/v1/me/achievements".into(), simple_get("User achievements", "User")); paths.insert("/api/v1/me/stats".into(), simple_get("User solve stats", "User")); paths.insert("/api/v1/instance".into(), simple_get("Instance info", "Instance")); paths.insert("/api/v1/themes".into(), simple_get("List available themes", "Instance")); paths.insert("/api/v1/keys".into(), json!({ "post": { "summary": "Create a new API key", "tags": ["API Keys"], "responses": { "200": { "description": "API key (shown once)" } } }, "get": { "summary": "List your API keys", "tags": ["API Keys"], "responses": { "200": { "description": "Key list (no raw keys)" } } } })); paths.insert("/api/v1/keys/{id}".into(), json!({ "delete": { "summary": "Revoke an API key", "tags": ["API Keys"], "parameters": [id_param], "responses": { "200": { "description": "Key deleted" } } } })); let components = json!({ "securitySchemes": { "apiKey": { "type": "http", "scheme": "bearer", "description": "API key with cv_ prefix" }, "cookie": { "type": "apiKey", "in": "cookie", "name": "cruciverb_session" } } }); json!({ "openapi": "3.1.0", "info": { "title": "cruciverb API", "version": "1.0.0", "description": "Public API for the cruciverb crossword puzzle server." }, "servers": [{ "url": "/" }], "components": components, "security": [{ "apiKey": [] }, { "cookie": [] }], "paths": paths }) }