server bootstrap and state

This commit is contained in:
2025-09-06 18:55:21 +03:00
parent cdc8c366e1
commit 714f0a8f52
2 changed files with 764 additions and 0 deletions
+606
View File
@@ -0,0 +1,606 @@
mod activitypub;
mod admin;
mod achievements;
mod api;
mod api_v1;
mod db;
mod receipts;
mod security;
mod feeds;
mod rooms;
mod routes;
mod session;
mod state;
mod webauthn;
mod webhooks;
use std::path::PathBuf;
use std::net::SocketAddr;
use axum::Router;
use axum::routing::{get, post};
use axum::middleware;
use axum::extract::DefaultBodyLimit;
use tower_http::cors::CorsLayer;
use tower_http::services::{ServeDir, ServeFile};
use tower_http::timeout::TimeoutLayer;
use cruciverb_core::clue::seed::load_clue_database;
use cruciverb_core::dict::Dictionary;
use crate::state::AppState;
async fn daily_pregenerator(state: std::sync::Arc<state::AppState>) {
use cruciverb_core::config::{DifficultyPreset, GenerationConfig};
let preset_configs: Vec<(&str, u8, GenerationConfig)> = vec![
("glyph", 0, 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)),
];
// small delay to let the server finish starting
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
let mut generated_this_hour = 0u32;
loop {
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
let tomorrow = (chrono::Utc::now() + chrono::Duration::days(1)).format("%Y-%m-%d").to_string();
// generate today's daily image if it doesn't exist yet
{
let data_dir = std::env::var("DATA_DIR").unwrap_or_else(|_| "../data".into());
let today_image = std::path::Path::new(&data_dir).join(format!("daily-image-{}.jpg", today));
if !today_image.exists() {
if let Ok(mut child) = tokio::process::Command::new("node")
.arg("/app/scripts/generate-daily-image.js")
.spawn()
{
tracing::info!("generating daily image for {}...", today);
let _ = tokio::time::timeout(
std::time::Duration::from_secs(30),
child.wait(),
).await;
}
// copy dated file to the canonical name for the /daily-image endpoint
let canonical = std::path::Path::new(&data_dir).join("daily-image.jpg");
if today_image.exists() {
std::fs::copy(&today_image, &canonical).ok();
}
}
}
// publish today's AP post (image is ready now)
if state.ap.is_some() {
let already_posted = {
let dbi = state.db.lock().await;
let post_id = format!("https://{}/ap/daily/{}", state.ap.as_ref().map(|a| a.domain.as_str()).unwrap_or(""), &today);
dbi.db.query_row("SELECT COUNT(*) > 0 FROM ap_outbox WHERE id LIKE ?1 || '%'", [&post_id], |r| r.get::<_, bool>(0)).unwrap_or(false)
};
if !already_posted {
activitypub::outbox::publish_daily_puzzle(&state, &today).await;
activitypub::outbox::publish_community_digest(&state, &today).await;
}
}
for date in [&today, &tomorrow] {
for (preset, diff_level, base_config) in &preset_configs {
// serialize generation per (date, preset) with the API path,
// then re-check existence under the lock
let _gen_lock = state.daily_gen_lock(date, preset).await;
// check if already exists
let exists = {
let dbi = state.db.lock().await;
db::get_daily_puzzle(&dbi.db, date, preset).is_some()
};
if exists { continue; }
// rate limit
if generated_this_hour >= 20 {
tracing::warn!("daily pre-generator rate limit hit, waiting");
break;
}
// acquire semaphore so we don't starve user requests
let permit = match state.generation_semaphore.acquire().await {
Ok(p) => p,
Err(_) => continue,
};
tracing::info!("pre-generating {} daily for {}", preset, date);
let seed = routes::daily_seed(date, preset, &state.daily_secret);
let mut config = base_config.clone();
config.seed = Some(seed);
let clue_db = state.clue_db.clone();
let word_diffs = state.word_diffs.clone();
let diff = *diff_level;
let mut dict = (*state.dict).clone();
dict.apply_difficulty(&word_diffs, diff);
let wq = state.word_quality.read().await.clone();
if !wq.is_empty() { dict.apply_quality(&wq); }
config.staleness = state.build_staleness_penalties().await;
let result = tokio::task::spawn_blocking(move || {
cruciverb_core::puzzle::generate_full_puzzle(&config, &dict, &clue_db, diff)
}).await;
drop(permit);
match result {
Ok(Ok(puzzle)) => {
let used_words: Vec<String> = puzzle.words.iter().map(|w| w.word.clone()).collect();
state.record_puzzle_words(&used_words).await;
let id = uuid::Uuid::new_v4();
if let Ok(json) = puzzle.to_json() {
let dbi = state.db.lock().await;
let short_id = db::store_puzzle(&dbi.db, &id.to_string(), &json,
puzzle.width, puzzle.height, diff,
Some(date), Some(preset));
tracing::info!("pre-generated {} for {} -> /p/{}", preset, date, short_id);
}
generated_this_hour += 1;
}
Ok(Err(e)) => {
tracing::warn!("failed to pre-generate {} for {}: {}", preset, date, e);
}
Err(e) => {
tracing::warn!("pre-generation task failed for {} {}: {}", preset, date, e);
}
}
}
}
generated_this_hour = 0;
tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
}
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
let port: u16 = std::env::var("PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(8080);
let data_dir = std::env::var("DATA_DIR")
.unwrap_or_else(|_| "../data".into());
let wordlist = std::env::var("WORDLIST")
.unwrap_or_else(|_| format!("{}/wordlists/enable.txt", data_dir));
let static_dir = std::env::var("STATIC_DIR")
.unwrap_or_else(|_| "../frontend/dist".into());
let banned_words = PathBuf::from(&data_dir).join("banned_words.txt");
tracing::info!("loading dictionary from {}...", wordlist);
let mut dict = Dictionary::load(
&PathBuf::from(&wordlist),
Some(&banned_words),
3,
).expect("failed to load dictionary");
tracing::info!("loaded {} words", dict.word_count());
let clue_db = load_clue_database(&PathBuf::from(&data_dir))
.unwrap_or_else(|_| cruciverb_core::clue::ClueDatabase::empty());
tracing::info!("loaded {} clue entries", clue_db.word_count());
let clue_words = clue_db.clueable_words();
tracing::info!("boosting {} words with clue coverage", clue_words.len());
dict.boost_from_clue_words(&clue_words);
let word_diffs = clue_db.word_difficulties();
tracing::info!("loaded {} word difficulty scores", word_diffs.len());
// cryptic clues live in the same clues.db with source='cryptic'
let cryptic_path = PathBuf::from(&data_dir).join("clues.db");
let cryptic_db = if cryptic_path.exists() {
match cruciverb_core::clue::CrypticClueDb::load(&cryptic_path) {
Ok(db) => {
tracing::info!("loaded {} cryptic clue entries", db.word_count());
Some(db)
}
Err(e) => {
tracing::warn!("failed to load cryptic clues: {}", e);
None
}
}
} else {
None
};
// download PDF fonts if missing
let fonts_dir = PathBuf::from(&data_dir).join("fonts");
std::fs::create_dir_all(&fonts_dir).ok();
for (filename, css_family, weight) in [
("PlayfairDisplay-Bold.ttf", "Playfair+Display", "700"),
("DMSans-Regular.ttf", "DM+Sans", "400"),
] {
let path = fonts_dir.join(filename);
if !path.exists() {
tracing::info!("downloading font {}...", filename);
if let Ok(font_data) = download_font(css_family, weight) {
std::fs::write(&path, &font_data).ok();
tracing::info!("saved {} ({} bytes)", filename, font_data.len());
}
}
}
let db_path = PathBuf::from(&data_dir).join("cruciverb.db");
tracing::info!("opening database at {}...", db_path.display());
let db = db::init_db(&db_path);
// seed default ActivityPub profile config
if db::get_server_config(&db, "ap_actor_name").is_none() {
db::set_server_config(&db, "ap_actor_name", "crossword");
}
if db::get_server_config(&db, "ap_bio").is_none() {
db::set_server_config(&db, "ap_bio", "Daily crossword puzzles in five sizes, four game modes, cryptic clues, multiplayer rooms, party games, a built-in puzzle editor for constructors, and a clue trainer for the stubborn. Self-hosted on whatever you call a server. Federated via ActivityPub because crosswords are social infrastructure. CC0 because charging for grid squares is morally dubious.");
}
let rp_id = std::env::var("RP_ID").unwrap_or_else(|_| "localhost".into());
let rp_origin = std::env::var("RP_ORIGIN")
.unwrap_or_else(|_| format!("http://localhost:{}", port));
tracing::info!("WebAuthn RP: {} ({})", rp_id, rp_origin);
// ActivityPub federation
let ap_state = if let Ok(ap_domain) = std::env::var("AP_DOMAIN") {
let actor_name = std::env::var("AP_ACTOR_NAME").unwrap_or_else(|_| "cruciverb".into());
tracing::info!("ActivityPub enabled: @{}@{}", actor_name, ap_domain);
let ap_keys = activitypub::keys::load_or_generate(&db);
Some(activitypub::ApState::new(ap_domain, actor_name, ap_keys))
} else {
None
};
// load or generate instance secret for daily puzzle seeds
let daily_secret = {
if let Some(s) = db::get_server_config(&db, "daily_secret") {
s
} else {
let secret = format!("{:016x}{:016x}", rand::random::<u64>(), rand::random::<u64>());
db::set_server_config(&db, "daily_secret", &secret);
tracing::info!("generated new daily puzzle secret");
secret
}
};
let content_blocklist = if banned_words.exists() {
cruciverb_core::dict::blocklist::Blocklist::load(&banned_words).unwrap_or_else(|_| cruciverb_core::dict::blocklist::Blocklist::empty())
} else {
cruciverb_core::dict::blocklist::Blocklist::empty()
};
// load Webster's definitions for word-of-the-day
let defs_path = PathBuf::from(&data_dir).join("definitions.json");
let definitions: std::collections::HashMap<String, String> = if defs_path.exists() {
let raw = std::fs::read_to_string(&defs_path).unwrap_or_default();
serde_json::from_str(&raw).unwrap_or_default()
} else {
tracing::warn!("definitions.json not found, word-of-the-day will be empty");
std::collections::HashMap::new()
};
tracing::info!("loaded {} dictionary definitions", definitions.len());
// build crossword-relevant word list for wotd (words in both definitions + clue db, 3-8 letters, alphabetic)
let clue_words_set = clue_db.all_words();
let mut wotd_words: Vec<String> = definitions.keys()
.filter(|w| {
let upper = w.to_uppercase();
upper.len() >= 3 && upper.len() <= 8
&& upper.chars().all(|c| c.is_alphabetic())
&& clue_words_set.contains(&upper)
})
.map(|w| w.to_uppercase())
.collect();
wotd_words.sort();
wotd_words.dedup();
tracing::info!("word-of-the-day pool: {} words", wotd_words.len());
let state = AppState::new(dict, clue_db, cryptic_db, word_diffs, db, db_path.to_string_lossy().into_owned(), rp_id, rp_origin, ap_state, daily_secret, content_blocklist, definitions, wotd_words);
// load quality scores from ratings
{
let dbi = state.db.lock().await;
let wq = db::get_word_quality_scores(&dbi.db);
let cq = db::get_clue_quality_scores(&dbi.db);
if !wq.is_empty() { tracing::info!("loaded {} word quality scores", wq.len()); }
if !cq.is_empty() { tracing::info!("loaded {} clue quality scores", cq.len()); }
*state.word_quality.write().await = wq;
*state.clue_quality.write().await = cq;
}
// spawn daily puzzle pre-generator
{
let state = state.clone();
tokio::spawn(async move {
daily_pregenerator(state).await;
});
}
// room cleanup
rooms::start_room_cleanup(state.rooms.clone());
let api = Router::new()
.route("/api/puzzles/generate", post(routes::generate_puzzle))
.route("/api/puzzles/daily/{date}", get(routes::get_daily_puzzle))
.route("/api/puzzles/daily/{date}/{preset}", get(routes::get_daily_preset))
.route("/api/puzzles/daily/{date}/{preset}/challenge", get(routes::get_daily_challenge))
.route("/api/puzzles/daily/{date}/{preset}/challenge/complete", post(routes::complete_daily_challenge))
.route("/api/puzzles/s/{short_id}", get(routes::get_puzzle_by_short_id))
.route("/api/puzzles/{id}", get(routes::get_puzzle))
.route("/api/puzzles/{id}/validate", post(routes::validate_solution))
.route("/api/puzzles/{id}/hint", post(routes::get_hint))
.route("/api/puzzles/{id}/progress", get(routes::load_progress).put(routes::save_progress))
.route("/api/me", get(routes::get_me).put(routes::update_me))
.route("/api/me/freezes", get(routes::get_freezes))
.route("/api/me/stats", get(routes::get_stats))
.route("/api/me/settings", get(routes::get_settings).put(routes::save_settings))
.route("/api/me/challenges/today", get(routes::get_today_challenges))
.route("/api/me/achievements", get(routes::get_achievements))
.route("/api/achievements", get(routes::list_achievements))
.route("/api/leaderboards/{id}", get(routes::get_leaderboard))
.route("/api/leaderboards/daily/{date}/{preset}", get(routes::get_daily_leaderboard))
.route("/api/leaderboards/federated/{date}/{preset}", get(routes::get_federated_leaderboard))
.route("/api/leaderboards/alltime/{preset}", get(routes::get_alltime_leaderboard))
.route("/api/leaderboards/weekly/{preset}", get(routes::get_weekly_leaderboard))
.route("/api/leaderboards/streaks", get(routes::get_streak_leaderboard))
.route("/api/leaderboards/completionist", get(routes::get_completionist_leaderboard))
.route("/api/leaderboards/clean/{date}/{preset}", get(routes::get_clean_leaderboard))
.route("/api/me/stats/detailed", get(routes::get_detailed_stats))
.route("/api/me/analytics", get(routes::get_analytics))
.route("/api/me/history", get(routes::get_history))
.route("/api/puzzles/{id}/percentile/{time}", get(routes::get_solve_percentile))
.route("/api/themes", get(routes::list_themes))
.route("/api/editor/autofill", post(routes::editor_autofill))
.route("/api/editor/check-words", post(routes::editor_check_words))
.route("/api/editor/clue-suggestions/{word}", get(routes::editor_clue_suggestions))
.route("/api/editor/puzzles", post(routes::editor_save).get(routes::editor_list))
.route("/api/editor/puzzles/{id}", get(routes::editor_load).delete(routes::editor_delete))
.route("/api/editor/puzzles/{id}/publish", post(routes::editor_publish))
.route("/api/editor/puzzles/{id}/analytics", get(routes::editor_puzzle_analytics))
.route("/api/editor/puzzles/{id}/export", get(routes::editor_export))
.route("/api/editor/export-all", get(routes::editor_export_all))
.route("/api/editor/import", post(routes::editor_import))
.route("/api/puzzles/archive", get(routes::get_puzzle_archive))
.route("/api/puzzles/import", post(routes::import_puzzle))
.route("/api/admin/config", get(admin::admin_config))
.route("/api/admin/config/all", get(admin::admin_full_config).put(admin::update_config))
.route("/api/admin/login", post(admin::admin_login))
.route("/api/admin/status", get(admin::admin_status))
.route("/api/admin/setup", post(admin::admin_setup))
.route("/api/admin/stats", get(admin::stats))
.route("/api/admin/sessions", get(admin::list_sessions))
.route("/api/admin/sessions/{token}", axum::routing::delete(admin::delete_session))
.route("/api/admin/users/{token}/ban", post(admin::ban_user))
.route("/api/admin/users/{token}/unban", post(admin::unban_user))
.route("/api/admin/sessions/purge", post(admin::purge_old_sessions))
.route("/api/admin/puzzles", get(admin::list_all_puzzles))
.route("/api/admin/puzzles/{id}", axum::routing::delete(admin::delete_puzzle))
.route("/api/admin/clues/pending", get(admin::list_pending_clues))
.route("/api/admin/clues/{id}/moderate", post(admin::moderate_clue))
.route("/api/admin/achievements", get(admin::achievement_stats))
.route("/api/admin/webhooks/{id}/test", post(admin::test_webhook))
.route("/api/admin/peers", get(admin::list_peers))
.route("/api/admin/peers/connect", post(admin::connect_peer))
.route("/api/admin/peers/{domain}", axum::routing::delete(admin::disconnect_peer))
.route("/api/admin/block", post(admin::block_instance))
.route("/api/admin/blocklist/export", get(admin::export_blocklist))
.route("/api/admin/blocklist/import", post(admin::import_blocklist))
.route("/api/admin/unblock/{domain}", post(admin::unblock_instance))
.route("/api/admin/audit-log", get(admin::audit_log))
.route("/api/admin/blocked-words", post(admin::add_blocked_word))
.route("/api/admin/followers/pending", get(admin::list_pending_followers))
.route("/api/admin/followers/approve", post(admin::approve_follower))
.route("/api/admin/followers/reject", post(admin::reject_follower))
.route("/api/admin/federation/freeze", post(admin::federation_freeze))
.route("/api/admin/outbox", get(admin::list_outbox))
.route("/api/admin/outbox/clear", post(admin::clear_outbox))
.route("/api/admin/outbox/{idx}", axum::routing::delete(admin::delete_outbox_item))
.route("/api/admin/outbox/delete-remote", post(admin::delete_remote_post))
.route("/api/admin/federation/unfreeze", post(admin::federation_unfreeze))
.route("/api/admin/federation/status", get(admin::federation_status))
.route("/api/admin/avatar", post(admin::upload_avatar))
.route("/api/admin/banner", post(admin::upload_banner))
.route("/avatar", get(admin::serve_avatar))
.route("/banner", get(admin::serve_banner))
.route("/api/webhooks", get(routes::list_webhooks).post(routes::create_webhook))
.route("/api/webhooks/{id}", axum::routing::delete(routes::delete_webhook))
.route("/api/packs", get(routes::list_packs).post(routes::create_pack))
.route("/api/packs/{id}", get(routes::get_pack_detail).delete(routes::delete_pack_handler))
.route("/api/packs/{id}/rate", post(routes::rate_pack_handler))
.route("/api/community-puzzles", get(routes::browse_community_puzzles))
.route("/api/federated-puzzles", get(routes::list_federated_puzzles))
.route("/api/community-puzzles/{id}", get(routes::play_community_puzzle))
.route("/api/puzzles/{id}/rate", post(routes::rate_puzzle))
.route("/api/puzzles/{id}/rating", get(routes::get_rating))
.route("/api/puzzles/{id}/replies", get(routes::get_puzzle_replies))
.route("/api/puzzles/daily/{date}/ratings", get(routes::get_daily_ratings))
.route("/api/clues/community", post(routes::submit_community_clue))
.route("/api/clues/community/{word}", get(routes::get_community_clues))
.route("/api/clues/community/vote/{id}", post(routes::vote_community_clue))
.route("/api/me/submissions", get(routes::my_submissions))
.route("/api/me/submissions/{id}", axum::routing::delete(routes::delete_submission))
.route("/api/passkey/register/start", post(routes::passkey_register_start))
.route("/api/passkey/register/finish", post(routes::passkey_register_finish))
.route("/api/passkey/authenticate/start", post(routes::passkey_auth_start))
.route("/api/passkey/authenticate/finish", post(routes::passkey_auth_finish))
.route("/api/me/passkeys", get(routes::list_passkeys))
.route("/api/me/sessions", get(routes::get_session_info))
.route("/api/me/account", axum::routing::delete(routes::delete_account))
.route("/api/me/export", get(routes::export_data))
.route("/api/me/import", post(routes::import_data))
.route("/api/me/migrate", post(routes::migrate_account))
.route("/api/me/blocklist", get(routes::export_user_blocklist))
.route("/api/me/blocklist/import", post(routes::import_user_blocklist))
.route("/api/rooms", post(rooms::create_room))
.route("/api/rooms/{code}", get(rooms::get_room))
.route("/api/rooms/{code}/ws", get(rooms::ws_upgrade))
.route("/api/receipts/generate", post(receipts::generate_receipt_handler))
.route("/api/receipts/claim", post(receipts::claim_receipt_handler))
.route("/api/receipts/{id}", get(receipts::get_receipt_handler))
.route("/api/word-of-the-day", get(routes::word_of_the_day))
.route("/api/trainer/clue", get(routes::trainer_clue))
.route("/api/trainer/check", post(routes::trainer_check))
.route("/api/community-puzzles/today", get(routes::community_puzzles_today))
.route("/api/internal/daily-image", post(routes::upload_daily_image))
.route("/daily-image", get(routes::serve_daily_image))
.route("/ap/daily/{*rest}", get(routes::ap_post_permalink))
.route("/ap/community-digest/{*rest}", get(routes::ap_post_permalink))
.route("/post/{date}", get(routes::daily_post_page))
.route("/api/report", post(routes::submit_report))
.route("/api/admin/reports", get(admin::list_reports))
.route("/api/admin/reports/{id}/resolve", post(admin::resolve_report))
.route("/api/admin/ap-humor-defaults", get(admin::ap_humor_defaults))
.route("/api/admin/community-puzzles", get(admin::list_community_puzzles))
.route("/api/admin/community-puzzles/{id}", axum::routing::delete(admin::delete_community_puzzle))
.route("/api/admin/community-puzzles/batch-delete", post(admin::batch_delete_community_puzzles))
.route("/api/admin/puzzles/filtered", get(admin::list_all_puzzles_filtered))
.route("/api/admin/puzzles/batch-delete", post(admin::batch_delete_puzzles))
.route("/api/puzzles/{id}/export/puz", get(routes::export_puz))
.route("/api/puzzles/{id}/export/ipuz", get(routes::export_ipuz))
.route("/api/puzzles/{id}/export/pdf", get(routes::export_pdf))
.route("/feeds/daily.atom", get(feeds::atom_feed))
.route("/feeds/daily.rss", get(feeds::rss_feed))
.route("/feeds/daily.json", get(feeds::json_feed))
.route("/about", get(routes::about_page))
.route("/api/about", get(routes::about_page))
.route("/api/moderation-log", get(routes::public_moderation_log))
.route("/api/federation/health", get(routes::federation_health))
.route("/api/federation/leaderboard", post(routes::receive_federated_leaderboard))
.route("/health", get(health))
.route("/api/health", get(health))
// ActivityPub / discovery endpoints
.route("/.well-known/webfinger", get(activitypub::actor::webfinger))
.route("/.well-known/nodeinfo", get(activitypub::nodeinfo::well_known))
.route("/nodeinfo/2.1", get(activitypub::nodeinfo::nodeinfo))
.route("/@{name}", get(activitypub::actor::actor_document))
.route("/@{name}/inbox", post(activitypub::inbox::inbox))
.route("/@{name}/outbox", get(activitypub::outbox::outbox))
.route("/@{name}/followers", get(activitypub::actor::followers))
.route("/inbox", post(activitypub::inbox::inbox))
.route("/e/{short_id}", get(routes::embed_page))
.route("/api/v1/openapi.json", get(api_v1::v1_openapi))
.route("/api/docs", get(api_v1::swagger_ui))
.with_state(state.clone());
// v1 public API
let v1 = Router::new()
.route("/puzzles/generate", post(api_v1::v1_generate))
.route("/puzzles/{id}", get(api_v1::v1_get_puzzle))
.route("/puzzles/{id}/solution", get(api_v1::v1_get_solution))
.route("/puzzles/{id}/validate", post(api_v1::v1_validate))
.route("/puzzles/{id}/hint", post(api_v1::v1_hint))
.route("/puzzles/{id}/export/{fmt}", get(api_v1::v1_export))
.route("/daily/{date}", get(api_v1::v1_get_daily))
.route("/daily/{date}/{preset}", get(api_v1::v1_get_daily_preset))
.route("/community/puzzles", get(api_v1::v1_community_puzzles))
.route("/community/clues/{word}", get(api_v1::v1_community_clues))
.route("/community/clues", post(api_v1::v1_submit_clue))
.route("/community/clues/{id}/vote", post(api_v1::v1_vote_clue))
.route("/packs", get(api_v1::v1_list_packs).post(api_v1::v1_create_pack))
.route("/packs/{id}", get(api_v1::v1_get_pack))
.route("/me", get(api_v1::v1_me))
.route("/me/achievements", get(api_v1::v1_achievements))
.route("/me/stats", get(api_v1::v1_stats))
.route("/instance", get(api_v1::v1_instance))
.route("/themes", get(api_v1::v1_themes))
.route("/keys", post(api_v1::v1_create_key).get(api_v1::v1_list_keys))
.route("/keys/{id}", axum::routing::delete(api_v1::v1_delete_key))
.with_state(state.clone());
let api = api.nest("/api/v1", v1);
let static_path = PathBuf::from(&static_dir);
let embed_path = static_path.join("embed");
let app = if static_path.exists() {
let index = static_path.join("index.html");
let mut router = api.nest_service("/embed", ServeDir::new(&embed_path));
router = router.fallback_service(ServeDir::new(&static_dir).fallback(ServeFile::new(index)));
router
} else {
tracing::warn!("static dir {} not found, serving API only", static_dir);
api.into()
};
// security layers
let cors = if std::env::var("CORS_ORIGIN").is_ok() {
let origin = std::env::var("CORS_ORIGIN").unwrap();
CorsLayer::new()
.allow_origin(origin.parse::<axum::http::HeaderValue>().unwrap())
.allow_methods([axum::http::Method::GET, axum::http::Method::POST, axum::http::Method::PUT, axum::http::Method::DELETE])
.allow_headers([axum::http::header::CONTENT_TYPE, axum::http::header::HeaderName::from_static("x-admin-session")])
} else {
tracing::warn!("CORS_ORIGIN not set - using permissive CORS (set CORS_ORIGIN for production)");
CorsLayer::permissive()
};
let app = app
.layer(TimeoutLayer::with_status_code(axum::http::StatusCode::REQUEST_TIMEOUT, std::time::Duration::from_secs(120)))
.layer(cors)
.layer(DefaultBodyLimit::max(2 * 1024 * 1024))
.layer(middleware::from_fn(security::security_headers));
// start ActivityPub workers
activitypub::deliver::start_delivery_worker(state.clone());
activitypub::discover::start_discovery_worker(state.clone());
// #10: ephemeral session cleanup worker - runs every hour
{
let cleanup_state = state.clone();
tokio::spawn(async move {
loop {
tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
let dbi = cleanup_state.db.lock().await;
db::cleanup_ephemeral_sessions(&dbi.db);
db::cleanup_expired_receipts(&dbi.db);
}
});
}
let addr = SocketAddr::from(([0, 0, 0, 0], port));
tracing::info!("listening on {}", addr);
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await.unwrap();
}
async fn health() -> axum::Json<serde_json::Value> {
axum::Json(serde_json::json!({"status": "ok"}))
}
fn download_font(family: &str, weight: &str) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
// get the font URL from Google Fonts CSS API
let css_url = format!(
"https://fonts.googleapis.com/css2?family={}:wght@{}",
family, weight
);
let client = reqwest::blocking::Client::builder()
.user_agent("Mozilla/5.0")
.build()?;
let css = client.get(&css_url).send()?.text()?;
// extract the TTF url from the CSS
let url = css.lines()
.find(|l| l.contains("src: url(") && l.contains(".ttf"))
.and_then(|l| {
let start = l.find("url(")? + 4;
let end = l[start..].find(')')? + start;
Some(l[start..end].to_string())
})
.ok_or("font URL not found in CSS")?;
let data = client.get(&url).send()?.bytes()?.to_vec();
Ok(data)
}
+158
View File
@@ -0,0 +1,158 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Instant;
use rusqlite::Connection;
use tokio::sync::{Mutex, RwLock, Semaphore};
use uuid::Uuid;
use cruciverb_core::clue::{ClueDatabase, CrypticClueDb};
use crate::activitypub::ApState;
use cruciverb_core::dict::Dictionary;
use cruciverb_core::puzzle::Puzzle;
const STALENESS_WINDOW: u64 = 50;
const PUZZLE_CACHE_MAX: usize = 500;
const PUZZLE_CACHE_EXPIRY_SECS: u64 = 3600;
pub struct AppStateInner {
pub db: Connection,
}
pub struct AppState {
pub dict: Arc<Dictionary>,
pub clue_db: Arc<ClueDatabase>,
pub cryptic_db: Option<Arc<CrypticClueDb>>,
pub word_diffs: Arc<HashMap<String, f64>>,
pub puzzles: RwLock<HashMap<Uuid, (Puzzle, Instant)>>,
pub db: Mutex<AppStateInner>,
pub db_path: String,
pub word_quality: RwLock<HashMap<String, f64>>,
pub clue_quality: RwLock<HashMap<(String, String), f64>>,
pub rp_id: String,
pub rp_origin: String,
// maps challenge -> (session_token, timestamp)
pub reg_challenges: Mutex<HashMap<String, (String, i64)>>,
// maps challenge -> timestamp (no session needed for auth)
pub auth_challenges: Mutex<HashMap<String, i64>>,
pub generation_semaphore: Semaphore,
pub ap: Option<Arc<ApState>>,
pub daily_secret: String,
pub rooms: crate::rooms::Rooms,
pub blocklist: std::sync::RwLock<cruciverb_core::dict::blocklist::Blocklist>,
pub definitions: Arc<HashMap<String, String>>,
pub wotd_words: Arc<Vec<String>>,
/// Per-(date, preset) locks so the pre-generator and the API cannot both
/// generate the same daily puzzle at once.
pub daily_gen_locks: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
/// Tracks the generation number when each word was last used in a puzzle.
pub word_staleness: RwLock<HashMap<String, u64>>,
/// Monotonic counter incremented after each puzzle generation.
pub generation_counter: AtomicU64,
}
impl AppState {
pub async fn cache_puzzle(&self, id: Uuid, puzzle: Puzzle) {
let mut cache = self.puzzles.write().await;
cache.insert(id, (puzzle, Instant::now()));
if cache.len() > PUZZLE_CACHE_MAX {
let cutoff = Instant::now() - std::time::Duration::from_secs(PUZZLE_CACHE_EXPIRY_SECS);
cache.retain(|_, (_, ts)| *ts > cutoff);
}
if cache.len() > PUZZLE_CACHE_MAX {
let mut entries: Vec<(Uuid, Instant)> = cache.iter().map(|(k, (_, ts))| (*k, *ts)).collect();
entries.sort_by_key(|(_, ts)| *ts);
for (k, _) in entries.iter().take(100) {
cache.remove(k);
}
}
}
/// Build a staleness penalty map for the solver.
/// Returns word -> penalty where penalty is 0.3 (just used) to 1.0 (old/unused).
pub async fn build_staleness_penalties(&self) -> Option<Arc<HashMap<String, f64>>> {
let gen = self.generation_counter.load(Ordering::Relaxed);
let staleness = self.word_staleness.read().await;
if staleness.is_empty() {
return None;
}
let mut penalties: HashMap<String, f64> = HashMap::new();
for (word, &last_gen) in staleness.iter() {
let age = gen.saturating_sub(last_gen);
if age < STALENESS_WINDOW {
// 0.3 for just-used words, linearly decaying to 1.0
let decay = age as f64 / STALENESS_WINDOW as f64;
penalties.insert(word.clone(), 0.3 + 0.7 * decay);
}
}
if penalties.is_empty() { None } else { Some(Arc::new(penalties)) }
}
/// Record words from a generated puzzle so future puzzles deprioritize them.
pub async fn record_puzzle_words(&self, words: &[String]) {
let gen = self.generation_counter.fetch_add(1, Ordering::Relaxed);
let mut staleness = self.word_staleness.write().await;
for word in words {
staleness.insert(word.clone(), gen);
}
// prune old entries periodically
if staleness.len() > 10_000 {
let cutoff = gen.saturating_sub(STALENESS_WINDOW);
staleness.retain(|_, v| *v >= cutoff);
}
}
/// Returns a per-(date, preset) async lock that serializes daily
/// generation between the pre-generator and on-demand API requests.
pub async fn daily_gen_lock(&self, date: &str, preset: &str) -> Arc<tokio::sync::Mutex<()>> {
let mut locks = self.daily_gen_locks.lock().await;
locks
.entry(format!("{}:{}", date, preset))
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
.clone()
}
pub fn new(
dict: Dictionary,
clue_db: ClueDatabase,
cryptic_db: Option<CrypticClueDb>,
word_diffs: HashMap<String, f64>,
db: Connection,
db_path: String,
rp_id: String,
rp_origin: String,
ap: Option<ApState>,
daily_secret: String,
blocklist: cruciverb_core::dict::blocklist::Blocklist,
definitions: HashMap<String, String>,
wotd_words: Vec<String>,
) -> Arc<Self> {
Arc::new(Self {
dict: Arc::new(dict),
clue_db: Arc::new(clue_db),
cryptic_db: cryptic_db.map(Arc::new),
word_diffs: Arc::new(word_diffs),
puzzles: RwLock::new(HashMap::new()),
db: Mutex::new(AppStateInner { db }),
db_path,
word_quality: RwLock::new(HashMap::new()),
clue_quality: RwLock::new(HashMap::new()),
rp_id,
rp_origin,
reg_challenges: Mutex::new(HashMap::new()),
auth_challenges: Mutex::new(HashMap::new()),
generation_semaphore: Semaphore::new(3),
ap: ap.map(Arc::new),
daily_secret,
rooms: crate::rooms::new_rooms(),
blocklist: std::sync::RwLock::new(blocklist),
definitions: Arc::new(definitions),
wotd_words: Arc::new(wotd_words),
daily_gen_locks: Mutex::new(HashMap::new()),
word_staleness: RwLock::new(HashMap::new()),
generation_counter: AtomicU64::new(0),
})
}
}