1147 lines
46 KiB
Rust
1147 lines
46 KiB
Rust
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<Arc<AppState>> for ApiAuth {
|
|
type Rejection = Response;
|
|
|
|
async fn from_request_parts(
|
|
parts: &mut Parts,
|
|
state: &Arc<AppState>,
|
|
) -> Result<Self, Self::Rejection> {
|
|
// 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<Arc<AppState>>,
|
|
auth: ApiAuth,
|
|
Json(req): Json<crate::api::GenerateRequest>,
|
|
) -> 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<String> = 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<Arc<AppState>>,
|
|
Path(id): Path<String>,
|
|
_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<Arc<AppState>>,
|
|
Path(id): Path<String>,
|
|
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<Vec<Option<String>>> = (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<Arc<AppState>>,
|
|
Path(id): Path<String>,
|
|
auth: ApiAuth,
|
|
Json(req): Json<crate::api::ValidateRequest>,
|
|
) -> 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<Arc<AppState>>,
|
|
Path(id): Path<String>,
|
|
_auth: ApiAuth,
|
|
Json(req): Json<crate::api::HintRequest>,
|
|
) -> 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<serde_json::Value> = 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<Arc<AppState>>,
|
|
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<Arc<AppState>>,
|
|
Path(date): Path<String>,
|
|
auth: ApiAuth,
|
|
) -> Response {
|
|
v1_daily_inner(state, &auth, &date, "scroll").await
|
|
}
|
|
|
|
pub async fn v1_get_daily_preset(
|
|
State(state): State<Arc<AppState>>,
|
|
Path((date, preset)): Path<(String, String)>,
|
|
auth: ApiAuth,
|
|
) -> Response {
|
|
v1_daily_inner(state, &auth, &date, &preset).await
|
|
}
|
|
|
|
async fn v1_daily_inner(state: Arc<AppState>, _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<Arc<AppState>>,
|
|
_auth: ApiAuth,
|
|
) -> Response {
|
|
let dbi = state.db.lock().await;
|
|
let puzzles = db::list_published_puzzles(&dbi.db, 50, 0);
|
|
let result: Vec<serde_json::Value> = 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<Arc<AppState>>,
|
|
Path(word): Path<String>,
|
|
_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<serde_json::Value> = 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<Arc<AppState>>,
|
|
auth: ApiAuth,
|
|
Json(req): Json<serde_json::Value>,
|
|
) -> 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<Arc<AppState>>,
|
|
Path(id): Path<i64>,
|
|
auth: ApiAuth,
|
|
Json(req): Json<serde_json::Value>,
|
|
) -> 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<Arc<AppState>>,
|
|
Query(params): Query<std::collections::HashMap<String, String>>,
|
|
_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<serde_json::Value> = 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<Arc<AppState>>,
|
|
Path(id): Path<String>,
|
|
_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<Arc<AppState>>,
|
|
auth: ApiAuth,
|
|
Json(req): Json<serde_json::Value>,
|
|
) -> 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::<Vec<_>>().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<Arc<AppState>>,
|
|
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<Arc<AppState>>,
|
|
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<serde_json::Value> = 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<Arc<AppState>>,
|
|
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::<Vec<_>>(),
|
|
}))
|
|
}
|
|
|
|
// -- instance endpoints --
|
|
|
|
pub async fn v1_instance(
|
|
State(state): State<Arc<AppState>>,
|
|
_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<Arc<AppState>>,
|
|
auth: ApiAuth,
|
|
Json(req): Json<serde_json::Value>,
|
|
) -> 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<Arc<AppState>>,
|
|
auth: ApiAuth,
|
|
) -> Response {
|
|
let dbi = state.db.lock().await;
|
|
let keys = db::list_api_keys(&dbi.db, &auth.session_token);
|
|
let result: Vec<serde_json::Value> = 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<Arc<AppState>>,
|
|
Path(id): Path<String>,
|
|
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#"<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>cruciverb API docs</title>
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist/swagger-ui.css">
|
|
</head>
|
|
<body>
|
|
<div id="swagger-ui"></div>
|
|
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist/swagger-ui-bundle.js"></script>
|
|
<script>
|
|
SwaggerUIBundle({
|
|
url: '/api/v1/openapi.json',
|
|
dom_id: '#swagger-ui',
|
|
presets: [SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset],
|
|
layout: 'BaseLayout',
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>"#;
|
|
(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::<u8>())).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<std::collections::HashSet<String>> {
|
|
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<String> = 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<cruciverb_core::puzzle::Puzzle, Response> {
|
|
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
|
|
})
|
|
}
|