Files
cruciverb/cruciverb-server/src/routes.rs
T
2025-10-07 21:29:55 +03:00

3678 lines
148 KiB
Rust

use std::sync::Arc;
use axum::Json;
use axum::extract::{ConnectInfo, Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use uuid::Uuid;
use cruciverb_core::config::{DifficultyPreset, GenerationConfig};
use cruciverb_core::grid::{Grid, Cell};
use cruciverb_core::grid::slot::{Direction, extract_slots};
use cruciverb_core::puzzle::{Puzzle, generate_full_puzzle, assemble_puzzle};
use cruciverb_core::solver;
use sha2::{Sha256, Digest};
use crate::api::*;
use crate::db;
use crate::session::Session;
use crate::state::AppState;
pub struct AppError {
msg: String,
status: StatusCode,
}
#[allow(non_snake_case)]
fn AppError(msg: String) -> AppError {
AppError { msg, status: StatusCode::INTERNAL_SERVER_ERROR }
}
impl AppError {
pub fn with_status(code: StatusCode, msg: &str) -> Self {
Self { msg: msg.to_string(), status: code }
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
(self.status, self.msg).into_response()
}
}
async fn check_banned(state: &AppState, token: &str) -> Result<(), AppError> {
let dbi = state.db.lock().await;
if db::is_banned(&dbi.db, token) {
Err(AppError::with_status(StatusCode::FORBIDDEN, "account suspended"))
} else {
Ok(())
}
}
fn sanitize_filename(name: &str) -> String {
name.chars()
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_' || *c == '.')
.take(100)
.collect()
}
fn with_session_cookie(session: &Session, json: Json<impl serde::Serialize>) -> Response {
let mut resp = json.into_response();
if let Ok(val) = session.cookie_header().parse() {
resp.headers_mut().insert(axum::http::header::SET_COOKIE, val);
}
resp
}
pub async fn generate_puzzle(
State(state): State<Arc<AppState>>,
headers: axum::http::HeaderMap,
session: Session,
Json(req): Json<GenerateRequest>,
) -> Result<Response, AppError> {
check_banned(&state, &session.token).await?;
crate::security::check_rate_limit(&session.token, "generate", 5, 60)
.map_err(|s| AppError::with_status(s, "rate limit exceeded"))?;
let ip = crate::security::extract_client_ip(&headers);
crate::security::check_rate_limit_ip(&ip, "generate", 20, 60)
.map_err(|s| AppError::with_status(s, "rate limit exceeded"))?;
let _permit = state.generation_semaphore.try_acquire()
.map_err(|_| AppError::with_status(StatusCode::SERVICE_UNAVAILABLE, "server busy - try again shortly"))?;
let clue_db = state.clue_db.clone();
let word_diffs = state.word_diffs.clone();
let (difficulty_level, config) = match req.difficulty.as_deref() {
Some("glyph") => (req.clue_difficulty.unwrap_or(0), GenerationConfig::from_preset(DifficultyPreset::Glyph)),
Some("rune") => (req.clue_difficulty.unwrap_or(1), GenerationConfig::from_preset(DifficultyPreset::Rune)),
Some("scroll") => (req.clue_difficulty.unwrap_or(2), GenerationConfig::from_preset(DifficultyPreset::Scroll)),
Some("codex") => (req.clue_difficulty.unwrap_or(3), GenerationConfig::from_preset(DifficultyPreset::Codex)),
Some("grimoire") => (req.clue_difficulty.unwrap_or(3), GenerationConfig::from_preset(DifficultyPreset::Grimoire)),
_ => {
let size = req.size.unwrap_or(9);
if size < 3 || size > 25 { return Err(AppError("grid size must be between 3 and 25".into())); }
let density = req.density.unwrap_or(0.22);
let diff = req.clue_difficulty.unwrap_or(1);
let freq_threshold = match diff {
0 => 0.1,
1 => 0.05,
_ => 0.0,
};
let (bt, restarts) = if size >= 19 { (30000, 30) }
else if size >= 13 { (15000, 20) }
else { (5000, 10) };
(diff, GenerationConfig {
width: size,
height: size,
black_cell_density: density,
frequency_threshold: freq_threshold,
obscurity_budget: match diff { 1 => 0.0, 2 => 0.10, _ => 0.20 },
max_backtrack: bt,
max_restarts: restarts,
seed: req.seed,
rand_top_base: GenerationConfig::rand_top_for_size(size),
..Default::default()
})
}
};
let mut config = config;
if let Some(seed) = req.seed {
config.seed = Some(seed);
}
if req.cryptic.unwrap_or(false) {
config.cryptic_grid = true;
// slightly sparser than standard, with longer slots preferred
config.black_cell_density = (config.black_cell_density + 0.08).min(0.40);
}
let mut dict = (*state.dict).clone();
let is_cryptic = req.cryptic.unwrap_or(false);
if !is_cryptic {
dict.apply_difficulty(&word_diffs, difficulty_level);
let wq = state.word_quality.read().await.clone();
if !wq.is_empty() { dict.apply_quality(&wq); }
}
// theme support
let theme_name = req.theme.clone();
let theme_words = theme_name.as_ref().and_then(|t| load_theme_words(t));
if let Some(ref tw) = theme_words {
let count = dict.boost_theme(tw);
tracing::info!("theme '{}': boosted {} words", theme_name.as_deref().unwrap_or(""), count);
}
// pack support - boost pack words in dictionary
if let Some(ref pack_id) = req.pack {
let dbi = state.db.lock().await;
if let Some(pack) = db::get_pack(&dbi.db, pack_id) {
let pack_json: serde_json::Value = serde_json::from_str(&pack.pack_data).unwrap_or_default();
if let Some(words) = pack_json.get("words").and_then(|w| w.as_array()) {
let mut boosted = 0;
for w in words {
let word = w.get("word").and_then(|w| w.as_str()).unwrap_or("").to_uppercase();
if !word.is_empty() {
dict.boost_word(&word, 2.0);
boosted += 1;
}
}
tracing::info!("pack '{}': boosted {} words", pack_id, boosted);
}
db::increment_pack_downloads(&dbi.db, pack_id);
}
drop(dbi);
}
// apply staleness penalties so recently used words get deprioritized
config.staleness = state.build_staleness_penalties().await;
let tw_clone = theme_words.clone();
let tn_clone = theme_name.clone();
let puzzle = tokio::task::spawn_blocking(move || {
cruciverb_core::puzzle::generate_themed_puzzle(
&config, &dict, &clue_db, difficulty_level,
tn_clone.as_deref(), tw_clone.as_ref(),
)
})
.await
.map_err(|e| AppError(format!("task join error: {}", e)))?
.map_err(|e| AppError(e.to_string()))?;
// record words so future puzzles deprioritize them
let used_words: Vec<String> = puzzle.words.iter().map(|w| w.word.clone()).collect();
state.record_puzzle_words(&used_words).await;
// cryptic mode: swap clues with cryptic versions
let mut puzzle = puzzle;
if is_cryptic {
if let Some(ref cdb) = state.cryptic_db {
let count = puzzle.apply_cryptic_clues(cdb);
tracing::info!("applied {} cryptic clues", count);
}
}
let id = Uuid::new_v4();
let puzzle_json = puzzle.to_json().map_err(|e| AppError(e.to_string()))?;
let content_hash = hex::encode(Sha256::digest(puzzle_json.as_bytes()));
let short_id = {
let dbi = state.db.lock().await;
let short_id = db::store_puzzle(&dbi.db, &id.to_string(), &puzzle_json,
puzzle.width, puzzle.height, difficulty_level, None,
req.difficulty.as_deref());
db::store_puzzle_hash(&dbi.db, &id.to_string(), &content_hash);
short_id
};
let client = to_client_puzzle(&puzzle, id);
state.cache_puzzle(id, puzzle).await;
Ok(with_session_cookie(&session, Json(GenerateResponse {
id: id.to_string(),
short_id: short_id.clone(),
puzzle: client,
})))
}
#[axum::debug_handler]
pub async fn get_puzzle(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
session: Session,
) -> Result<Response, AppError> {
let uuid = Uuid::parse_str(&id).map_err(|_| AppError("invalid puzzle id".into()))?;
// try in-memory first
let puzzles = state.puzzles.read().await;
if let Some((puzzle, _)) = puzzles.get(&uuid) {
let short_id = {
let dbi = state.db.lock().await;
db::get_puzzle_json(&dbi.db, &id).map(|(_, s)| s).unwrap_or_default()
};
return Ok(with_session_cookie(&session, Json(ClientPuzzleResponse {
id: id.clone(),
short_id,
puzzle: to_client_puzzle(puzzle, uuid),
})));
}
drop(puzzles);
// try database
let dbi = state.db.lock().await;
let (puzzle_json, short_id) = db::get_puzzle_json(&dbi.db, &id)
.ok_or(AppError("puzzle not found".into()))?;
let puzzle: Puzzle = serde_json::from_str(&puzzle_json)
.map_err(|e| AppError(e.to_string()))?;
drop(dbi);
let client = to_client_puzzle(&puzzle, uuid);
state.cache_puzzle(uuid, puzzle).await;
Ok(with_session_cookie(&session, Json(ClientPuzzleResponse {
id,
short_id,
puzzle: client,
})))
}
pub async fn get_puzzle_by_short_id(
State(state): State<Arc<AppState>>,
Path(short_id): Path<String>,
session: Session,
) -> Result<Response, AppError> {
let dbi = state.db.lock().await;
let (id, puzzle_json) = db::get_puzzle_by_short_id(&dbi.db, &short_id)
.ok_or(AppError("puzzle not found".into()))?;
let puzzle: Puzzle = serde_json::from_str(&puzzle_json)
.map_err(|e| AppError(e.to_string()))?;
drop(dbi);
let uuid = Uuid::parse_str(&id).unwrap_or(Uuid::new_v4());
let client = to_client_puzzle(&puzzle, uuid);
state.cache_puzzle(uuid, puzzle).await;
Ok(with_session_cookie(&session, Json(ClientPuzzleResponse {
id,
short_id,
puzzle: client,
})))
}
pub async fn get_daily_puzzle(
State(state): State<Arc<AppState>>,
Path(date): Path<String>,
session: Session,
) -> Result<Response, AppError> {
get_daily_impl(state, session, &date, "scroll").await
}
pub async fn get_daily_preset(
State(state): State<Arc<AppState>>,
Path((date, preset)): Path<(String, String)>,
session: Session,
) -> Result<Response, AppError> {
get_daily_impl(state, session, &date, &preset).await
}
async fn get_daily_impl(
state: Arc<AppState>,
session: Session,
date: &str,
preset: &str,
) -> Result<Response, AppError> {
// serialize generation with the pre-generator, then re-check under the lock
let _gen_lock = state.daily_gen_lock(date, preset).await;
// check if already generated
{
let dbi = state.db.lock().await;
if let Some((id, short_id, puzzle_json)) = db::get_daily_puzzle(&dbi.db, date, preset) {
let puzzle: Puzzle = serde_json::from_str(&puzzle_json)
.map_err(|e| AppError(e.to_string()))?;
let uuid = Uuid::parse_str(&id).unwrap_or(Uuid::new_v4());
let client = to_client_puzzle(&puzzle, uuid);
let solve_info = db::get_solve_info(&dbi.db, &session.token, &id);
state.cache_puzzle(uuid, puzzle).await;
let mut resp = serde_json::to_value(ClientPuzzleResponse {
id, short_id, puzzle: client,
}).unwrap();
if let Some((time, hints, cell_results)) = solve_info {
resp["already_solved"] = serde_json::json!(true);
resp["solve_time"] = serde_json::json!(time);
resp["solve_hints"] = serde_json::json!(hints);
if let Some(cr) = cell_results {
resp["cell_results"] = serde_json::json!(cr);
}
}
return Ok(with_session_cookie(&session, Json(resp)));
}
}
// acquire generation permit before the heavy work
let _permit = state.generation_semaphore.try_acquire()
.map_err(|_| AppError::with_status(StatusCode::SERVICE_UNAVAILABLE, "server busy - try again shortly"))?;
// generate with secret-seeded RNG
let seed = daily_seed(date, preset, &state.daily_secret);
let (difficulty_level, mut config) = match preset {
"glyph" => (0u8, GenerationConfig::from_preset(DifficultyPreset::Glyph)),
"rune" => (1, GenerationConfig::from_preset(DifficultyPreset::Rune)),
"scroll" => (2, GenerationConfig::from_preset(DifficultyPreset::Scroll)),
"codex" => (3, GenerationConfig::from_preset(DifficultyPreset::Codex)),
"grimoire" => (3, GenerationConfig::from_preset(DifficultyPreset::Grimoire)),
_ => (2, GenerationConfig::from_preset(DifficultyPreset::Scroll)),
};
config.seed = Some(seed);
let clue_db = state.clue_db.clone();
let word_diffs = state.word_diffs.clone();
let mut dict = (*state.dict).clone();
dict.apply_difficulty(&word_diffs, difficulty_level);
let wq = state.word_quality.read().await.clone();
if !wq.is_empty() { dict.apply_quality(&wq); }
config.staleness = state.build_staleness_penalties().await;
let puzzle = tokio::task::spawn_blocking(move || {
generate_full_puzzle(&config, &dict, &clue_db, difficulty_level)
})
.await
.map_err(|e| AppError(format!("task join error: {}", e)))?
.map_err(|e| AppError(e.to_string()))?;
let used_words: Vec<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 = puzzle.to_json().map_err(|e| AppError(e.to_string()))?;
let short_id = {
let dbi = state.db.lock().await;
db::store_puzzle(&dbi.db, &id.to_string(), &puzzle_json,
puzzle.width, puzzle.height, difficulty_level,
Some(date), Some(preset))
};
crate::webhooks::fire_event(&state, "daily_puzzle.published", serde_json::json!({
"description": format!("New {} puzzle ({}x{}) - /p/{}", preset, puzzle.width, puzzle.height, short_id),
"preset": preset, "date": date, "short_id": short_id,
})).await;
// daily AP post is now handled by the pre-generator, not per-preset
let client = to_client_puzzle(&puzzle, id);
state.cache_puzzle(id, puzzle).await;
Ok(with_session_cookie(&session, Json(ClientPuzzleResponse {
id: id.to_string(),
short_id,
puzzle: client,
})))
}
pub fn daily_seed(date: &str, preset: &str, secret: &str) -> u64 {
use sha2::{Sha256, Digest};
let input = format!("{}-{}-{}", secret, preset, date);
let hash = Sha256::digest(input.as_bytes());
u64::from_le_bytes(hash[..8].try_into().unwrap())
}
pub async fn validate_solution(
State(state): State<Arc<AppState>>,
session: Session,
Path(id): Path<String>,
Json(req): Json<ValidateRequest>,
) -> Result<Response, AppError> {
check_banned(&state, &session.token).await?;
let uuid = Uuid::parse_str(&id).map_err(|_| AppError("invalid puzzle id".into()))?;
let puzzles = state.puzzles.read().await;
let (puzzle, _) = puzzles.get(&uuid).ok_or(AppError("puzzle not found".into()))?;
let mut all_correct = true;
let mut cell_results = Vec::with_capacity(puzzle.height);
for r in 0..puzzle.height {
let mut row_results = Vec::with_capacity(puzzle.width);
for c in 0..puzzle.width {
let actual = puzzle.grid.get(r, c).letter();
let submitted = req.cells.get(r)
.and_then(|row| row.get(c))
.and_then(|cell| cell.as_ref())
.and_then(|s| s.chars().next())
.filter(|c| c.is_alphabetic())
.map(|c| c.to_ascii_uppercase());
let result = match (actual, submitted) {
(Some(a), Some(s)) if a == s => "correct",
(Some(_), Some(_)) => { all_correct = false; "incorrect" }
(Some(_), None) => { all_correct = false; "empty" }
_ => "black",
};
row_results.push(result.to_string());
}
cell_results.push(row_results);
}
// record solve if correct (respecting privacy settings)
let mut new_achievements = Vec::new();
let mut flagged = false;
if all_correct {
let dbi = state.db.lock().await;
// skip if already solved - don't let re-submissions overwrite the record
if db::has_solve_record(&dbi.db, &session.token, &id) {
drop(dbi);
return Ok(with_session_cookie(&session, Json(serde_json::json!({
"correct": true,
"already_solved": true,
"cells": cell_results,
"new_achievements": [],
}))));
}
// use server-tracked hint count instead of trusting the client
let server_hints = db::get_hint_count(&dbi.db, &id, &session.token);
// minimum solve time check - flag suspiciously fast solves (raw client
// time; anonymized rounding below happens only for what gets stored)
let elapsed = req.elapsed.unwrap_or(0);
let daily_info = db::get_puzzle_daily_info(&dbi.db, &id);
let min_time: i64 = match &daily_info {
Some((_, preset)) => match preset.as_str() {
"glyph" => 3,
"rune" => 5,
"scroll" => 15,
"codex" => 30,
"grimoire" => 60,
_ => 3,
},
None => 3,
};
if elapsed < min_time {
flagged = true;
}
let settings_json = db::get_settings(&dbi.db, &session.token);
let settings: serde_json::Value = serde_json::from_str(&settings_json).unwrap_or_default();
let anon_mode = settings.get("anonymousSolveMode").and_then(|v| v.as_bool()).unwrap_or(false);
let no_track = settings.get("noTrackMode").and_then(|v| v.as_bool()).unwrap_or(false);
let anonymize_times = settings.get("anonymizeTimes").and_then(|v| v.as_bool()).unwrap_or(false);
// anonymizeTimes: round stored solve times to the minute
let elapsed = if anonymize_times { (elapsed / 60) * 60 } else { elapsed };
let cr = req.cell_results.as_deref();
if !no_track && !anon_mode {
db::record_solve(&dbi.db, &session.token, &id, elapsed, server_hints, flagged, cr);
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
// archived dailies count toward history and stats, but not the
// daily streak (matches the archive page copy)
let is_past_daily = daily_info
.as_ref()
.map(|(d, _)| d < &today)
.unwrap_or(false);
if !is_past_daily {
db::update_streak(&dbi.db, &session.token, &today);
}
new_achievements = crate::achievements::check_achievements(&dbi.db, &session.token);
} else if !no_track {
db::record_solve(&dbi.db, &session.token, &id, elapsed, server_hints, flagged, cr);
}
// auto-purge check
let purge_enabled = settings.get("autoPurgeEnabled").and_then(|v| v.as_bool()).unwrap_or(false);
if purge_enabled {
let days = settings.get("autoPurgeDays").and_then(|v| v.as_i64()).unwrap_or(90).max(7);
db::auto_purge(&dbi.db, &session.token, days);
}
// compute percentile and rank before dropping dbi
let percentile = if !no_track {
Some(db::get_solve_percentile(&dbi.db, &id, elapsed))
} else { None };
let rank = if !no_track {
Some(db::get_solve_rank(&dbi.db, &id, elapsed))
} else { None };
let total_solvers = if !no_track {
Some(db::get_total_solvers(&dbi.db, &id))
} else { None };
// generate receipt if this is a room solve
let receipt_json = if req.room_code.is_some() {
let (pj, _) = db::get_puzzle_json(&dbi.db, &id).unwrap_or_default();
let puzzle_hash = hex::encode(Sha256::digest(pj.as_bytes()));
let player_name = session.display_name.clone().unwrap_or_else(|| "Anonymous".into());
// get other players from the room
let other_players = if let Some(code) = &req.room_code {
let rooms = state.rooms.read().await;
rooms.get(code).map(|room| {
room.players.values()
.filter(|p| p.id != session.token)
.map(|p| p.name.clone())
.collect::<Vec<_>>()
}).unwrap_or_default()
} else {
Vec::new()
};
let mode_str = if let Some(code) = &req.room_code {
let rooms = state.rooms.read().await;
rooms.get(code).map(|room| {
match room.mode {
crate::rooms::RoomMode::Coop => "coop".to_string(),
crate::rooms::RoomMode::Competitive => "competitive".to_string(),
crate::rooms::RoomMode::Party => "party".to_string(),
}
})
} else {
None
};
crate::receipts::generate_receipt(
&dbi.db,
&puzzle_hash,
elapsed,
server_hints,
&player_name,
req.room_code.as_deref(),
mode_str.as_deref(),
other_players,
).map(|sr| serde_json::json!({
"receipt": sr.receipt,
"signature": sr.signature,
"key_id": sr.key_id,
}))
} else {
None
};
drop(dbi);
// fire webhooks (unless no-track)
if !no_track {
crate::webhooks::fire_event(&state, "puzzle.completed", serde_json::json!({
"description": format!("Puzzle solved in {}s", elapsed),
})).await;
for ach in &new_achievements {
crate::webhooks::fire_event(&state, "achievement.unlocked", serde_json::json!({
"description": format!("Achievement unlocked: {}", ach),
})).await;
}
// publish leaderboard to peers via direct API (not AP Notes)
if let Some((daily_date, daily_preset)) = daily_info.clone() {
let state2 = state.clone();
tokio::spawn(async move {
let entries = {
let dbi = state2.db.lock().await;
db::get_daily_leaderboard_filtered(&dbi.db, &daily_date, &daily_preset, 10)
.into_iter().map(|(name, time, _)| (name, time)).collect()
};
crate::activitypub::outbox::publish_leaderboard(
&state2, &daily_date, &daily_preset, entries,
).await;
});
}
}
return Ok(with_session_cookie(&session, Json(serde_json::json!({
"correct": true,
"cells": cell_results,
"new_achievements": new_achievements,
"flagged": flagged,
"percentile": percentile,
"rank": rank,
"total_solvers": total_solvers,
"receipt": receipt_json,
}))));
}
Ok(with_session_cookie(&session, Json(serde_json::json!({
"correct": all_correct,
"cells": cell_results,
"new_achievements": new_achievements,
"flagged": flagged,
}))))
}
pub async fn get_hint(
State(state): State<Arc<AppState>>,
session: Session,
Path(id): Path<String>,
Json(req): Json<HintRequest>,
) -> Result<Response, AppError> {
Uuid::parse_str(&id).map_err(|_| AppError("invalid puzzle id".into()))?;
// rate limit check_cell to prevent brute-force solution extraction
if req.kind == "check_cell" {
crate::security::check_rate_limit(&session.token, &format!("check_cell:{}", id), 50, 60)
.map_err(|s| AppError::with_status(s, "too many check requests"))?;
} else {
let dbi = state.db.lock().await;
let hint_count = db::get_hint_count(&dbi.db, &id, &session.token);
if hint_count >= 20 {
return Err(AppError::with_status(StatusCode::TOO_MANY_REQUESTS, "hint limit reached for this puzzle"));
}
db::increment_hint_count(&dbi.db, &id, &session.token);
drop(dbi);
}
let puzzle = load_puzzle(&state, &id).await?;
match req.kind.as_str() {
"check_cell" => {
let actual = puzzle.grid.get(req.row, req.col).letter();
let submitted = req.letter.as_ref()
.and_then(|s| s.chars().next())
.map(|c| c.to_ascii_uppercase());
let is_correct = actual == submitted;
Ok(with_session_cookie(&session, Json(HintResponse::CheckCell { is_correct })))
}
"reveal_letter" => {
let letter = puzzle.grid.get(req.row, req.col)
.letter()
.ok_or(AppError("not a letter cell".into()))?;
Ok(with_session_cookie(&session, Json(HintResponse::RevealLetter {
row: req.row, col: req.col, letter: letter.to_string(),
})))
}
"reveal_word" => {
let dir = match req.direction.as_deref() {
Some("down") => Direction::Down,
_ => Direction::Across,
};
let word = puzzle.words.iter()
.find(|w| w.direction == dir && word_contains(w, req.row, req.col))
.ok_or(AppError("no word at that position".into()))?;
let letters: Vec<LetterPosition> = word.word.chars().enumerate().map(|(i, ch)| {
let (r, c) = match word.direction {
Direction::Across => (word.row, word.col + i),
Direction::Down => (word.row + i, word.col),
};
LetterPosition { row: r, col: c, letter: ch.to_string() }
}).collect();
Ok(with_session_cookie(&session, Json(HintResponse::RevealWord { letters })))
}
_ => Err(AppError("unknown hint kind".into())),
}
}
pub async fn save_progress(
State(state): State<Arc<AppState>>,
session: Session,
Path(id): Path<String>,
Json(req): Json<ProgressRequest>,
) -> Result<Response, AppError> {
let grid_json = serde_json::to_string(&req.cells).map_err(|e| AppError(e.to_string()))?;
let dbi = state.db.lock().await;
// #9: encrypt if user has encryptSolveData enabled
let settings_json = db::get_settings(&dbi.db, &session.token);
let encrypt = serde_json::from_str::<serde_json::Value>(&settings_json)
.ok()
.and_then(|v| v.get("encryptSolveData")?.as_bool())
.unwrap_or(false);
if encrypt {
match encrypt_progress(grid_json.as_bytes()) {
Some(encrypted) => {
let stored = format!("ENC2:{}", hex::encode(&encrypted));
db::save_progress(&dbi.db, &session.token, &id, &stored);
}
None => {
tracing::warn!("encryptSolveData enabled but DATA_ENC_KEY missing or invalid - storing plaintext");
db::save_progress(&dbi.db, &session.token, &id, &grid_json);
}
}
} else {
db::save_progress(&dbi.db, &session.token, &id, &grid_json);
}
Ok(with_session_cookie(&session, Json(serde_json::json!({"ok": true}))))
}
pub async fn load_progress(
State(state): State<Arc<AppState>>,
session: Session,
Path(id): Path<String>,
) -> Result<Response, AppError> {
let dbi = state.db.lock().await;
let progress = db::load_progress(&dbi.db, &session.token, &id);
// #9: decrypt if data is encrypted (ENC2 = AES-256-GCM, ENC = legacy XOR)
let cells = progress.and_then(|p| {
if let Some(hex_data) = p.strip_prefix("ENC2:") {
let encrypted = hex::decode(hex_data).ok()?;
let decrypted = decrypt_progress(&encrypted)?;
let json_str = String::from_utf8(decrypted).ok()?;
serde_json::from_str::<serde_json::Value>(&json_str).ok()
} else if let Some(hex_data) = p.strip_prefix("ENC:") {
let encrypted = hex::decode(hex_data).ok()?;
let key = derive_key(&session.token);
let decrypted = xor_crypt(&encrypted, &key);
let json_str = String::from_utf8(decrypted).ok()?;
serde_json::from_str::<serde_json::Value>(&json_str).ok()
} else {
serde_json::from_str::<serde_json::Value>(&p).ok()
}
});
Ok(with_session_cookie(&session, Json(serde_json::json!({
"cells": cells
}))))
}
pub async fn get_me(
State(state): State<Arc<AppState>>,
session: Session,
) -> Result<Response, AppError> {
let dbi = state.db.lock().await;
let (current, best, last_date) = db::get_streak(&dbi.db, &session.token);
let has_passkey = db::has_passkey(&dbi.db, &session.token);
let preferred_preset = db::get_preferred_preset(&dbi.db, &session.token);
let settings_json = db::get_settings(&dbi.db, &session.token);
let settings: serde_json::Value = serde_json::from_str(&settings_json).unwrap_or_default();
let private = settings.get("privateProfile").and_then(|v| v.as_bool()).unwrap_or(false);
let display = if private { None } else { session.display_name.clone() };
// auto-purge on session load
let purge_enabled = settings.get("autoPurgeEnabled").and_then(|v| v.as_bool()).unwrap_or(false);
if purge_enabled {
let days = settings.get("autoPurgeDays").and_then(|v| v.as_i64()).unwrap_or(90);
db::auto_purge(&dbi.db, &session.token, days);
}
let freezes = db::get_streak_freezes(&dbi.db, &session.token);
Ok(with_session_cookie(&session, Json(serde_json::json!({
"display_name": display,
"is_new": session.is_new,
"has_passkey": has_passkey,
"preferred_preset": preferred_preset,
"streak": {
"current": current,
"best": best,
"last_date": last_date,
},
"streak_freezes": freezes,
}))))
}
pub async fn update_me(
State(state): State<Arc<AppState>>,
session: Session,
Json(req): Json<UpdateMeRequest>,
) -> Result<Response, AppError> {
if let Some(name) = &req.display_name {
if name.len() > 100 {
return Err(AppError::with_status(axum::http::StatusCode::BAD_REQUEST, "name too long"));
}
let clean = crate::security::sanitize_html(name);
if state.blocklist.read().unwrap().contains_blocked(&clean) {
return Err(AppError::with_status(axum::http::StatusCode::BAD_REQUEST, "name contains inappropriate content"));
}
let dbi = state.db.lock().await;
db::set_display_name(&dbi.db, &session.token, &clean);
}
Ok(with_session_cookie(&session, Json(serde_json::json!({"ok": true}))))
}
pub async fn get_stats(
State(state): State<Arc<AppState>>,
session: Session,
) -> Result<Response, AppError> {
let dbi = state.db.lock().await;
let (current, best, last_date) = db::get_streak(&dbi.db, &session.token);
let solves = db::get_stats(&dbi.db, &session.token);
let total = solves.len();
Ok(with_session_cookie(&session, Json(serde_json::json!({
"streak": { "current": current, "best": best, "last_date": last_date },
"total_solved": total,
"recent": solves.iter().map(|(pid, preset, time, hints)| {
serde_json::json!({
"puzzle_id": pid,
"preset": preset,
"time": time,
"hints": hints,
})
}).collect::<Vec<_>>(),
}))))
}
pub async fn get_leaderboard(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
session: Session,
) -> Result<Response, AppError> {
let dbi = state.db.lock().await;
let entries = db::get_leaderboard_filtered(&dbi.db, &id, 20);
Ok(with_session_cookie(&session, Json(serde_json::json!({
"entries": entries.iter().map(|(name, time, hints)| {
serde_json::json!({ "name": name, "time": time, "hints": hints })
}).collect::<Vec<_>>()
}))))
}
pub async fn get_daily_leaderboard(
State(state): State<Arc<AppState>>,
Path((date, preset)): Path<(String, String)>,
Query(params): Query<std::collections::HashMap<String, String>>,
session: Session,
) -> Result<Response, AppError> {
let clean_only = params.get("clean").map(|v| v == "true").unwrap_or(false);
let dbi = state.db.lock().await;
let entries = if clean_only {
db::get_daily_leaderboard_by_date(&dbi.db, &date, &preset, true, 20)
} else {
db::get_daily_leaderboard_filtered(&dbi.db, &date, &preset, 20)
};
Ok(with_session_cookie(&session, Json(serde_json::json!({
"entries": entries.iter().map(|(name, time, hints)| {
serde_json::json!({ "name": name, "time": time, "hints": hints })
}).collect::<Vec<_>>()
}))))
}
pub async fn get_federated_leaderboard(
State(state): State<Arc<AppState>>,
Path((date, preset)): Path<(String, String)>,
session: Session,
) -> Result<Response, AppError> {
let dbi = state.db.lock().await;
let entries = db::get_federated_leaderboard(&dbi.db, &date, &preset, 20);
Ok(with_session_cookie(&session, Json(serde_json::json!({
"entries": entries.iter().map(|(name, origin, time)| {
serde_json::json!({ "name": name, "instance": origin, "time": time })
}).collect::<Vec<_>>()
}))))
}
pub async fn get_alltime_leaderboard(
State(state): State<Arc<AppState>>,
Path(preset): Path<String>,
session: Session,
) -> Result<Response, AppError> {
let dbi = state.db.lock().await;
let entries = db::get_alltime_leaderboard(&dbi.db, &preset, 20);
Ok(with_session_cookie(&session, Json(serde_json::json!({
"entries": entries.iter().map(|(name, time, hints, date)| {
serde_json::json!({ "name": name, "time": time, "hints": hints, "date": date })
}).collect::<Vec<_>>()
}))))
}
pub async fn get_weekly_leaderboard(
State(state): State<Arc<AppState>>,
Path(preset): Path<String>,
session: Session,
) -> Result<Response, AppError> {
let dbi = state.db.lock().await;
let entries = db::get_weekly_leaderboard(&dbi.db, &preset, 20);
Ok(with_session_cookie(&session, Json(serde_json::json!({
"entries": entries.iter().map(|(name, time, hints)| {
serde_json::json!({ "name": name, "time": time, "hints": hints })
}).collect::<Vec<_>>()
}))))
}
pub async fn get_streak_leaderboard(
State(state): State<Arc<AppState>>,
session: Session,
) -> Result<Response, AppError> {
let dbi = state.db.lock().await;
let entries = db::get_streak_leaderboard(&dbi.db, 20);
Ok(with_session_cookie(&session, Json(serde_json::json!({
"entries": entries.iter().map(|(name, current, best)| {
serde_json::json!({ "name": name, "current": current, "best": best })
}).collect::<Vec<_>>()
}))))
}
pub async fn get_completionist_leaderboard(
State(state): State<Arc<AppState>>,
session: Session,
) -> Result<Response, AppError> {
let dbi = state.db.lock().await;
let entries = db::get_completionist_leaderboard(&dbi.db, 20);
Ok(with_session_cookie(&session, Json(serde_json::json!({
"entries": entries.iter().map(|(name, total)| {
serde_json::json!({ "name": name, "total_solves": total })
}).collect::<Vec<_>>()
}))))
}
pub async fn get_clean_leaderboard(
State(state): State<Arc<AppState>>,
Path((date, preset)): Path<(String, String)>,
session: Session,
) -> Result<Response, AppError> {
let dbi = state.db.lock().await;
let entries = db::get_clean_leaderboard(&dbi.db, &date, &preset, 20);
Ok(with_session_cookie(&session, Json(serde_json::json!({
"entries": entries.iter().map(|(name, time)| {
serde_json::json!({ "name": name, "time": time })
}).collect::<Vec<_>>()
}))))
}
pub async fn get_detailed_stats(
State(state): State<Arc<AppState>>,
session: Session,
) -> Result<Response, AppError> {
crate::security::check_rate_limit(&session.token, "stats", 10, 60)
.map_err(|s| AppError::with_status(s, "rate limit exceeded"))?;
let dbi = state.db.lock().await;
let stats = db::get_personal_stats(&dbi.db, &session.token);
Ok(with_session_cookie(&session, Json(stats)))
}
pub async fn get_analytics(
State(state): State<Arc<AppState>>,
session: Session,
) -> Result<Response, AppError> {
crate::security::check_rate_limit(&session.token, "analytics", 10, 60)
.map_err(|s| AppError::with_status(s, "rate limit exceeded"))?;
let dbi = state.db.lock().await;
let data = db::get_analytics_data(&dbi.db, &session.token);
Ok(with_session_cookie(&session, Json(data)))
}
pub async fn get_history(
State(state): State<Arc<AppState>>,
session: Session,
) -> Result<Response, AppError> {
crate::security::check_rate_limit(&session.token, "history", 10, 60)
.map_err(|s| AppError::with_status(s, "rate limit exceeded"))?;
let dbi = state.db.lock().await;
let history = db::get_solve_history(&dbi.db, &session.token, 50);
Ok(with_session_cookie(&session, Json(serde_json::json!({
"history": history
}))))
}
pub async fn get_solve_percentile(
State(state): State<Arc<AppState>>,
Path((id, time)): Path<(String, i64)>,
session: Session,
) -> Result<Response, AppError> {
let dbi = state.db.lock().await;
let percentile = db::get_solve_percentile(&dbi.db, &id, time);
Ok(with_session_cookie(&session, Json(serde_json::json!({
"percentile": percentile
}))))
}
pub async fn get_settings(
State(state): State<Arc<AppState>>,
session: Session,
) -> Result<Response, AppError> {
let dbi = state.db.lock().await;
let settings = db::get_settings(&dbi.db, &session.token);
let parsed: serde_json::Value = serde_json::from_str(&settings).unwrap_or_default();
Ok(with_session_cookie(&session, Json(parsed)))
}
pub async fn save_settings(
State(state): State<Arc<AppState>>,
session: Session,
Json(settings): Json<serde_json::Value>,
) -> Result<Response, AppError> {
let json = serde_json::to_string(&settings).map_err(|e| AppError(e.to_string()))?;
let dbi = state.db.lock().await;
db::save_settings(&dbi.db, &session.token, &json);
Ok(with_session_cookie(&session, Json(serde_json::json!({"ok": true}))))
}
// ---- achievement endpoints ----
pub async fn get_achievements(
State(state): State<Arc<AppState>>,
session: Session,
) -> Result<Response, AppError> {
let dbi = state.db.lock().await;
let unlocked = db::get_achievements(&dbi.db, &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();
Ok(with_session_cookie(&session, Json(serde_json::json!({ "achievements": result }))))
}
pub async fn list_achievements(
State(_state): State<Arc<AppState>>,
) -> Result<Json<serde_json::Value>, AppError> {
let all = crate::achievements::all_achievements();
let result: Vec<serde_json::Value> = all.iter().map(|(id, name, desc, icon, c1, c2)| {
serde_json::json!({
"id": id, "name": name, "description": desc,
"icon": icon, "color1": c1, "color2": c2,
})
}).collect();
Ok(Json(serde_json::json!({ "achievements": result })))
}
// ---- import puzzle from .puz / .ipuz ----
pub async fn import_puzzle(
State(state): State<Arc<AppState>>,
headers: axum::http::HeaderMap,
session: Session,
body: axum::body::Bytes,
) -> Result<Response, AppError> {
check_banned(&state, &session.token).await?;
crate::security::check_rate_limit(&session.token, "import", 10, 60)
.map_err(|s| AppError::with_status(s, "rate limit exceeded"))?;
let ip = crate::security::extract_client_ip(&headers);
crate::security::check_rate_limit_ip(&ip, "import", 30, 60)
.map_err(|s| AppError::with_status(s, "rate limit exceeded"))?;
if body.len() > 1_000_000 {
return Err(AppError::with_status(StatusCode::PAYLOAD_TOO_LARGE, "file too large (max 1MB)"));
}
let puzzle = cruciverb_core::import::detect_and_parse(&body)
.map_err(|e| AppError::with_status(StatusCode::BAD_REQUEST, &e))?;
let id = Uuid::new_v4();
let puzzle_json = puzzle.to_json().map_err(|e| AppError(e.to_string()))?;
let short_id = {
let dbi = state.db.lock().await;
db::store_puzzle(&dbi.db, &id.to_string(), &puzzle_json,
puzzle.width, puzzle.height, 0, None, Some("imported"))
};
let client = to_client_puzzle(&puzzle, id);
state.cache_puzzle(id, puzzle).await;
Ok(with_session_cookie(&session, Json(GenerateResponse {
id: id.to_string(),
short_id: short_id.clone(),
puzzle: client,
})))
}
// ---- export endpoints ----
pub async fn export_puz(
State(state): State<Arc<AppState>>,
session: Session,
Path(id): Path<String>,
) -> Result<Response, AppError> {
crate::security::check_rate_limit(&session.token, "export", 10, 60)
.map_err(|s| AppError::with_status(s, "rate limit exceeded"))?;
let puzzle = load_puzzle(&state, &id).await?;
let data = cruciverb_core::export::puz::to_puz(&puzzle, "cruciverb puzzle", "cruciverb");
Ok((
[(axum::http::header::CONTENT_TYPE, "application/octet-stream"),
(axum::http::header::CONTENT_DISPOSITION, "attachment; filename=\"puzzle.puz\"")],
data,
).into_response())
}
pub async fn export_ipuz(
State(state): State<Arc<AppState>>,
session: Session,
Path(id): Path<String>,
) -> Result<Response, AppError> {
crate::security::check_rate_limit(&session.token, "export", 10, 60)
.map_err(|s| AppError::with_status(s, "rate limit exceeded"))?;
let puzzle = load_puzzle(&state, &id).await?;
let data = cruciverb_core::export::ipuz::to_ipuz(&puzzle, "cruciverb puzzle");
Ok((
[(axum::http::header::CONTENT_TYPE, "application/json"),
(axum::http::header::CONTENT_DISPOSITION, "attachment; filename=\"puzzle.ipuz\"")],
serde_json::to_string_pretty(&data).unwrap_or_default(),
).into_response())
}
#[derive(serde::Deserialize)]
pub struct PdfQuery {
pub variant: Option<String>,
}
pub async fn export_pdf(
State(state): State<Arc<AppState>>,
session: Session,
Path(id): Path<String>,
Query(q): Query<PdfQuery>,
) -> Result<Response, AppError> {
crate::security::check_rate_limit(&session.token, "export", 10, 60)
.map_err(|s| AppError::with_status(s, "rate limit exceeded"))?;
let puzzle = load_puzzle(&state, &id).await?;
let variant = match q.variant.as_deref() {
Some("solution") => "solution",
_ => "puzzle",
};
let data = cruciverb_core::export::pdf::to_pdf(&puzzle, "cruciverb puzzle", variant);
Ok((
[(axum::http::header::CONTENT_TYPE, "application/pdf"),
(axum::http::header::CONTENT_DISPOSITION, &format!("attachment; filename=\"puzzle-{}.pdf\"", variant))],
data,
).into_response())
}
async fn load_puzzle(state: &AppState, id: &str) -> Result<Puzzle, AppError> {
let uuid = Uuid::parse_str(id).map_err(|_| AppError("invalid puzzle id".into()))?;
// try in-memory
let puzzles = state.puzzles.read().await;
if let Some((p, _)) = puzzles.get(&uuid) {
return Ok(p.clone());
}
drop(puzzles);
// try database
let dbi = state.db.lock().await;
let (json, _) = db::get_puzzle_json(&dbi.db, id).ok_or(AppError("puzzle not found".into()))?;
serde_json::from_str(&json).map_err(|e| AppError(e.to_string()))
}
// ---- puzzle editor endpoints ----
pub async fn editor_autofill(
State(state): State<Arc<AppState>>,
session: Session,
Json(req): Json<serde_json::Value>,
) -> Result<Response, AppError> {
let width = req.get("width").and_then(|v| v.as_u64()).ok_or(AppError("missing width".into()))? as usize;
let height = req.get("height").and_then(|v| v.as_u64()).ok_or(AppError("missing height".into()))? as usize;
let cells = req.get("cells").and_then(|v| v.as_array()).ok_or(AppError("missing cells".into()))?;
if width > 25 || height > 25 { return Err(AppError("max grid size is 25x25".into())); }
if width < 3 || height < 3 { return Err(AppError("min grid size is 3x3".into())); }
// check for offensive patterns
let cell_strs: Vec<Vec<&str>> = cells.iter().map(|row| {
row.as_array().map(|a| a.iter().map(|c| c.as_str().unwrap_or("white")).collect())
.unwrap_or_default()
}).collect();
if has_offensive_pattern(&cell_strs, width, height) {
return Err(AppError("this grid pattern is not allowed".into()));
}
let mut grid = Grid::new(width, height);
for (r, row) in cells.iter().enumerate() {
if let Some(row_arr) = row.as_array() {
for (c, cell) in row_arr.iter().enumerate() {
if cell.as_str() == Some("black") {
grid.set(r, c, Cell::Black);
}
}
}
}
let locked: Vec<(usize, usize, char)> = req.get("locked")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| {
let r = v.get("row")?.as_u64()? as usize;
let c = v.get("col")?.as_u64()? as usize;
let ch = v.get("letter")?.as_str()?.chars().next()?;
Some((r, c, ch))
}).collect())
.unwrap_or_default();
for &(r, c, ch) in &locked {
grid.set(r, c, Cell::Letter(ch));
}
let word_diffs = state.word_diffs.clone();
let mut dict = (*state.dict).clone();
dict.apply_difficulty(&word_diffs, 1); // easy words for editor fills
let wq = state.word_quality.read().await.clone();
if !wq.is_empty() { dict.apply_quality(&wq); }
let clue_db = state.clue_db.clone();
let (bt, restarts) = if width >= 19 { (30000, 30) }
else if width >= 13 { (15000, 20) }
else { (5000, 10) };
let staleness = state.build_staleness_penalties().await;
let config = GenerationConfig {
width, height,
min_word_length: 3,
frequency_threshold: 0.1,
max_backtrack: bt,
max_restarts: restarts,
timeout_seconds: 60,
rand_top_base: GenerationConfig::rand_top_for_size(width),
staleness,
..Default::default()
};
let result = tokio::task::spawn_blocking(move || {
let slots = extract_slots(&grid, 3);
match solver::solve(&grid, &slots, &dict, &config) {
Ok(solve_result) => {
let puzzle = assemble_puzzle(&solve_result, &slots, &dict, &clue_db, &config, 2, 0, None, None);
Ok(puzzle)
}
Err(e) => Err(e),
}
}).await.map_err(|e| AppError(format!("task error: {}", e)))?;
match result {
Ok(puzzle) => {
let filled: Vec<Vec<Option<String>>> = (0..height).map(|r| {
(0..width).map(|c| puzzle.grid.get(r, c).letter().map(|ch| ch.to_string())).collect()
}).collect();
let clues: Vec<serde_json::Value> = puzzle.clues_across.iter().chain(puzzle.clues_down.iter())
.map(|c| serde_json::json!({
"number": c.number, "direction": format!("{:?}", c.direction).to_lowercase(),
"text": c.text, "answer": c.answer,
})).collect();
Ok(with_session_cookie(&session, Json(serde_json::json!({
"ok": true, "letters": filled, "clues": clues,
}))))
}
Err(_) => Ok(with_session_cookie(&session, Json(serde_json::json!({
"ok": false, "error": "could not fill this grid pattern - try adjusting the black cells"
}))))
}
}
pub async fn editor_clue_suggestions(
State(state): State<Arc<AppState>>,
session: Session,
Path(word): Path<String>,
) -> Result<Response, AppError> {
let upper = word.to_uppercase();
if !upper.chars().all(|c| c.is_alphabetic()) {
return Err(AppError("invalid word".into()));
}
let mut suggestions = state.clue_db.suggestions(&upper, 5);
if let Some(ref cdb) = state.cryptic_db {
for s in cdb.suggestions(&upper, 3) {
if !suggestions.contains(&s) { suggestions.push(s); }
}
}
Ok(with_session_cookie(&session, Json(serde_json::json!({ "suggestions": suggestions }))))
}
pub async fn editor_check_words(
State(state): State<Arc<AppState>>,
session: Session,
Json(req): Json<serde_json::Value>,
) -> Result<Response, AppError> {
let words = req.get("words").and_then(|v| v.as_array())
.ok_or(AppError("missing words".into()))?;
// check against the in-memory blocklist (the DATA_DIR-aware one), not a
// hardcoded relative path that ignores the configured data directory
let blocklist = state.blocklist.read().unwrap();
let mut blocked: Vec<String> = Vec::new();
for w in words {
if let Some(word) = w.as_str() {
let upper = word.to_uppercase();
if upper.len() < 2 || !upper.chars().all(|c| c.is_alphabetic()) { continue; }
if blocklist.contains_blocked(&upper) {
blocked.push(upper);
}
}
}
Ok(with_session_cookie(&session, Json(serde_json::json!({
"blocked": blocked,
}))))
}
pub async fn editor_save(
State(state): State<Arc<AppState>>,
session: Session,
Json(req): Json<serde_json::Value>,
) -> Result<Response, AppError> {
check_banned(&state, &session.token).await?;
let id = req.get("id").and_then(|v| v.as_str())
.unwrap_or("").to_string();
let id = if id.is_empty() { Uuid::new_v4().to_string() } else { id };
let raw_title = req.get("title").and_then(|v| v.as_str()).unwrap_or("Untitled");
let title = crate::security::sanitize_html(raw_title);
if state.blocklist.read().unwrap().contains_blocked(&title) {
return Err(AppError::with_status(axum::http::StatusCode::BAD_REQUEST, "title contains inappropriate content"));
}
let width = req.get("width").and_then(|v| v.as_u64()).unwrap_or(15) as usize;
let height = req.get("height").and_then(|v| v.as_u64()).unwrap_or(15) as usize;
if width > 25 || height > 25 || width < 3 || height < 3 {
return Err(AppError::with_status(axum::http::StatusCode::BAD_REQUEST, "grid size must be between 3 and 25"));
}
let grid_json = req.get("grid").map(|v| v.to_string()).unwrap_or_else(|| "[]".into());
// check for offensive patterns in saved grid
if let Some(grid_arr) = req.get("grid").and_then(|v| v.as_array()) {
let cell_strs: Vec<Vec<&str>> = grid_arr.iter().map(|row| {
row.as_array().map(|a| a.iter().map(|c| c.as_str().unwrap_or("white")).collect())
.unwrap_or_default()
}).collect();
if has_offensive_pattern(&cell_strs, width, height) {
return Err(AppError("this grid pattern is not allowed".into()));
}
}
let clues_json = req.get("clues").map(|v| v.to_string());
let puzzle_json = req.get("puzzle").map(|v| v.to_string());
let status = req.get("status").and_then(|v| v.as_str()).unwrap_or("draft");
if status == "published" {
return Err(AppError::with_status(
axum::http::StatusCode::BAD_REQUEST,
"use the publish endpoint to publish puzzles",
));
}
let dbi = state.db.lock().await;
db::save_user_puzzle(&dbi.db, &id, &session.token, &title, width, height,
&grid_json, clues_json.as_deref(), puzzle_json.as_deref(), status);
Ok(with_session_cookie(&session, Json(serde_json::json!({ "id": id }))))
}
pub async fn editor_load(
State(state): State<Arc<AppState>>,
session: Session,
Path(id): Path<String>,
) -> Result<Response, AppError> {
let dbi = state.db.lock().await;
let puzzle = db::get_user_puzzle(&dbi.db, &id, &session.token)
.ok_or(AppError("puzzle not found".into()))?;
Ok(with_session_cookie(&session, Json(serde_json::json!({
"id": puzzle.0, "title": puzzle.1, "width": puzzle.2, "height": puzzle.3,
"grid": serde_json::from_str::<serde_json::Value>(&puzzle.4).unwrap_or_default(),
"clues": puzzle.5.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok()),
"puzzle": puzzle.6.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok()),
"status": puzzle.7,
}))))
}
pub async fn editor_list(
State(state): State<Arc<AppState>>,
session: Session,
) -> Result<Response, AppError> {
let dbi = state.db.lock().await;
let puzzles = db::list_user_puzzles(&dbi.db, &session.token);
let result: Vec<serde_json::Value> = puzzles.iter().map(|(id, title, w, h, status, updated)| {
serde_json::json!({ "id": id, "title": title, "width": w, "height": h, "status": status, "updated_at": updated })
}).collect();
Ok(with_session_cookie(&session, Json(serde_json::json!({ "puzzles": result }))))
}
pub async fn editor_delete(
State(state): State<Arc<AppState>>,
session: Session,
Path(id): Path<String>,
) -> Result<Response, AppError> {
// check if puzzle was published before deleting (need to send delete to federation)
let was_published = {
let dbi = state.db.lock().await;
let status: Option<String> = dbi.db.query_row(
"SELECT status FROM user_puzzles WHERE id = ?1 AND session_token = ?2",
rusqlite::params![id, session.token], |row| row.get(0),
).ok();
status.as_deref() == Some("published")
};
{
let dbi = state.db.lock().await;
db::delete_user_puzzle(&dbi.db, &id, &session.token);
}
// notify federation of deletion
if was_published {
if let Some(ap) = state.ap.as_ref() {
let object_uri = format!("https://{}/ap/community-puzzles/{}", ap.domain, id);
crate::activitypub::outbox::publish_delete(&state, &object_uri).await;
}
}
Ok(with_session_cookie(&session, Json(serde_json::json!({ "ok": true }))))
}
pub async fn editor_publish(
State(state): State<Arc<AppState>>,
session: Session,
Path(id): Path<String>,
) -> Result<Response, AppError> {
check_banned(&state, &session.token).await?;
{
let dbi = state.db.lock().await;
let ok = db::publish_user_puzzle(&dbi.db, &id, &session.token);
if !ok { return Err(AppError("puzzle needs clues before publishing".into())); }
}
Ok(with_session_cookie(&session, Json(serde_json::json!({ "ok": true }))))
}
pub async fn editor_export(
State(state): State<Arc<AppState>>,
session: Session,
Path(id): Path<String>,
) -> Result<Response, AppError> {
let dbi = state.db.lock().await;
let (title, w, h, grid_json, clues_json) = db::get_user_puzzle(&dbi.db, &id, &session.token)
.map(|(_, title, w, h, grid, clues, _, _)| (title, w, h, grid, clues))
.ok_or(AppError("puzzle not found".into()))?;
let grid: Vec<Vec<String>> = serde_json::from_str(&grid_json).unwrap_or_default();
let clues_data: Vec<serde_json::Value> = clues_json.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default();
// build ipuz
let mut puzzle_grid = Vec::new();
let mut solution_grid = Vec::new();
let mut cell_numbers = std::collections::HashMap::new();
for c in &clues_data {
let num = c.get("number").and_then(|v| v.as_u64()).unwrap_or(0);
let sr = c.get("startRow").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let sc = c.get("startCol").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
cell_numbers.entry((sr, sc)).or_insert(num);
}
// build letter map from clues
let mut letter_map: Vec<Vec<Option<char>>> = vec![vec![None; w as usize]; h as usize];
for c in &clues_data {
let answer = c.get("answer").and_then(|v| v.as_str()).unwrap_or("");
let dir = c.get("direction").and_then(|v| v.as_str()).unwrap_or("");
let sr = c.get("startRow").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let sc = c.get("startCol").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
for (i, ch) in answer.chars().enumerate() {
let (r, c) = if dir == "across" { (sr, sc + i) } else { (sr + i, sc) };
if r < h as usize && c < w as usize { letter_map[r][c] = Some(ch); }
}
}
for r in 0..h as usize {
let mut pr = Vec::new();
let mut sr = Vec::new();
for c in 0..w as usize {
if grid.get(r).and_then(|row| row.get(c)).map(|v| v.as_str()) == Some("black") {
pr.push(serde_json::json!("#"));
sr.push(serde_json::json!("#"));
} else {
let num = cell_numbers.get(&(r, c));
pr.push(if let Some(&n) = num { serde_json::json!(n) } else { serde_json::json!(0) });
sr.push(if let Some(ch) = letter_map[r][c] { serde_json::json!(ch.to_string()) } else { serde_json::json!(null) });
}
}
puzzle_grid.push(pr);
solution_grid.push(sr);
}
let across: Vec<serde_json::Value> = clues_data.iter()
.filter(|c| c.get("direction").and_then(|v| v.as_str()) == Some("across"))
.map(|c| serde_json::json!([c.get("number").and_then(|v| v.as_u64()).unwrap_or(0), c.get("text").and_then(|v| v.as_str()).unwrap_or("")]))
.collect();
let down: Vec<serde_json::Value> = clues_data.iter()
.filter(|c| c.get("direction").and_then(|v| v.as_str()) == Some("down"))
.map(|c| serde_json::json!([c.get("number").and_then(|v| v.as_u64()).unwrap_or(0), c.get("text").and_then(|v| v.as_str()).unwrap_or("")]))
.collect();
let ipuz = serde_json::json!({
"version": "http://ipuz.org/v2",
"kind": ["http://ipuz.org/crossword#1"],
"title": title,
"dimensions": { "width": w, "height": h },
"puzzle": puzzle_grid,
"solution": solution_grid,
"clues": { "Across": across, "Down": down },
});
let body = serde_json::to_string_pretty(&ipuz).map_err(|e| AppError(e.to_string()))?;
let safe_title = sanitize_filename(&title.replace(' ', "_").to_lowercase());
let filename = if safe_title.is_empty() { "puzzle.ipuz".to_string() } else { format!("{}.ipuz", safe_title) };
Ok((
[(axum::http::header::CONTENT_TYPE, "application/json"),
(axum::http::header::CONTENT_DISPOSITION, &format!("attachment; filename=\"{}\"", filename))],
body,
).into_response())
}
pub async fn editor_export_all(
State(state): State<Arc<AppState>>,
session: Session,
) -> Result<Response, AppError> {
let dbi = state.db.lock().await;
let puzzles = db::list_user_puzzles(&dbi.db, &session.token);
let mut files: Vec<(String, String)> = Vec::new();
for (id, _title, _, _, _, _) in &puzzles {
if let Some((_, t, w, h, grid, clues, _, _)) = db::get_user_puzzle(&dbi.db, id, &session.token) {
let ipuz = serde_json::json!({
"version": "http://ipuz.org/v2",
"kind": ["http://ipuz.org/crossword#1"],
"title": t,
"dimensions": { "width": w, "height": h },
"grid": grid,
"clues_data": clues,
});
let fname = format!("{}.ipuz", t.replace(' ', "_").to_lowercase());
files.push((fname, serde_json::to_string_pretty(&ipuz).unwrap_or_default()));
}
}
// build a simple concatenated JSON array as export (zip would need another dep)
let export = serde_json::json!({ "puzzles": files.iter().map(|(name, data)| serde_json::json!({ "filename": name, "data": data })).collect::<Vec<_>>() });
Ok((
[(axum::http::header::CONTENT_TYPE, "application/json"),
(axum::http::header::CONTENT_DISPOSITION, "attachment; filename=\"cruciverb-puzzles-export.json\"")],
serde_json::to_string_pretty(&export).unwrap_or_default(),
).into_response())
}
pub async fn editor_import(
State(state): State<Arc<AppState>>,
session: Session,
Json(req): Json<serde_json::Value>,
) -> Result<Response, AppError> {
let puzzles = req.get("puzzles").and_then(|v| v.as_array());
let single = if puzzles.is_none() { Some(&req) } else { None };
let items: Vec<&serde_json::Value> = if let Some(arr) = puzzles {
arr.iter().collect()
} else if let Some(s) = single {
vec![s]
} else {
return Err(AppError("no puzzle data found".into()));
};
let mut imported = 0;
let dbi = state.db.lock().await;
for item in items {
// parse ipuz format
let ipuz = if let Some(data) = item.get("data") {
// bulk export format: { filename, data }
if let Some(s) = data.as_str() {
serde_json::from_str::<serde_json::Value>(s).unwrap_or_default()
} else {
data.clone()
}
} else {
item.clone()
};
let title = ipuz.get("title").and_then(|v| v.as_str()).unwrap_or("Imported puzzle");
let dims = ipuz.get("dimensions");
let w = dims.and_then(|d| d.get("width")).and_then(|v| v.as_u64()).unwrap_or(15) as usize;
let h = dims.and_then(|d| d.get("height")).and_then(|v| v.as_u64()).unwrap_or(15) as usize;
// build grid from ipuz puzzle array
let mut grid: Vec<Vec<String>> = Vec::new();
let mut letters: Vec<Vec<Option<String>>> = Vec::new();
if let Some(puzzle_arr) = ipuz.get("puzzle").and_then(|v| v.as_array()) {
for row in puzzle_arr {
let mut gr = Vec::new();
let mut lr = Vec::new();
if let Some(cells) = row.as_array() {
for cell in cells {
if cell.as_str() == Some("#") {
gr.push("black".to_string());
lr.push(None);
} else {
gr.push("white".to_string());
lr.push(None);
}
}
}
grid.push(gr);
letters.push(lr);
}
}
// extract solution letters
if let Some(sol) = ipuz.get("solution").and_then(|v| v.as_array()) {
for (r, row) in sol.iter().enumerate() {
if let Some(cells) = row.as_array() {
for (c, cell) in cells.iter().enumerate() {
if let Some(s) = cell.as_str() {
if s != "#" && r < letters.len() && c < letters[r].len() {
letters[r][c] = Some(s.to_string());
}
}
}
}
}
}
// extract clues, recovering each answer from the solution grid so
// imported puzzles are playable instead of shipping empty answers
let mut editor_clues: Vec<serde_json::Value> = Vec::new();
let answer_at = |r: usize, c: usize, dir_key: &str| -> String {
let mut answer = String::new();
if dir_key == "across" {
let mut cc = c;
while cc < w {
if let Some(Some(ch)) = letters.get(r).and_then(|row| row.get(cc)) {
answer.push_str(ch);
cc += 1;
} else {
break;
}
}
} else {
let mut rr = r;
while rr < h {
if let Some(Some(ch)) = letters.get(rr).and_then(|row| row.get(c)) {
answer.push_str(ch);
rr += 1;
} else {
break;
}
}
}
answer
};
for (dir_name, dir_key) in [("Across", "across"), ("Down", "down")] {
if let Some(clue_arr) = ipuz.get("clues").and_then(|c| c.get(dir_name)).and_then(|v| v.as_array()) {
for clue in clue_arr {
if let Some(arr) = clue.as_array() {
if arr.len() >= 2 {
let num = arr[0].as_u64().unwrap_or(0);
let text = arr[1].as_str().unwrap_or("");
// find the numbered cell in the ipuz puzzle grid
let mut answer = String::new();
'find: {
if let Some(puzzle_arr) = ipuz.get("puzzle").and_then(|v| v.as_array()) {
for (r, row) in puzzle_arr.iter().enumerate() {
if let Some(cells) = row.as_array() {
for (c, cell) in cells.iter().enumerate() {
if cell.as_u64() == Some(num) {
answer = answer_at(r, c, dir_key);
break 'find;
}
}
}
}
}
}
editor_clues.push(serde_json::json!({
"number": num, "direction": dir_key, "text": text, "answer": answer
}));
}
}
}
}
}
let id = Uuid::new_v4().to_string();
let grid_str = serde_json::to_string(&grid).unwrap_or_default();
let clues_str = if editor_clues.is_empty() { None } else { Some(serde_json::to_string(&editor_clues).unwrap_or_default()) };
db::save_user_puzzle(&dbi.db, &id, &session.token, title, w, h,
&grid_str, clues_str.as_deref(), None, "draft");
imported += 1;
}
Ok(with_session_cookie(&session, Json(serde_json::json!({ "imported": imported }))))
}
// ---- webhook endpoints ----
pub async fn create_webhook(
State(state): State<Arc<AppState>>,
session: Session,
Json(req): Json<serde_json::Value>,
) -> Result<Response, AppError> {
let url = req.get("url").and_then(|v| v.as_str()).ok_or(AppError("missing url".into()))?;
if crate::security::is_internal_url(url) {
return Err(AppError("webhook URL cannot target internal addresses".into()));
}
let events = req.get("events").and_then(|v| v.as_str()).unwrap_or("daily_puzzle.published");
let secret = req.get("secret").and_then(|v| v.as_str());
let format = req.get("format").and_then(|v| v.as_str()).unwrap_or("json");
let id = Uuid::new_v4().to_string();
let dbi = state.db.lock().await;
db::create_webhook(&dbi.db, &id, url, events, secret, format);
Ok(with_session_cookie(&session, Json(serde_json::json!({ "id": id }))))
}
pub async fn list_webhooks(
State(state): State<Arc<AppState>>,
session: Session,
) -> Result<Response, AppError> {
let dbi = state.db.lock().await;
let hooks = db::list_webhooks(&dbi.db);
let result: Vec<serde_json::Value> = hooks.iter().map(|(id, url, events, _, format, enabled, failures)| {
serde_json::json!({ "id": id, "url": url, "events": events, "format": format, "enabled": enabled, "failure_count": failures })
}).collect();
Ok(with_session_cookie(&session, Json(serde_json::json!({ "webhooks": result }))))
}
pub async fn delete_webhook(
State(state): State<Arc<AppState>>,
session: Session,
Path(id): Path<String>,
) -> Result<Response, AppError> {
let dbi = state.db.lock().await;
db::delete_webhook(&dbi.db, &id);
Ok(with_session_cookie(&session, Json(serde_json::json!({ "ok": true }))))
}
pub async fn browse_community_puzzles(
State(state): State<Arc<AppState>>,
Query(params): Query<std::collections::HashMap<String, String>>,
session: Session,
) -> Result<Response, AppError> {
let limit: usize = params.get("limit").and_then(|l| l.parse().ok()).unwrap_or(50).min(100);
let offset: usize = params.get("offset").and_then(|o| o.parse().ok()).unwrap_or(0);
let dbi = state.db.lock().await;
let puzzles = db::list_published_puzzles(&dbi.db, limit, offset);
let result: Vec<serde_json::Value> = puzzles.iter().map(|(id, title, author, w, h, updated, origin)| {
let check_domain = origin.as_deref().unwrap_or("");
let rep = if !check_domain.is_empty() {
Some(crate::db::get_instance_reputation(&dbi.db, check_domain))
} else {
None
};
let low_rep = rep.map(|s| s < 50.0).unwrap_or(false);
serde_json::json!({
"id": id, "title": title, "author": author,
"width": w, "height": h, "updated_at": updated,
"origin": origin, "origin_reputation": rep, "low_reputation": low_rep,
})
}).collect();
Ok(with_session_cookie(&session, Json(serde_json::json!({ "puzzles": result }))))
}
pub async fn community_puzzles_today(
State(state): State<Arc<AppState>>,
session: Session,
) -> Result<Response, AppError> {
let dbi = state.db.lock().await;
let mut stmt = dbi.db.prepare(
"SELECT id, title, session_token, width, height, updated_at FROM user_puzzles
WHERE status = 'published' AND created_at >= datetime('now', '-24 hours')
ORDER BY created_at DESC LIMIT 50"
).map_err(|e| AppError(e.to_string()))?;
let puzzles: Vec<serde_json::Value> = stmt.query_map([], |row| {
let id: String = row.get(0)?;
let title: String = row.get(1)?;
let author: String = row.get(2)?;
let w: i64 = row.get(3)?;
let h: i64 = row.get(4)?;
let updated: String = row.get(5)?;
Ok(serde_json::json!({
"id": id, "title": title, "author": author,
"width": w, "height": h, "updated_at": updated,
}))
}).map_err(|e| AppError(e.to_string()))?.filter_map(|r| r.ok()).collect();
Ok(with_session_cookie(&session, Json(serde_json::json!({ "puzzles": puzzles }))))
}
pub async fn upload_daily_image(
State(_state): State<Arc<AppState>>,
ConnectInfo(source): ConnectInfo<std::net::SocketAddr>,
headers: axum::http::HeaderMap,
Json(body): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, AppError> {
// the daily image script runs on the local machine; the socket address is
// the only trustworthy way to prove that (request headers are not)
let is_local = source.ip().is_loopback();
let has_admin = headers.get("x-admin-session")
.and_then(|v| v.to_str().ok())
.map(|t| crate::admin::verify_admin_session(t))
.unwrap_or(false);
if !is_local && !has_admin {
return Err(AppError::with_status(StatusCode::FORBIDDEN, "not authorized"));
}
let data = body.get("data").and_then(|d| d.as_str())
.ok_or_else(|| AppError("missing data field".into()))?;
// strip data:image/jpeg;base64, prefix if present
let b64 = if let Some(idx) = data.find(",") { &data[idx + 1..] } else { data };
use base64::Engine;
let bytes = base64::engine::general_purpose::STANDARD.decode(b64)
.map_err(|e| AppError(format!("invalid base64: {}", e)))?;
let data_dir = std::env::var("DATA_DIR").unwrap_or_else(|_| "../data".into());
let path = std::path::PathBuf::from(&data_dir).join("daily-image.jpg");
std::fs::write(&path, &bytes).map_err(|e| AppError(format!("failed to write image: {}", e)))?;
tracing::info!("saved daily image ({} bytes)", bytes.len());
Ok(Json(serde_json::json!({ "ok": true })))
}
pub async fn serve_daily_image(
State(_state): State<Arc<AppState>>,
) -> Result<Response, AppError> {
let data_dir = std::env::var("DATA_DIR").unwrap_or_else(|_| "../data".into());
let path = std::path::PathBuf::from(&data_dir).join("daily-image.jpg");
if !path.exists() {
return Err(AppError::with_status(StatusCode::NOT_FOUND, "no daily image"));
}
let bytes = std::fs::read(&path).map_err(|e| AppError(e.to_string()))?;
Ok((
[(axum::http::header::CONTENT_TYPE, "image/jpeg"),
(axum::http::header::CACHE_CONTROL, "public, max-age=3600")],
bytes,
).into_response())
}
pub async fn ap_post_permalink(
State(state): State<Arc<AppState>>,
headers: axum::http::HeaderMap,
axum::extract::OriginalUri(uri): axum::extract::OriginalUri,
) -> Result<Response, AppError> {
let path = uri.path().to_string();
// if the request wants AP JSON, look up the outbox activity
let accept = headers.get("accept").and_then(|v| v.to_str().ok()).unwrap_or("");
if accept.contains("application/activity+json") || accept.contains("application/ld+json") {
let dbi = state.db.lock().await;
let domain = state.ap.as_ref().map(|a| a.domain.as_str()).unwrap_or("");
let note_uri = format!("https://{}{}", domain, path);
let activity_id = format!("{}/activity", note_uri);
if let Ok(json) = dbi.db.query_row(
"SELECT activity FROM ap_outbox WHERE id = ?1", [&activity_id], |r| r.get::<_, String>(0),
) {
return Ok((
[(axum::http::header::CONTENT_TYPE, "application/activity+json")],
json,
).into_response());
}
return Err(AppError::with_status(StatusCode::NOT_FOUND, "post not found"));
}
// HTML permalink page
let domain = state.ap.as_ref().map(|a| a.domain.as_str()).unwrap_or("");
let note_uri = format!("https://{}{}", domain, path);
let activity_id = format!("{}/activity", note_uri);
let dbi = state.db.lock().await;
let activity_json = dbi.db.query_row(
"SELECT activity FROM ap_outbox WHERE id = ?1", [&activity_id], |r| r.get::<_, String>(0),
).ok();
drop(dbi);
let (content, published, image_url) = if let Some(json) = &activity_json {
let v: serde_json::Value = serde_json::from_str(json).unwrap_or_default();
let content = v.pointer("/object/content").and_then(|c| c.as_str()).unwrap_or("").to_string();
let content = crate::security::sanitize_note_html(&content);
let published = v.get("published").and_then(|p| p.as_str()).unwrap_or("").to_string();
let image = v.pointer("/object/attachment/0/url").and_then(|u| u.as_str()).unwrap_or("").to_string();
(content, published, image)
} else {
// post not in outbox - might be an old deleted post, show a minimal page
(String::new(), String::new(), String::new())
};
let title = "cruciverb - daily puzzles";
let description = "Five crossword puzzles in five sizes, from very easy to expert.";
let og_image = if image_url.is_empty() { format!("https://{}/og-image.png", domain) } else { image_url.clone() };
let html = format!(r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<meta property="og:title" content="{title}">
<meta property="og:description" content="{description}">
<meta property="og:image" content="{og_image}">
<meta property="og:url" content="{note_uri}">
<meta property="og:type" content="article">
<meta name="twitter:card" content="summary_large_image">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ background: #faf5f0; color: #1a1410; font-family: 'DM Sans', system-ui, sans-serif; }}
.page {{ max-width: 36rem; margin: 0 auto; padding: 2rem 1.5rem; }}
.masthead {{ text-align: center; border-bottom: 3px double #1a1410; padding-bottom: 1rem; margin-bottom: 1.5rem; }}
.masthead h1 {{ font-family: 'Playfair Display', Georgia, serif; font-size: 1.5rem; font-weight: 900; text-transform: uppercase; letter-spacing: 0.05em; }}
.masthead .date {{ font-family: 'JetBrains Mono', monospace; font-size: 0.625rem; color: #8a8078; text-transform: uppercase; letter-spacing: 0.1em; margin-top: 0.25rem; }}
.content {{ font-size: 0.9rem; line-height: 1.7; color: #3a342e; }}
.content a {{ color: #c9503e; }}
.image {{ margin: 1.5rem 0; }}
.image img {{ width: 100%; height: auto; border: 1px solid #d4cfc8; }}
.cta {{ text-align: center; margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #d4cfc8; }}
.cta a {{ display: inline-block; padding: 0.625rem 1.5rem; background: #1a1410; color: #faf5f0; text-decoration: none; font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.08em; }}
.cta a:hover {{ background: #c9503e; }}
</style>
</head>
<body>
<div class="page">
<div class="masthead">
<h1>cruciverb</h1>
{published_html}
</div>
{content_html}
{image_html}
<div class="cta">
<a href="https://{domain}">play today's puzzles</a>
</div>
</div>
</body>
</html>"#,
title = crate::security::sanitize_html(title),
description = crate::security::sanitize_html(description),
og_image = crate::security::sanitize_html(&og_image),
note_uri = crate::security::sanitize_html(&note_uri),
domain = crate::security::sanitize_html(domain),
published_html = if published.is_empty() { String::new() } else {
format!("<div class=\"date\">{}</div>", crate::security::sanitize_html(&published))
},
content_html = if content.is_empty() {
"<p style=\"text-align:center;color:#8a8078;font-style:italic;\">This post is no longer available.</p>".to_string()
} else {
format!("<div class=\"content\">{}</div>", content)
},
image_html = if image_url.is_empty() { String::new() } else {
format!("<div class=\"image\"><img src=\"{}\" alt=\"Daily puzzle preview\"></div>", crate::security::sanitize_html(&image_url))
},
);
Ok((
[(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
html,
).into_response())
}
pub async fn daily_post_page(
State(state): State<Arc<AppState>>,
Path(date): Path<String>,
) -> Result<Response, AppError> {
// validate date format
if chrono::NaiveDate::parse_from_str(&date, "%Y-%m-%d").is_err() {
return Err(AppError::with_status(StatusCode::BAD_REQUEST, "invalid date"));
}
let domain = state.ap.as_ref().map(|a| a.domain.as_str()).unwrap_or("localhost");
let parsed_date = chrono::NaiveDate::parse_from_str(&date, "%Y-%m-%d").unwrap();
let today = chrono::Utc::now().date_naive();
let is_past = parsed_date < today;
let days_ago = (today - parsed_date).num_days();
let formatted_date = parsed_date.format("%A, %B %-d, %Y").to_string();
// get tagline/signoff for this date
let (tagline, signoff) = {
let dbi = state.db.lock().await;
let ct = crate::activitypub::outbox::load_custom_lines(&dbi.db, "ap_daily_taglines");
let cs = crate::activitypub::outbox::load_custom_lines(&dbi.db, "ap_daily_signoffs");
let t = crate::activitypub::outbox::pick_line(&date, crate::activitypub::outbox::TAGLINES, &ct, "tagline").to_string();
let s = crate::activitypub::outbox::pick_line(&date, crate::activitypub::outbox::SIGNOFFS, &cs, "signoff").to_string();
(t, s)
};
let page_url = format!("https://{}/post/{}", domain, date);
let image_url = format!("https://{}/daily-image", domain);
let site_url = format!("https://{}", domain);
let title = format!("cruciverb - {}", formatted_date);
let description = format!("{}. {}", tagline, signoff);
let archive_banner = if is_past {
let ago = if days_ago == 1 { "yesterday".to_string() } else { format!("{} days ago", days_ago) };
let today_url = format!("https://{}/post/{}", domain, today.format("%Y-%m-%d"));
format!(
r#"<div class="archive-banner"><div class="archive-banner-title">From the archive</div><div class="archive-banner-text">These puzzles were published {}. Solving them counts toward your history and stats, but not your daily streak.<br><a href="{}">See today's puzzles instead</a></div></div>"#,
crate::security::sanitize_html(&ago),
crate::security::sanitize_html(&today_url),
)
} else {
String::new()
};
let html = format!(r##"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<meta property="og:title" content="{title}">
<meta property="og:description" content="{og_desc}">
<meta property="og:image" content="{image_url}">
<meta property="og:image:width" content="2560">
<meta property="og:image:height" content="1440">
<meta property="og:image:type" content="image/jpeg">
<meta property="og:image:alt" content="Five crossword puzzle grids side by side representing today's daily puzzles from very easy to expert.">
<meta property="og:url" content="{page_url}">
<meta property="og:type" content="article">
<meta property="og:site_name" content="cruciverb">
<meta property="article:published_time" content="{date}T00:00:00Z">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{title}">
<meta name="twitter:description" content="{og_desc}">
<meta name="twitter:image" content="{image_url}">
<meta name="description" content="{og_desc}">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="canonical" href="{page_url}">
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,700;0,900;1,400&family=DM+Sans:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{{margin:0;padding:0;box-sizing:border-box}}
:root{{
--font-display:'Playfair Display',Georgia,serif;
--font-body:'DM Sans',system-ui,sans-serif;
--font-mono:'JetBrains Mono',ui-monospace,monospace;
--bg:#f8f6f3;--bg-alt:#efecea;--surface:#ffffff;
--text:#0d0d0d;--text-secondary:#454545;--text-tertiary:#4a4a4a;
--border:#e0dcd8;--border-light:#c5c0ba;
--accent:#d4432f;--accent-hover:#ba3825;
}}
@media(prefers-color-scheme:dark){{:root{{
--bg:#111111;--bg-alt:#191919;--surface:#1a1a1a;
--text:#ededed;--text-secondary:#b5b5b5;--text-tertiary:#ababab;
--border:#2a2a2a;--border-light:#222;
--accent:#e8594a;--accent-hover:#d04a3c;
}}}}
body{{font-family:var(--font-body);background:var(--bg);color:var(--text);line-height:1.5;min-height:100vh;display:flex;flex-direction:column;align-items:center}}
.page{{width:100%;max-width:40rem;padding:3rem 2rem 4rem}}
.masthead{{text-align:center;margin-bottom:2rem}}
.masthead-rule{{height:2px;background:var(--text);width:100%}}
.masthead h1{{font-family:var(--font-display);font-size:3rem;font-weight:900;letter-spacing:-0.03em;line-height:1;margin:0.75rem 0 0.25rem;text-transform:uppercase}}
.masthead-date{{font-family:var(--font-mono);font-size:0.563rem;text-transform:uppercase;letter-spacing:0.2em;color:var(--text-tertiary)}}
.masthead-sub{{font-family:var(--font-display);font-size:0.875rem;font-weight:400;font-style:italic;color:var(--text-secondary);margin:0.5rem 0 0.75rem}}
.tagline{{font-family:var(--font-display);font-style:italic;font-size:0.9rem;color:var(--text-secondary);text-align:center;margin:1.5rem 0}}
.puzzle-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:1px;background:var(--border);border:1px solid var(--border);margin:1.5rem 0}}
.puzzle-card{{background:var(--surface);padding:1rem 0.5rem;text-align:center;text-decoration:none;color:var(--text);transition:background 200ms}}
.puzzle-card:hover{{background:var(--bg-alt)}}
.puzzle-card-name{{font-family:var(--font-display);font-size:0.875rem;font-weight:700;display:block}}
.puzzle-card-meta{{font-family:var(--font-mono);font-size:0.5rem;color:var(--text-tertiary);text-transform:uppercase;letter-spacing:0.08em;margin-top:0.25rem;display:block}}
.signoff{{font-family:var(--font-body);font-size:0.75rem;color:var(--text-tertiary);text-align:center;margin:0.5rem 0 1.5rem}}
.post-image{{margin:1.5rem 0;border:1px solid var(--border)}}
.post-image img{{width:100%;height:auto;display:block}}
.hashtags{{display:flex;gap:0.5rem;justify-content:center;margin:1rem 0}}
.hashtags a{{font-family:var(--font-mono);font-size:0.563rem;text-transform:uppercase;letter-spacing:0.08em;color:var(--text-tertiary);text-decoration:none;padding:0.25rem 0.625rem;border:1px solid var(--border)}}
.hashtags a:hover{{color:var(--accent);border-color:var(--accent)}}
.cta{{text-align:center;margin-top:2rem;padding-top:1.5rem;border-top:1px solid var(--border)}}
.cta a{{display:inline-block;padding:0.75rem 2rem;background:var(--text);color:var(--bg);text-decoration:none;font-family:var(--font-mono);font-size:0.688rem;text-transform:uppercase;letter-spacing:0.1em;transition:background 200ms}}
.cta a:hover{{background:var(--accent)}}
.footer{{text-align:center;margin-top:2rem;font-family:var(--font-mono);font-size:0.5rem;color:var(--text-tertiary);letter-spacing:0.1em;text-transform:uppercase}}
.footer a{{color:var(--text-tertiary);text-decoration:none}}
.footer a:hover{{color:var(--accent)}}
@media(max-width:640px){{
.page{{padding:1.5rem 1rem 3rem}}
.masthead h1{{font-size:2rem}}
.puzzle-grid{{grid-template-columns:repeat(3,1fr)}}
}}
@media(max-width:380px){{.puzzle-grid{{grid-template-columns:repeat(2,1fr)}}}}
.archive-banner{{background:var(--bg-alt);border:1px solid var(--border);padding:1rem 1.25rem;margin-bottom:1.5rem;text-align:center}}
.archive-banner-title{{font-family:var(--font-display);font-size:0.875rem;font-style:italic;color:var(--text);margin-bottom:0.375rem}}
.archive-banner-text{{font-family:var(--font-body);font-size:0.75rem;color:var(--text-secondary);line-height:1.6}}
.archive-banner a{{color:var(--accent);text-decoration:none}}
.archive-banner a:hover{{text-decoration:underline}}
</style>
</head>
<body>
<div class="page">
<div class="masthead">
<div class="masthead-rule"></div>
<h1>cruciverb</h1>
<div class="masthead-date">{formatted_date}</div>
<div class="masthead-sub">daily puzzles</div>
<div class="masthead-rule"></div>
</div>
{archive_banner}
<div class="tagline">{tagline}</div>
<div class="puzzle-grid">
<a class="puzzle-card" href="{site_url}/daily/glyph"><span class="puzzle-card-name">Glyph</span><span class="puzzle-card-meta">5x5 very easy</span></a>
<a class="puzzle-card" href="{site_url}/daily/rune"><span class="puzzle-card-name">Rune</span><span class="puzzle-card-meta">5x5 easy</span></a>
<a class="puzzle-card" href="{site_url}/daily/scroll"><span class="puzzle-card-name">Scroll</span><span class="puzzle-card-meta">9x9 medium</span></a>
<a class="puzzle-card" href="{site_url}/daily/codex"><span class="puzzle-card-name">Codex</span><span class="puzzle-card-meta">15x15 hard</span></a>
<a class="puzzle-card" href="{site_url}/daily/grimoire"><span class="puzzle-card-name">Grimoire</span><span class="puzzle-card-meta">21x21 expert</span></a>
</div>
<div class="signoff">{signoff}</div>
<div class="post-image"><img src="{image_url}" alt="Five crossword puzzle grids side by side representing today's daily puzzles from very easy to expert."></div>
<div class="hashtags">
<a href="{site_url}/tags/crossword">#crossword</a>
<a href="{site_url}/tags/puzzle">#puzzle</a>
<a href="{site_url}/tags/cruciverbDaily">#cruciverbDaily</a>
</div>
<div class="cta"><a href="{site_url}">play today's puzzles</a></div>
<div class="footer"><a href="{site_url}">cruciverb.lashman.live</a></div>
</div>
</body>
</html>"##,
title = crate::security::sanitize_html(&title),
og_desc = crate::security::sanitize_html(&description),
image_url = crate::security::sanitize_html(&image_url),
page_url = crate::security::sanitize_html(&page_url),
site_url = crate::security::sanitize_html(&site_url),
formatted_date = crate::security::sanitize_html(&formatted_date),
date = crate::security::sanitize_html(&date),
tagline = crate::security::sanitize_html(&tagline),
signoff = crate::security::sanitize_html(&signoff),
archive_banner = archive_banner,
);
Ok((
[(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8"),
(axum::http::header::CACHE_CONTROL, "public, max-age=86400")],
html,
).into_response())
}
pub async fn list_federated_puzzles(
State(state): State<Arc<AppState>>,
session: Session,
) -> Result<Response, AppError> {
let dbi = state.db.lock().await;
let puzzles = db::list_federated_puzzles(&dbi.db, 20, 0);
Ok(with_session_cookie(&session, Json(serde_json::json!({
"puzzles": puzzles.iter().map(|(id, title, w, h, origin, date)| {
serde_json::json!({ "id": id, "title": title, "width": w, "height": h, "origin": origin, "updated_at": date })
}).collect::<Vec<_>>()
}))))
}
pub async fn play_community_puzzle(
State(state): State<Arc<AppState>>,
session: Session,
Path(id): Path<String>,
) -> Result<Response, AppError> {
let dbi = state.db.lock().await;
let (title, w, h, grid_json, clues_json) = db::get_published_puzzle(&dbi.db, &id)
.ok_or(AppError("puzzle not found".into()))?;
let grid: Vec<Vec<String>> = serde_json::from_str(&grid_json).unwrap_or_default();
let clues: Vec<serde_json::Value> = serde_json::from_str(&clues_json).unwrap_or_default();
// build cells array for client
let cells: Vec<Vec<&str>> = grid.iter().map(|row| {
row.iter().map(|c| if c == "black" { "black" } else { "empty" }).collect()
}).collect();
// build cell_numbers and extract letters for solution checking
let mut cell_numbers = std::collections::HashMap::new();
let mut solution: Vec<Vec<Option<String>>> = vec![vec![None; w as usize]; h as usize];
for clue in &clues {
let num = clue.get("number").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
let dir = clue.get("direction").and_then(|v| v.as_str()).unwrap_or("");
let answer = clue.get("answer").and_then(|v| v.as_str()).unwrap_or("");
let start_row = clue.get("startRow").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let start_col = clue.get("startCol").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
cell_numbers.insert(format!("{},{}", start_row, start_col), num);
for (i, ch) in answer.chars().enumerate() {
let (r, c) = if dir == "across" { (start_row, start_col + i) } else { (start_row + i, start_col) };
if r < h as usize && c < w as usize {
solution[r][c] = Some(ch.to_string());
}
}
}
let client_clues_across: Vec<serde_json::Value> = clues.iter()
.filter(|c| c.get("direction").and_then(|v| v.as_str()) == Some("across"))
.map(|c| serde_json::json!({
"number": c.get("number"), "direction": "across",
"text": c.get("text").and_then(|v| v.as_str()).unwrap_or(""),
})).collect();
let client_clues_down: Vec<serde_json::Value> = clues.iter()
.filter(|c| c.get("direction").and_then(|v| v.as_str()) == Some("down"))
.map(|c| serde_json::json!({
"number": c.get("number"), "direction": "down",
"text": c.get("text").and_then(|v| v.as_str()).unwrap_or(""),
})).collect();
// store as a playable puzzle so validate/hint routes work
let puzzle_id = Uuid::new_v4();
let mut grid_obj = cruciverb_core::grid::Grid::new(w as usize, h as usize);
for (r, row) in grid.iter().enumerate() {
for (c, cell) in row.iter().enumerate() {
if cell == "black" {
grid_obj.set(r, c, cruciverb_core::grid::Cell::Black);
} else if let Some(ref letter) = solution[r][c] {
if let Some(ch) = letter.chars().next() {
grid_obj.set(r, c, cruciverb_core::grid::Cell::Letter(ch));
}
}
}
}
// build the placed-words list as well, so hint reveal-word and other
// word-based routes behave like generated puzzles
let mut placed_words: Vec<cruciverb_core::puzzle::PlacedWord> = Vec::new();
for c in clues.iter() {
let num = c.get("number").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
let answer = c.get("answer").and_then(|v| v.as_str()).unwrap_or("");
if answer.is_empty() {
continue;
}
let sr = c.get("startRow").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let sc = c.get("startCol").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let direction = if c.get("direction").and_then(|v| v.as_str()) == Some("down") {
cruciverb_core::grid::slot::Direction::Down
} else {
cruciverb_core::grid::slot::Direction::Across
};
placed_words.push(cruciverb_core::puzzle::PlacedWord {
word: answer.to_string(),
row: sr,
col: sc,
direction,
clue_number: num,
});
}
let puzzle = cruciverb_core::puzzle::Puzzle {
grid: grid_obj,
words: placed_words,
clues_across: clues.iter()
.filter(|c| c.get("direction").and_then(|v| v.as_str()) == Some("across"))
.map(|c| cruciverb_core::puzzle::Clue {
number: c.get("number").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
direction: cruciverb_core::grid::slot::Direction::Across,
text: c.get("text").and_then(|v| v.as_str()).unwrap_or("").to_string(),
answer: c.get("answer").and_then(|v| v.as_str()).unwrap_or("").to_string(),
definition: None, wordplay_type: None,
}).collect(),
clues_down: clues.iter()
.filter(|c| c.get("direction").and_then(|v| v.as_str()) == Some("down"))
.map(|c| cruciverb_core::puzzle::Clue {
number: c.get("number").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
direction: cruciverb_core::grid::slot::Direction::Down,
text: c.get("text").and_then(|v| v.as_str()).unwrap_or("").to_string(),
answer: c.get("answer").and_then(|v| v.as_str()).unwrap_or("").to_string(),
definition: None, wordplay_type: None,
}).collect(),
width: w as usize, height: h as usize,
symmetry: cruciverb_core::config::SymmetryType::None,
difficulty_score: 0, generation_time_ms: 0,
theme: None, theme_entries: vec![], is_cryptic: false,
};
let short_id = db::make_short_id(&puzzle_id);
state.cache_puzzle(puzzle_id, puzzle).await;
Ok(with_session_cookie(&session, Json(serde_json::json!({
"id": puzzle_id.to_string(),
"short_id": short_id,
"puzzle": {
"width": w, "height": h,
"cells": cells,
"clues_across": client_clues_across,
"clues_down": client_clues_down,
"difficulty_score": 0,
"cell_numbers": cell_numbers,
},
"title": title,
"community": true,
}))))
}
fn has_offensive_pattern(cells: &[Vec<&str>], width: usize, height: usize) -> bool {
// detect swastika-like patterns by checking for the characteristic
// bent cross shape at various positions and scales
let is_black = |r: i32, c: i32| -> bool {
if r < 0 || c < 0 || r >= height as i32 || c >= width as i32 { return false; }
cells[r as usize][c as usize] == "black"
};
// check at different scales (arm length 2-5)
for arm in 2..=5i32 {
for cr in 0..height as i32 {
for cc in 0..width as i32 {
// a swastika has 4 arms from a center, each bending at a right angle
// check both clockwise and counterclockwise variants
for &dir in &[1i32, -1] {
// center must be black
if !is_black(cr, cc) { continue; }
// 4 arms: up-right, right-down, down-left, left-up (or mirrored)
let arms_ok = (1..=arm).all(|i| is_black(cr - i, cc)) // up
&& is_black(cr - arm, cc + dir) // up arm bends
&& (1..=arm).all(|i| is_black(cr, cc + i)) // right
&& is_black(cr + dir, cc + arm) // right arm bends
&& (1..=arm).all(|i| is_black(cr + i, cc)) // down
&& is_black(cr + arm, cc - dir) // down arm bends
&& (1..=arm).all(|i| is_black(cr, cc - i)) // left
&& is_black(cr - dir, cc - arm); // left arm bends
if arms_ok { return true; }
}
}
}
}
// also check for SS bolts (two parallel zigzag / lightning bolt shapes)
// the sig rune looks like: go down, shift right, go down (or mirrored)
for arm in 2..=4i32 {
for cr in 0..height as i32 - arm * 2 {
for cc in 0..width as i32 - arm - 2 {
for &dir in &[1i32, -1] {
// single bolt: vertical-diagonal-vertical pattern
let bolt = |sc: i32| -> bool {
(0..arm).all(|i| is_black(cr + i, sc))
&& (1..=arm).all(|i| is_black(cr + arm - 1 + i, sc + dir * i))
&& (0..arm).all(|i| is_black(cr + arm * 2 - 1 + i, sc + dir * arm))
};
// two parallel bolts = SS
if bolt(cc) && bolt(cc + arm + 1) {
return true;
}
}
}
}
}
false
}
fn load_theme_words(theme: &str) -> Option<std::collections::HashSet<String>> {
let safe_name: String = theme.chars().filter(|c| c.is_alphanumeric() || *c == '_' || *c == '-').collect();
let data_dir = std::env::var("DATA_DIR").unwrap_or_else(|_| "../data".into());
let path = std::path::PathBuf::from(&data_dir).join("themes").join(format!("{}.txt", safe_name));
let content = std::fs::read_to_string(&path).ok()?;
let words: std::collections::HashSet<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) }
}
pub async fn list_themes() -> Json<serde_json::Value> {
let data_dir = std::env::var("DATA_DIR").unwrap_or_else(|_| "../data".into());
let themes_dir = std::path::PathBuf::from(&data_dir).join("themes");
let mut themes = Vec::new();
if let Ok(entries) = std::fs::read_dir(&themes_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("txt") { continue; }
if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
let count = match std::fs::read_to_string(&path) {
Ok(c) => c.lines().filter(|l| !l.trim().is_empty()).count(),
Err(e) => {
tracing::warn!("failed to read theme {}: {}", name, e);
0
}
};
themes.push(serde_json::json!({
"id": name,
"name": name.replace('_', " "),
"word_count": count,
}));
}
}
}
themes.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
Json(serde_json::json!({ "themes": themes }))
}
fn word_contains(word: &cruciverb_core::puzzle::PlacedWord, row: usize, col: usize) -> bool {
match word.direction {
Direction::Across => row == word.row && col >= word.col && col < word.col + word.word.len(),
Direction::Down => col == word.col && row >= word.row && row < word.row + word.word.len(),
}
}
// ---- puzzle rating endpoints ----
pub async fn rate_puzzle(
State(state): State<Arc<AppState>>,
session: Session,
Path(id): Path<String>,
Json(req): Json<serde_json::Value>,
) -> Result<Response, AppError> {
check_banned(&state, &session.token).await?;
let rating = req.get("rating").and_then(|v| v.as_i64())
.ok_or(AppError("missing rating".into()))?;
if !(1..=5).contains(&rating) {
return Err(AppError("rating must be 1-5".into()));
}
let dbi = state.db.lock().await;
let prev = db::get_my_rating(&dbi.db, &session.token, &id);
db::rate_puzzle(&dbi.db, &session.token, &id, rating as i32);
// first rating counts fully; changing an existing rating applies only the delta
let delta = rating as i64 - prev.unwrap_or(0) as i64;
db::update_quality_from_rating(&dbi.db, &id, delta, prev.is_none());
let (avg, count) = db::get_puzzle_rating(&dbi.db, &id);
// refresh quality caches
*state.word_quality.write().await = db::get_word_quality_scores(&dbi.db);
*state.clue_quality.write().await = db::get_clue_quality_scores(&dbi.db);
Ok(with_session_cookie(&session, Json(serde_json::json!({
"average": (avg * 10.0).round() / 10.0,
"count": count,
"my_rating": rating,
}))))
}
pub async fn get_rating(
State(state): State<Arc<AppState>>,
session: Session,
Path(id): Path<String>,
) -> Result<Response, AppError> {
let dbi = state.db.lock().await;
let (avg, count) = db::get_puzzle_rating(&dbi.db, &id);
let my = db::get_my_rating(&dbi.db, &session.token, &id);
Ok(with_session_cookie(&session, Json(serde_json::json!({
"average": (avg * 10.0).round() / 10.0,
"count": count,
"my_rating": my,
}))))
}
pub async fn get_daily_ratings(
State(state): State<Arc<AppState>>,
session: Session,
Path(date): Path<String>,
) -> Result<Response, AppError> {
let dbi = state.db.lock().await;
let ratings = db::get_daily_ratings(&dbi.db, &date);
let result: serde_json::Map<String, serde_json::Value> = ratings.iter().map(|(preset, avg, count)| {
(preset.clone(), serde_json::json!({
"average": (*avg * 10.0).round() / 10.0,
"count": count,
}))
}).collect();
Ok(with_session_cookie(&session, Json(serde_json::json!(result))))
}
// ---- community clue endpoints ----
pub async fn submit_community_clue(
State(state): State<Arc<AppState>>,
session: Session,
Json(req): Json<serde_json::Value>,
) -> Result<Response, AppError> {
check_banned(&state, &session.token).await?;
crate::security::check_rate_limit(&session.token, "submit_clue", 10, 60)
.map_err(|s| AppError::with_status(s, "rate limit exceeded"))?;
let word = req.get("word").and_then(|v| v.as_str())
.ok_or(AppError("missing word".into()))?;
let clue_text = req.get("clue").and_then(|v| v.as_str())
.ok_or(AppError("missing clue".into()))?;
if word.is_empty() || clue_text.is_empty() {
return Err(AppError("word and clue must not be empty".into()));
}
if !word.chars().all(|c| c.is_alphabetic()) {
return Err(AppError("word must contain only letters".into()));
}
if clue_text.len() > 500 {
return Err(AppError("clue too long (max 500 characters)".into()));
}
if db::text_contains_url(clue_text) {
return Err(AppError("clues cannot contain URLs".into()));
}
let clean_clue = crate::security::sanitize_html(clue_text);
if state.blocklist.read().unwrap().contains_blocked(&clean_clue) {
return Err(AppError::with_status(axum::http::StatusCode::BAD_REQUEST, "clue contains inappropriate content"));
}
let dbi = state.db.lock().await;
let id = db::submit_clue(&dbi.db, word, &clean_clue, &session.token);
Ok(with_session_cookie(&session, Json(serde_json::json!({ "id": id }))))
}
pub async fn get_community_clues(
State(state): State<Arc<AppState>>,
session: Session,
Path(word): Path<String>,
) -> Result<Response, AppError> {
if !word.chars().all(|c| c.is_alphabetic()) {
return Err(AppError("invalid word".into()));
}
let dbi = state.db.lock().await;
// check federated content filter
let settings_json = db::get_settings(&dbi.db, &session.token);
let settings: serde_json::Value = serde_json::from_str(&settings_json).unwrap_or_default();
let filter = settings.get("federatedContentFilter").and_then(|v| v.as_str()).unwrap_or("all");
let blocked_instances: Vec<String> = settings.get("blockedInstances")
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
.unwrap_or_default();
let clues = db::get_community_clues(&dbi.db, &word);
let clue_ids: Vec<i64> = clues.iter().map(|(id, ..)| *id).collect();
let user_votes = db::get_user_votes(&dbi.db, &session.token, &clue_ids);
let result: Vec<serde_json::Value> = clues.iter()
.filter(|(_, _, _, _, _, _, origin)| {
match filter {
"local" => origin.is_none(),
"approved" => origin.is_none() || true, // show all but could filter more
_ => true,
}
})
.filter(|(_, _, _, _, _, _, origin)| {
if let Some(d) = origin { !blocked_instances.contains(d) } else { true }
})
.map(|(id, text, up, down, status, created, origin)| {
let my_vote = user_votes.iter().find(|(cid, _)| cid == id).map(|(_, v)| *v).unwrap_or(0);
serde_json::json!({
"id": id, "clue": text, "upvotes": up, "downvotes": down,
"status": status, "created_at": created, "my_vote": my_vote,
"origin_domain": origin,
})
}).collect();
Ok(with_session_cookie(&session, Json(serde_json::json!({ "clues": result }))))
}
pub async fn vote_community_clue(
State(state): State<Arc<AppState>>,
session: Session,
headers: axum::http::HeaderMap,
Path(id): Path<i64>,
Json(req): Json<serde_json::Value>,
) -> Result<Response, AppError> {
check_banned(&state, &session.token).await?;
crate::security::check_rate_limit(&session.token, "vote_clue", 30, 60)
.map_err(|s| AppError::with_status(s, "rate limit exceeded"))?;
let ip = crate::security::extract_client_ip(&headers);
crate::security::check_rate_limit_ip(&ip, "vote_clue", 10, 60)
.map_err(|s| AppError::with_status(s, "rate limit exceeded"))?;
let vote = req.get("vote").and_then(|v| v.as_i64())
.ok_or(AppError("missing vote".into()))?;
if vote != 1 && vote != -1 {
return Err(AppError("vote must be 1 or -1".into()));
}
let dbi = state.db.lock().await;
db::vote_clue(&dbi.db, &session.token, id, vote as i32);
Ok(with_session_cookie(&session, Json(serde_json::json!({ "ok": true }))))
}
pub async fn my_submissions(
State(state): State<Arc<AppState>>,
session: Session,
) -> Result<Response, AppError> {
let dbi = state.db.lock().await;
let subs = db::get_my_submissions(&dbi.db, &session.token);
let result: Vec<serde_json::Value> = subs.iter().map(|(id, word, clue, up, down, status)| {
serde_json::json!({
"id": id, "word": word, "clue": clue,
"upvotes": up, "downvotes": down, "status": status,
})
}).collect();
Ok(with_session_cookie(&session, Json(serde_json::json!({ "submissions": result }))))
}
pub async fn delete_submission(
State(state): State<Arc<AppState>>,
session: Session,
Path(id): Path<i64>,
) -> Result<Response, AppError> {
let dbi = state.db.lock().await;
db::delete_submission(&dbi.db, id, &session.token);
Ok(with_session_cookie(&session, Json(serde_json::json!({ "ok": true }))))
}
// ---- passkey endpoints ----
pub async fn passkey_register_start(
State(state): State<Arc<AppState>>,
session: Session,
) -> Result<Response, AppError> {
let challenge = crate::webauthn::generate_challenge();
let name = session.display_name.as_deref().unwrap_or("cruciverb player");
let options = crate::webauthn::create_registration_challenge(
&challenge, &state.rp_id, "cruciverb", &session.token, name,
);
let now = chrono::Utc::now().timestamp();
state.reg_challenges.lock().await.insert(challenge, (session.token.clone(), now));
Ok(with_session_cookie(&session, Json(serde_json::json!({ "options": options }))))
}
pub async fn passkey_register_finish(
State(state): State<Arc<AppState>>,
session: Session,
Json(response): Json<crate::webauthn::RegistrationResponse>,
) -> Result<Response, AppError> {
// find and consume the challenge (clean expired first)
let mut challenges = state.reg_challenges.lock().await;
let now = chrono::Utc::now().timestamp();
challenges.retain(|_, (_, ts)| now - *ts < 300);
let challenge = challenges.iter()
.find(|(_, (tok, _))| tok == &session.token)
.map(|(c, _)| c.clone());
let challenge = challenge.ok_or(AppError("no pending registration".into()))?;
challenges.remove(&challenge);
drop(challenges);
let credential = crate::webauthn::verify_registration(
&response, &challenge, &state.rp_origin, &state.rp_id,
).map_err(|e| AppError(format!("registration failed: {}", e)))?;
let cred_id = base64::Engine::encode(
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
&credential.credential_id,
);
let passkey_json = serde_json::to_string(&credential)
.map_err(|e| AppError(e.to_string()))?;
let dbi = state.db.lock().await;
db::save_passkey(&dbi.db, &cred_id, &session.token, &passkey_json, "My passkey");
Ok(with_session_cookie(&session, Json(serde_json::json!({ "ok": true }))))
}
pub async fn passkey_auth_start(
State(state): State<Arc<AppState>>,
) -> Result<Json<serde_json::Value>, AppError> {
let challenge = crate::webauthn::generate_challenge();
let now = chrono::Utc::now().timestamp();
// for discoverable credentials, we send an empty allow list
let options = crate::webauthn::create_authentication_challenge(&challenge, &state.rp_id, &[]);
state.auth_challenges.lock().await.insert(challenge, now);
Ok(Json(serde_json::json!({ "options": options })))
}
pub async fn passkey_auth_finish(
State(state): State<Arc<AppState>>,
Json(response): Json<crate::webauthn::AuthenticationResponse>,
) -> Result<Response, AppError> {
// look up the credential by ID
let dbi = state.db.lock().await;
let (passkey_json, session_token) = db::get_passkey_json(&dbi.db, &response.id)
.ok_or(AppError("unknown credential".into()))?;
drop(dbi);
let credential: crate::webauthn::StoredCredential = serde_json::from_str(&passkey_json)
.map_err(|e| AppError(format!("corrupt credential: {}", e)))?;
// find and consume a valid challenge
let mut challenges = state.auth_challenges.lock().await;
let now = chrono::Utc::now().timestamp();
// clean up expired challenges (> 2 min)
challenges.retain(|_, ts| now - *ts < 120);
// try each challenge until one matches (the client embeds the challenge in clientDataJSON)
let mut matched: Option<(String, u32)> = None;
for (ch, _) in challenges.iter() {
match crate::webauthn::verify_authentication(
&response, &credential, ch, &state.rp_origin, &state.rp_id,
) {
Ok(counter) => {
matched = Some((ch.clone(), counter));
break;
}
Err(_) => continue,
}
}
let (challenge, auth_counter) = matched.ok_or(AppError("authentication failed".into()))?;
challenges.remove(&challenge);
drop(challenges);
// signature counter: catch clones of device-bound keys without breaking
// synced passkeys, which always report zero
let mut credential = credential;
{
let dbi = state.db.lock().await;
if auth_counter > 0 && credential.counter > 0 && auth_counter <= credential.counter {
tracing::warn!("possible cloned authenticator: counter did not advance");
crate::db::log_admin_action(&dbi.db, "possible_cloned_key", None, None);
return Err(AppError::with_status(
axum::http::StatusCode::FORBIDDEN,
"authentication failed - possible cloned authenticator",
));
}
if auth_counter > credential.counter {
credential.counter = auth_counter;
if let Ok(json) = serde_json::to_string(&credential) {
crate::db::update_passkey_json(&dbi.db, &response.id, &json);
}
}
}
// create a session with the original token
let dbi = state.db.lock().await;
let (token, name) = db::get_or_create_session(&dbi.db, &session_token);
let settings_json = db::get_settings(&dbi.db, &token);
let cookie_max_age = serde_json::from_str::<serde_json::Value>(&settings_json)
.ok()
.and_then(|v| v.get("cookieLifetime")?.as_u64())
.unwrap_or(2_592_000);
let session = Session {
token, display_name: name, is_new: false, cookie_max_age,
};
Ok(with_session_cookie(&session, Json(serde_json::json!({
"ok": true,
"display_name": session.display_name,
}))))
}
pub async fn list_passkeys(
State(state): State<Arc<AppState>>,
session: Session,
) -> Result<Response, AppError> {
let dbi = state.db.lock().await;
let passkeys = db::get_passkeys(&dbi.db, &session.token);
let result: Vec<serde_json::Value> = passkeys.iter().map(|(id, name, created)| {
serde_json::json!({ "id": id, "name": name, "created_at": created })
}).collect();
Ok(with_session_cookie(&session, Json(serde_json::json!({ "passkeys": result }))))
}
// ---- privacy / account endpoints ----
pub async fn get_session_info(
State(state): State<Arc<AppState>>,
session: Session,
) -> Result<Response, AppError> {
let dbi = state.db.lock().await;
let info = dbi.db.query_row(
"SELECT created_at, last_seen FROM sessions WHERE token = ?1",
[&session.token],
|row| Ok(serde_json::json!({
"created_at": row.get::<_, String>(0)?,
"last_seen": row.get::<_, String>(1)?,
})),
).unwrap_or(serde_json::json!(null));
Ok(with_session_cookie(&session, Json(serde_json::json!({ "session": info }))))
}
pub async fn delete_account(
State(state): State<Arc<AppState>>,
session: Session,
) -> Result<Response, AppError> {
let puzzle_ids = {
let dbi = state.db.lock().await;
db::get_user_published_puzzles(&dbi.db, &session.token)
};
// send federation deletes for published puzzles
for pid in &puzzle_ids {
if let Some(ap) = state.ap.as_ref() {
let uri = format!("https://{}/ap/community-puzzles/{}", ap.domain, pid);
crate::activitypub::outbox::publish_delete(&state, &uri).await;
}
}
let dbi = state.db.lock().await;
db::cleanup_user_content(&dbi.db, &session.token);
db::delete_account(&dbi.db, &session.token);
Ok(with_session_cookie(&session, Json(serde_json::json!({ "ok": true }))))
}
pub async fn export_data(
State(state): State<Arc<AppState>>,
session: Session,
) -> Result<Response, AppError> {
crate::security::check_rate_limit(&session.token, "export_data", 3, 60)
.map_err(|s| AppError::with_status(s, "rate limit exceeded"))?;
let dbi = state.db.lock().await;
let data = db::export_user_data(&dbi.db, &session.token);
Ok((
[(axum::http::header::CONTENT_TYPE, "application/json"),
(axum::http::header::CONTENT_DISPOSITION, "attachment; filename=\"cruciverb-export.json\"")],
serde_json::to_string_pretty(&data).unwrap_or_default(),
).into_response())
}
pub async fn import_data(
State(state): State<Arc<AppState>>,
session: Session,
Json(data): Json<serde_json::Value>,
) -> Result<Response, AppError> {
crate::security::check_rate_limit(&session.token, "import_data", 3, 60)
.map_err(|s| AppError::with_status(s, "rate limit exceeded"))?;
let dbi = state.db.lock().await;
let (imported, skipped) = db::import_user_data(&dbi.db, &session.token, &data);
Ok(with_session_cookie(&session, Json(serde_json::json!({
"imported": imported,
"skipped": skipped,
}))))
}
// ---- user migration ----
pub async fn migrate_account(
State(state): State<Arc<AppState>>,
session: Session,
Json(req): Json<serde_json::Value>,
) -> Result<Response, AppError> {
crate::security::check_rate_limit(&session.token, "migrate", 2, 300)
.map_err(|s| AppError::with_status(s, "rate limit exceeded"))?;
let target_domain = req.get("target_domain").and_then(|v| v.as_str())
.ok_or_else(|| AppError::with_status(StatusCode::BAD_REQUEST, "missing target_domain"))?;
if target_domain.is_empty() || !target_domain.contains('.') {
return Err(AppError::with_status(StatusCode::BAD_REQUEST, "invalid target domain"));
}
// get published puzzles before cleanup
let puzzle_ids = {
let dbi = state.db.lock().await;
db::get_user_published_puzzles(&dbi.db, &session.token)
};
// send federation deletes for published puzzles
for pid in &puzzle_ids {
if let Some(ap) = state.ap.as_ref() {
let uri = format!("https://{}/ap/community-puzzles/{}", ap.domain, pid);
crate::activitypub::outbox::publish_delete(&state, &uri).await;
}
}
// send Move activity to all peers
crate::activitypub::outbox::publish_move(&state, target_domain).await;
// export user data before cleanup
let dbi = state.db.lock().await;
let data = db::export_user_data(&dbi.db, &session.token);
db::cleanup_user_content(&dbi.db, &session.token);
drop(dbi);
Ok(with_session_cookie(&session, Json(serde_json::json!({
"ok": true,
"target": target_domain,
"export": data,
}))))
}
// ---- user blocklist import/export ----
pub async fn export_user_blocklist(
State(state): State<Arc<AppState>>,
session: Session,
) -> Result<Response, AppError> {
let dbi = state.db.lock().await;
let settings_json = db::get_settings(&dbi.db, &session.token);
let settings: serde_json::Value = serde_json::from_str(&settings_json).unwrap_or_default();
let blocked = settings.get("blockedInstances").and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect::<Vec<_>>())
.unwrap_or_default();
let body = serde_json::json!({
"format": "cruciverb-user-blocklist-v1",
"exported_at": chrono::Utc::now().to_rfc3339(),
"count": blocked.len(),
"domains": blocked,
});
Ok((
[(axum::http::header::CONTENT_TYPE, "application/json"),
(axum::http::header::CONTENT_DISPOSITION, "attachment; filename=\"my-blocklist.json\"")],
serde_json::to_string_pretty(&body).unwrap_or_default(),
).into_response())
}
pub async fn import_user_blocklist(
State(state): State<Arc<AppState>>,
session: Session,
Json(req): Json<serde_json::Value>,
) -> Result<Response, AppError> {
let domains = req.get("domains").and_then(|v| v.as_array())
.or_else(|| req.get("blocked").and_then(|v| v.as_array()));
let new_domains: Vec<String> = match domains {
Some(arr) => arr.iter().filter_map(|v| {
let d = if let Some(s) = v.as_str() { s.to_string() }
else if let Some(s) = v.get("domain").and_then(|d| d.as_str()) { s.to_string() }
else { return None };
let d = d.trim().to_lowercase();
if d.is_empty() { None } else { Some(d) }
}).collect(),
None => return Err(AppError("missing domains array".into())),
};
let dbi = state.db.lock().await;
let settings_json = db::get_settings(&dbi.db, &session.token);
let mut settings: serde_json::Value = serde_json::from_str(&settings_json).unwrap_or_default();
let mut existing: Vec<String> = settings.get("blockedInstances")
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
let mut added = 0;
for d in &new_domains {
if !existing.contains(d) { existing.push(d.clone()); added += 1; }
}
settings["blockedInstances"] = serde_json::to_value(&existing).unwrap_or_default();
db::save_settings(&dbi.db, &session.token, &settings.to_string());
Ok(with_session_cookie(&session, Json(serde_json::json!({ "imported": added, "total": existing.len() }))))
}
// ---- word of the day ----
pub async fn word_of_the_day(
State(state): State<Arc<AppState>>,
) -> Json<serde_json::Value> {
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
if state.wotd_words.is_empty() {
return Json(serde_json::json!({
"word": "CRUCIVERB",
"definition": "One who creates or solves crossword puzzles.",
"clue": "Crossword enthusiast",
"length": 9
}));
}
// deterministic pick based on date hash
let hash = Sha256::digest(format!("wotd-{}", today).as_bytes());
let idx = u64::from_le_bytes(hash[..8].try_into().unwrap()) as usize % state.wotd_words.len();
let word = &state.wotd_words[idx];
let definition = state.definitions.get(word)
.or_else(|| state.definitions.get(&word.to_lowercase()))
.or_else(|| {
// try case-insensitive lookup
state.definitions.iter()
.find(|(k, _)| k.to_uppercase() == *word)
.map(|(_, v)| v)
})
.cloned()
.unwrap_or_default();
// trim long definitions to something readable
let def_short = if definition.len() > 300 {
let cut = definition[..300].rfind(|c: char| c == '.' || c == ';').unwrap_or(300);
format!("{}...", &definition[..cut])
} else {
definition
};
let clue = state.clue_db.get_clue(word, 1).unwrap_or_else(|_| "Crossword word".into());
Json(serde_json::json!({
"word": word,
"definition": def_short,
"clue": clue,
"length": word.len()
}))
}
// ---- clue trainer ----
#[derive(serde::Deserialize)]
pub struct TrainerQuery {
difficulty: Option<String>,
}
pub async fn trainer_clue(
State(state): State<Arc<AppState>>,
Query(q): Query<TrainerQuery>,
) -> Json<serde_json::Value> {
let difficulty = q.difficulty.as_deref().unwrap_or("easy");
let clues = state.clue_db.random_clues(1);
if clues.is_empty() {
return Json(serde_json::json!({
"clue": "No clues available",
"length": 0,
"pattern": "",
"difficulty": difficulty
}));
}
let (clue_text, word) = &clues[0];
let chars: Vec<char> = word.chars().collect();
let pattern = match difficulty {
"easy" => {
// show first and last letter
chars.iter().enumerate().map(|(i, c)| {
if i == 0 || i == chars.len() - 1 { c.to_string() } else { "_".to_string() }
}).collect::<Vec<_>>().join(" ")
}
"medium" => {
// just underscores
chars.iter().map(|_| "_".to_string()).collect::<Vec<_>>().join(" ")
}
_ => String::new(), // hard: no pattern
};
Json(serde_json::json!({
"clue": clue_text,
"length": word.len(),
"pattern": pattern,
"difficulty": difficulty
}))
}
#[derive(serde::Deserialize)]
pub struct TrainerCheckRequest {
clue: String,
answer: String,
}
pub async fn trainer_check(
State(state): State<Arc<AppState>>,
Json(req): Json<TrainerCheckRequest>,
) -> Json<serde_json::Value> {
let answer_upper = req.answer.trim().to_uppercase();
// look up what word goes with this clue
// we stored the clue text, so search for a matching word
let mut correct_word = None;
if let Some(ref db) = state.clue_db.db_ref() {
if let Ok(mut stmt) = db.prepare(
"SELECT word FROM clues WHERE clue = ?1 LIMIT 1"
) {
if let Ok(rows) = stmt.query_map(rusqlite::params![req.clue], |row| row.get::<_, String>(0)) {
for w in rows.flatten() {
correct_word = Some(w);
break;
}
}
}
}
// fallback: search json clues
if correct_word.is_none() {
correct_word = state.clue_db.find_word_for_clue(&req.clue);
}
let answer = correct_word.unwrap_or_default();
let is_correct = answer_upper == answer.to_uppercase();
Json(serde_json::json!({
"correct": is_correct,
"answer": answer.to_uppercase()
}))
}
// ---- content reporting ----
pub async fn submit_report(
State(state): State<Arc<AppState>>,
session: Session,
Json(req): Json<serde_json::Value>,
) -> Result<Response, AppError> {
check_banned(&state, &session.token).await?;
crate::security::check_rate_limit(&session.token, "report", 10, 60)
.map_err(|s| AppError::with_status(s, "rate limit exceeded"))?;
let content_type = req.get("content_type").and_then(|v| v.as_str())
.ok_or(AppError("missing content_type".into()))?;
let content_id = req.get("content_id").and_then(|v| v.as_str())
.ok_or(AppError("missing content_id".into()))?;
let reason = req.get("reason").and_then(|v| v.as_str());
if content_type != "clue" && content_type != "puzzle" {
return Err(AppError("content_type must be 'clue' or 'puzzle'".into()));
}
let dbi = state.db.lock().await;
let id = db::submit_report(&dbi.db, &session.token, content_type, content_id, reason);
// if reporting a federated clue, update instance reputation
if content_type == "clue" {
if let Ok(domain) = dbi.db.query_row(
"SELECT origin_domain FROM community_clues WHERE id = ?1",
[content_id], |row| row.get::<_, Option<String>>(0),
) {
if let Some(d) = domain {
db::increment_instance_reports(&dbi.db, &d);
}
}
}
Ok(with_session_cookie(&session, Json(serde_json::json!({ "id": id }))))
}
// #9: XOR encryption helpers (legacy format, kept for reading old rows)
fn xor_crypt(data: &[u8], key: &[u8]) -> Vec<u8> {
data.iter().enumerate().map(|(i, b)| b ^ key[i % key.len()]).collect()
}
fn derive_key(token: &str) -> Vec<u8> {
Sha256::digest(token.as_bytes()).to_vec()
}
fn enc_key() -> Option<[u8; 32]> {
let raw = std::env::var("DATA_ENC_KEY").ok()?;
let bytes = hex::decode(raw.trim()).ok()?;
bytes.try_into().ok()
}
/// AES-256-GCM with the nonce prepended to the ciphertext. Returns None when
/// the server key is not configured, so callers can fall back to plaintext.
fn encrypt_progress(plain: &[u8]) -> Option<Vec<u8>> {
use aes_gcm::aead::{Aead, AeadCore, OsRng};
use aes_gcm::{Aes256Gcm, KeyInit};
let key = enc_key()?;
let cipher = Aes256Gcm::new_from_slice(&key).ok()?;
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let ciphertext = cipher.encrypt(&nonce, plain).ok()?;
let mut out = Vec::with_capacity(nonce.len() + ciphertext.len());
out.extend_from_slice(&nonce);
out.extend_from_slice(&ciphertext);
Some(out)
}
fn decrypt_progress(data: &[u8]) -> Option<Vec<u8>> {
use aes_gcm::aead::Aead;
use aes_gcm::{Aes256Gcm, KeyInit};
let key = enc_key()?;
let cipher = Aes256Gcm::new_from_slice(&key).ok()?;
let (nonce, ciphertext) = data.split_at(12);
cipher.decrypt(nonce.into(), ciphertext).ok()
}
// #18: instance about page
pub async fn about_page(
State(state): State<Arc<AppState>>,
) -> Json<serde_json::Value> {
let contact = std::env::var("INSTANCE_CONTACT").unwrap_or_default();
let (rules, peer_count) = {
let dbi = state.db.lock().await;
let r = db::get_server_config(&dbi.db, "instance_rules")
.or_else(|| std::env::var("INSTANCE_RULES").ok())
.unwrap_or_else(|| "Be respectful. Submit quality clues. No spam.".into());
let pc: i64 = dbi.db.query_row("SELECT COUNT(*) FROM ap_peers", [], |row| row.get(0)).unwrap_or(0);
(r, pc)
};
let ap_enabled = state.ap.is_some();
Json(serde_json::json!({
"software": "cruciverb",
"version": env!("CARGO_PKG_VERSION"),
"contact": contact,
"federation": ap_enabled,
"connected_instances": peer_count,
"rules": rules,
}))
}
// #17: public moderation log
pub async fn public_moderation_log(
State(state): State<Arc<AppState>>,
) -> Result<Json<serde_json::Value>, StatusCode> {
if std::env::var("PUBLIC_MOD_LOG").as_deref() != Ok("true") {
return Err(StatusCode::NOT_FOUND);
}
let dbi = state.db.lock().await;
let entries = db::get_audit_log(&dbi.db, 50);
let moderation_actions = ["moderate_clue", "approve_follower", "reject_follower",
"block_instance", "unblock_instance", "auto_block_instance", "federation_freeze", "federation_unfreeze"];
let filtered: Vec<serde_json::Value> = entries.iter()
.filter(|(_, action, _, _, _)| moderation_actions.contains(&action.as_str()))
.map(|(id, action, target, detail, ts)| {
serde_json::json!({
"id": id, "action": action, "target": target, "detail": detail, "timestamp": ts,
})
})
.collect();
Ok(Json(serde_json::json!({ "entries": filtered })))
}
// #19: federation health dashboard
pub async fn federation_health(
State(state): State<Arc<AppState>>,
) -> Json<serde_json::Value> {
let ap_enabled = state.ap.is_some();
if !ap_enabled {
return Json(serde_json::json!({ "enabled": false }));
}
let dbi = state.db.lock().await;
let queue_depth: i64 = dbi.db.query_row(
"SELECT COUNT(*) FROM ap_delivery_queue", [], |r| r.get(0)
).unwrap_or(0);
let total_outbound: i64 = dbi.db.query_row(
"SELECT COALESCE(SUM(count), 0) FROM ap_stats WHERE direction = 'outbound'",
[], |r| r.get(0),
).unwrap_or(0);
let total_inbound: i64 = dbi.db.query_row(
"SELECT COALESCE(SUM(count), 0) FROM ap_stats WHERE direction = 'inbound'",
[], |r| r.get(0),
).unwrap_or(0);
let peer_count: i64 = dbi.db.query_row(
"SELECT COUNT(*) FROM ap_peers", [], |r| r.get(0)
).unwrap_or(0);
let last_delivery: String = dbi.db.query_row(
"SELECT COALESCE(MAX(last_activity), '') FROM ap_stats WHERE direction = 'outbound'",
[], |r| r.get(0),
).unwrap_or_default();
let failed_deliveries: i64 = dbi.db.query_row(
"SELECT COUNT(*) FROM ap_delivery_queue WHERE attempts > 0", [], |r| r.get(0)
).unwrap_or(0);
let success_rate = if total_outbound + failed_deliveries > 0 {
(total_outbound as f64 / (total_outbound + failed_deliveries) as f64 * 100.0).round()
} else {
100.0
};
Json(serde_json::json!({
"enabled": true,
"frozen": crate::activitypub::is_frozen(),
"peers": peer_count,
"queue_depth": queue_depth,
"total_inbound": total_inbound,
"total_outbound": total_outbound,
"delivery_success_rate": success_rate,
"failed_in_queue": failed_deliveries,
"last_delivery": last_delivery,
}))
}
pub async fn receive_federated_leaderboard(
State(state): State<Arc<AppState>>,
headers: axum::http::HeaderMap,
body: String,
) -> Result<Json<serde_json::Value>, Response> {
// require HTTP signature from a known peer
let sig_header = headers.iter()
.find(|(k, _)| k.as_str().to_lowercase() == "signature")
.and_then(|(_, v)| v.to_str().ok())
.ok_or_else(|| (StatusCode::UNAUTHORIZED, "missing signature").into_response())?;
let key_domain = sig_header.split("keyId=\"")
.nth(1).and_then(|s| s.split('"').next())
.and_then(|uri| uri.split("//").nth(1))
.and_then(|s| s.split('/').next())
.ok_or_else(|| (StatusCode::BAD_REQUEST, "bad signature format").into_response())?
.to_string();
let public_pem = {
let dbi = state.db.lock().await;
let blocked: bool = dbi.db.query_row(
"SELECT COUNT(*) > 0 FROM ap_blocked WHERE domain = ?1",
[&key_domain], |r| r.get(0),
).unwrap_or(false);
if blocked {
return Err((StatusCode::FORBIDDEN, "blocked").into_response());
}
dbi.db.query_row(
"SELECT public_pem FROM ap_peers WHERE domain = ?1",
[&key_domain], |r| r.get::<_, String>(0),
).ok()
};
let public_pem = public_pem
.ok_or_else(|| (StatusCode::FORBIDDEN, "unknown peer").into_response())?;
let header_pairs: Vec<(String, String)> = headers.iter()
.filter_map(|(k, v)| Some((k.as_str().to_string(), v.to_str().ok()?.to_string())))
.collect();
crate::activitypub::signatures::verify_request(
&public_pem, "POST", "/api/federation/leaderboard",
&header_pairs, Some(body.as_bytes()),
).map_err(|e| (StatusCode::UNAUTHORIZED, format!("signature error: {}", e)).into_response())?;
let req: serde_json::Value = serde_json::from_str(&body)
.map_err(|_| (StatusCode::BAD_REQUEST, "invalid JSON").into_response())?;
let origin = req.get("origin_domain").and_then(|v| v.as_str())
.ok_or_else(|| (StatusCode::BAD_REQUEST, "missing origin_domain").into_response())?;
if origin != key_domain {
return Err((StatusCode::FORBIDDEN, "origin mismatch").into_response());
}
let date = req.get("puzzle_date").and_then(|v| v.as_str())
.ok_or_else(|| (StatusCode::BAD_REQUEST, "missing puzzle_date").into_response())?;
let preset = req.get("preset").and_then(|v| v.as_str())
.ok_or_else(|| (StatusCode::BAD_REQUEST, "missing preset").into_response())?;
let entries = req.get("entries").and_then(|v| v.as_array())
.ok_or_else(|| (StatusCode::BAD_REQUEST, "missing entries").into_response())?;
if entries.len() > 100 {
return Err((StatusCode::BAD_REQUEST, "too many entries").into_response());
}
let dbi = state.db.lock().await;
let mut count = 0;
for entry in entries {
let name = entry.get("name").and_then(|n| n.as_str()).unwrap_or("Anonymous");
let name = if name.len() > 100 { &name[..100] } else { name };
let time = entry.get("time").and_then(|t| t.as_i64()).unwrap_or(0);
if time > 0 && time < 172_800 {
db::insert_federated_leaderboard(&dbi.db, origin, date, preset, name, time);
count += 1;
}
}
Ok(Json(serde_json::json!({ "received": count })))
}
pub async fn get_puzzle_replies(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Json<serde_json::Value> {
let dbi = state.db.lock().await;
let replies = db::get_ap_replies(&dbi.db, &id, 50);
let items: Vec<serde_json::Value> = replies.into_iter().map(|(uri, name, content, published)| {
// turn actor URI into user@domain format
let author = uri.split("//").nth(1)
.map(|s| {
let domain = s.split('/').next().unwrap_or("");
let user = s.rsplit('/').next().unwrap_or("");
let user = user.trim_start_matches('@');
format!("{}@{}", user, domain)
})
.unwrap_or(uri);
serde_json::json!({
"author": author,
"name": name,
"content": content,
"published": published,
})
}).collect();
Json(serde_json::json!({ "replies": items }))
}
// wordlist packs
pub async fn list_packs(
State(state): State<Arc<AppState>>,
Query(params): Query<std::collections::HashMap<String, String>>,
) -> Json<serde_json::Value> {
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();
Json(serde_json::json!({ "packs": packs }))
}
pub async fn get_pack_detail(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
let dbi = state.db.lock().await;
let pack = db::get_pack(&dbi.db, &id)
.ok_or(AppError("pack not found".into()))?;
let tag_list: Vec<&str> = pack.tags.as_deref().unwrap_or("").split(',').filter(|s| !s.is_empty()).collect();
let parsed: serde_json::Value = serde_json::from_str(&pack.pack_data).unwrap_or_default();
Ok(Json(serde_json::json!({
"id": pack.id, "name": pack.name, "description": pack.description,
"author": pack.author_name, "tags": tag_list, "word_count": pack.word_count,
"downloads": pack.download_count, "rating": pack.rating_avg, "rating_count": pack.rating_count,
"words": parsed.get("words"), "created_at": pack.created_at,
})))
}
pub async fn create_pack(
State(state): State<Arc<AppState>>,
session: Session,
Json(req): Json<serde_json::Value>,
) -> Result<Response, AppError> {
check_banned(&state, &session.token).await?;
crate::security::check_rate_limit(&session.token, "pack_upload", 5, 3600)
.map_err(|s| AppError::with_status(s, "rate limit exceeded"))?;
let name = req.get("name").and_then(|v| v.as_str()).unwrap_or("");
if name.len() < 3 || name.len() > 100 {
return Err(AppError::with_status(StatusCode::BAD_REQUEST, "name must be 3-100 characters"));
}
let desc = req.get("description").and_then(|v| v.as_str());
let tags_arr = req.get("tags").and_then(|v| v.as_array());
let tags_str = tags_arr.map(|arr| {
arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(",")
});
let words = req.get("words").and_then(|v| v.as_array())
.ok_or(AppError::with_status(StatusCode::BAD_REQUEST, "missing words array"))?;
if words.is_empty() || words.len() > 5000 {
return Err(AppError::with_status(StatusCode::BAD_REQUEST, "words array must have 1-5000 entries"));
}
// validate each word
let mut cleaned_words = Vec::new();
for w in words {
let word = w.get("word").and_then(|v| v.as_str()).unwrap_or("");
let upper = word.to_uppercase();
if upper.len() < 3 || upper.len() > 21 {
return Err(AppError::with_status(StatusCode::BAD_REQUEST,
&format!("word '{}' must be 3-21 characters", word)));
}
if !upper.chars().all(|c| c.is_ascii_alphabetic()) {
return Err(AppError::with_status(StatusCode::BAD_REQUEST,
&format!("word '{}' must be alphabetic only", word)));
}
let clues = w.get("clues").and_then(|v| v.as_array());
if clues.is_none() || clues.unwrap().is_empty() {
return Err(AppError::with_status(StatusCode::BAD_REQUEST,
&format!("word '{}' must have at least one clue", word)));
}
for clue in clues.unwrap() {
let text = clue.get("text").and_then(|v| v.as_str()).unwrap_or("");
if text.len() < 3 || text.len() > 200 {
return Err(AppError::with_status(StatusCode::BAD_REQUEST, "each clue must be 3-200 characters"));
}
}
cleaned_words.push(serde_json::json!({
"word": upper,
"clues": clues.unwrap(),
}));
}
let pack_data = serde_json::json!({ "words": cleaned_words });
let word_count = cleaned_words.len() as i64;
let id = Uuid::new_v4().to_string();
let dbi = state.db.lock().await;
let (_, author_name) = db::get_or_create_session(&dbi.db, &session.token);
let success = db::create_pack(
&dbi.db, &id,
&crate::security::sanitize_html(name),
desc.map(|d| crate::security::sanitize_html(d)).as_deref(),
&session.token,
author_name.as_deref(),
tags_str.as_deref(),
word_count,
&pack_data.to_string(),
);
if !success {
return Err(AppError("failed to create pack".into()));
}
Ok(with_session_cookie(&session, Json(serde_json::json!({
"id": id, "word_count": word_count,
}))))
}
pub async fn delete_pack_handler(
State(state): State<Arc<AppState>>,
session: Session,
Path(id): Path<String>,
) -> Result<Response, AppError> {
let dbi = state.db.lock().await;
if db::delete_pack(&dbi.db, &id, &session.token) {
Ok(with_session_cookie(&session, Json(serde_json::json!({"deleted": true}))))
} else {
Err(AppError::with_status(StatusCode::FORBIDDEN, "not your pack or pack not found"))
}
}
pub async fn rate_pack_handler(
State(state): State<Arc<AppState>>,
session: Session,
Path(id): Path<String>,
Json(req): Json<serde_json::Value>,
) -> Result<Response, AppError> {
check_banned(&state, &session.token).await?;
let rating = req.get("rating").and_then(|v| v.as_i64())
.ok_or(AppError("missing rating".into()))?;
if !(1..=5).contains(&rating) {
return Err(AppError::with_status(StatusCode::BAD_REQUEST, "rating must be 1-5"));
}
let dbi = state.db.lock().await;
db::rate_pack(&dbi.db, &id, &session.token, rating);
Ok(with_session_cookie(&session, Json(serde_json::json!({"ok": true}))))
}
pub async fn editor_puzzle_analytics(
State(state): State<Arc<AppState>>,
session: Session,
Path(id): Path<String>,
) -> Result<Response, AppError> {
crate::security::check_rate_limit(&session.token, "analytics", 10, 60)
.map_err(|s| AppError::with_status(s, "rate limit exceeded"))?;
let dbi = state.db.lock().await;
match db::get_puzzle_analytics(&dbi.db, &id, &session.token) {
Some(data) => Ok(with_session_cookie(&session, Json(data))),
None => Err(AppError::with_status(StatusCode::NOT_FOUND, "not found or not your puzzle")),
}
}
pub async fn get_puzzle_archive(
State(state): State<Arc<AppState>>,
session: Session,
Query(params): Query<std::collections::HashMap<String, String>>,
) -> Result<Response, AppError> {
crate::security::check_rate_limit(&session.token, "archive", 10, 60)
.map_err(|s| AppError::with_status(s, "rate limit exceeded"))?;
let preset = params.get("preset").map(|s| s.as_str());
let page: usize = params.get("page").and_then(|s| s.parse().ok()).unwrap_or(0);
let dbi = state.db.lock().await;
let entries = db::get_daily_archive(&dbi.db, preset, &session.token, page, 20);
Ok(with_session_cookie(&session, Json(serde_json::json!({ "entries": entries }))))
}
pub async fn embed_page(
Path(short_id): Path<String>,
Query(params): Query<std::collections::HashMap<String, String>>,
) -> Response {
let theme = params.get("theme").map(|s| s.as_str()).unwrap_or("");
let timer = params.get("timer").map(|s| s.as_str()).unwrap_or("true");
let hints = params.get("hints").map(|s| s.as_str()).unwrap_or("true");
let html = format!(r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>cruciverb</title>
<link rel="stylesheet" href="/embed/embed.css">
</head>
<body>
<div id="root" data-puzzle-id="{}" data-theme="{}" data-timer="{}" data-hints="{}"></div>
<script type="module" src="/embed/embed.js"></script>
</body>
</html>"#, short_id, theme, timer, hints);
let mut resp = (StatusCode::OK, html).into_response();
let h = resp.headers_mut();
h.insert(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8".parse().unwrap());
h.insert(axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN, "*".parse().unwrap());
h.insert(axum::http::header::X_FRAME_OPTIONS, "ALLOWALL".parse().unwrap());
h.insert("content-security-policy", "frame-ancestors *".parse().unwrap());
resp
}
// streak freezes
pub async fn get_freezes(
State(state): State<Arc<AppState>>,
session: Session,
) -> Result<Response, AppError> {
let dbi = state.db.lock().await;
let freezes = db::get_streak_freezes(&dbi.db, &session.token);
Ok(with_session_cookie(&session, Json(serde_json::json!({ "freezes": freezes }))))
}
// daily challenges
pub async fn get_daily_challenge(
State(state): State<Arc<AppState>>,
session: Session,
Path((date, preset)): Path<(String, String)>,
) -> Result<Response, AppError> {
let dbi = state.db.lock().await;
let challenge = db::get_daily_challenge(&dbi.db, &date, &preset);
let completed = db::has_completed_challenge(&dbi.db, &session.token, &date, &preset);
Ok(with_session_cookie(&session, Json(serde_json::json!({
"challenge": challenge,
"completed": completed,
}))))
}
pub async fn complete_daily_challenge(
State(state): State<Arc<AppState>>,
session: Session,
Path((date, preset)): Path<(String, String)>,
Json(req): Json<serde_json::Value>,
) -> Result<Response, AppError> {
let challenge_type = req.get("challenge_type").and_then(|v| v.as_str())
.ok_or(AppError("missing challenge_type".into()))?;
let dbi = state.db.lock().await;
// verify the challenge against the server-side solve record where that is
// possible (times, hints, hour of the day); challenges that only the client
// can observe (deletes, pauses, letter streaks) are stored as attested
let mut verified = false;
let mut client_attested = true;
if let Some((elapsed, hints, completed_at)) =
db::get_challenge_solve_info(&dbi.db, &session.token, &date, &preset)
{
verified = challenge_met(challenge_type, elapsed, hints, &completed_at, &preset);
client_attested = !verified;
}
db::complete_challenge(
&dbi.db,
&session.token,
&date,
&preset,
challenge_type,
verified,
client_attested,
);
Ok(with_session_cookie(&session, Json(serde_json::json!({ "ok": true, "verified": verified }))))
}
fn challenge_met(challenge: &str, elapsed: i64, hints: i64, completed_at: &str, preset: &str) -> bool {
use chrono::Timelike;
let hour = chrono::DateTime::parse_from_rfc3339(completed_at)
.map(|t| t.hour())
.unwrap_or(0);
let speed_limit: i64 = match preset {
"glyph" => 45,
"rune" => 60,
"scroll" => 300,
"codex" => 1200,
"grimoire" => 2700,
_ => 300,
};
match challenge {
"purist" => hints == 0,
"minimalist" => hints > 0 && hints < 3,
"night_owl" => hour < 5,
"early_bird" => (5..8).contains(&hour),
"rush_hour" => (12..13).contains(&hour),
"night_shift" => (20..24).contains(&hour),
"afternoon_tea" => (15..17).contains(&hour),
"blitz" => elapsed < 60,
"sunday_stroll" => elapsed >= 180,
"overtime" => elapsed >= 600,
"lucky_seven" => (elapsed - 420).abs() <= 10,
"even_steven" => elapsed % 2 == 0,
"speed_demon" => elapsed < speed_limit,
_ => false,
}
}
pub async fn get_today_challenges(
State(state): State<Arc<AppState>>,
session: Session,
) -> Result<Response, AppError> {
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
let dbi = state.db.lock().await;
let completions = db::get_today_challenge_completions(&dbi.db, &session.token, &today);
Ok(with_session_cookie(&session, Json(serde_json::json!(completions))))
}
#[cfg(test)]
mod challenge_tests {
use super::challenge_met;
#[test]
fn time_and_hint_challenges_are_checked() {
assert!(challenge_met("purist", 100, 0, "2026-01-01T10:00:00Z", "scroll"));
assert!(!challenge_met("purist", 100, 1, "2026-01-01T10:00:00Z", "scroll"));
assert!(challenge_met("minimalist", 100, 2, "2026-01-01T10:00:00Z", "scroll"));
assert!(!challenge_met("minimalist", 100, 0, "2026-01-01T10:00:00Z", "scroll"));
assert!(challenge_met("night_owl", 100, 0, "2026-01-01T03:00:00Z", "scroll"));
assert!(!challenge_met("night_owl", 100, 0, "2026-01-01T10:00:00Z", "scroll"));
assert!(challenge_met("blitz", 59, 0, "2026-01-01T10:00:00Z", "scroll"));
assert!(!challenge_met("blitz", 61, 0, "2026-01-01T10:00:00Z", "scroll"));
assert!(challenge_met("speed_demon", 40, 0, "2026-01-01T10:00:00Z", "glyph"));
assert!(!challenge_met("speed_demon", 50, 0, "2026-01-01T10:00:00Z", "glyph"));
assert!(!challenge_met("surgeon", 100, 0, "2026-01-01T10:00:00Z", "scroll"));
}
}