From 3d708cd756bf288febf6ac61ce242e707ef31110 Mon Sep 17 00:00:00 2001 From: lashman Date: Mon, 29 Sep 2025 18:42:27 +0300 Subject: [PATCH] public api v1 --- cruciverb-server/src/api_v1.rs | 1146 ++++++++++++++++++++++++++++++++ 1 file changed, 1146 insertions(+) create mode 100644 cruciverb-server/src/api_v1.rs diff --git a/cruciverb-server/src/api_v1.rs b/cruciverb-server/src/api_v1.rs new file mode 100644 index 0000000..0aa7323 --- /dev/null +++ b/cruciverb-server/src/api_v1.rs @@ -0,0 +1,1146 @@ +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 + }) +}