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) }