diff --git a/cruciverb-server/src/main.rs b/cruciverb-server/src/main.rs new file mode 100644 index 0000000..01ae1a8 --- /dev/null +++ b/cruciverb-server/src/main.rs @@ -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) { + 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 = 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::(), rand::random::()); + 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 = 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 = 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::().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::()).await.unwrap(); +} + +async fn health() -> axum::Json { + axum::Json(serde_json::json!({"status": "ok"})) +} + +fn download_font(family: &str, weight: &str) -> Result, Box> { + // 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) +} diff --git a/cruciverb-server/src/state.rs b/cruciverb-server/src/state.rs new file mode 100644 index 0000000..11788af --- /dev/null +++ b/cruciverb-server/src/state.rs @@ -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, + pub clue_db: Arc, + pub cryptic_db: Option>, + pub word_diffs: Arc>, + pub puzzles: RwLock>, + pub db: Mutex, + pub db_path: String, + pub word_quality: RwLock>, + pub clue_quality: RwLock>, + pub rp_id: String, + pub rp_origin: String, + // maps challenge -> (session_token, timestamp) + pub reg_challenges: Mutex>, + // maps challenge -> timestamp (no session needed for auth) + pub auth_challenges: Mutex>, + pub generation_semaphore: Semaphore, + pub ap: Option>, + pub daily_secret: String, + pub rooms: crate::rooms::Rooms, + pub blocklist: std::sync::RwLock, + pub definitions: Arc>, + pub wotd_words: Arc>, + /// 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>>>, + /// Tracks the generation number when each word was last used in a puzzle. + pub word_staleness: RwLock>, + /// 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>> { + 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 = 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> { + 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, + word_diffs: HashMap, + db: Connection, + db_path: String, + rp_id: String, + rp_origin: String, + ap: Option, + daily_secret: String, + blocklist: cruciverb_core::dict::blocklist::Blocklist, + definitions: HashMap, + wotd_words: Vec, + ) -> Arc { + 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), + }) + } +}