3678 lines
148 KiB
Rust
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(¬e_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"));
|
|
}
|
|
}
|