server bootstrap and state
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user