pub mod auth; pub mod error; pub mod handlers; pub mod models; use axum::{ Router, extract::DefaultBodyLimit, http::{HeaderValue, header}, routing::{delete, get, post}, }; use std::{collections::HashMap, env, path::PathBuf, sync::Arc, time::Duration}; use tokio::sync::{Mutex, RwLock}; use tower_http::{ compression::CompressionLayer, cors::{AllowOrigin, CorsLayer}, services::ServeDir, }; use tracing::{error, info, warn}; use crate::handlers::contact::RATE_LIMIT_WINDOW_MS; use crate::models::{ImageDim, PostInfo}; pub struct CachedPost { pub info: PostInfo, pub body: String, } pub struct AppState { pub admin_token: String, pub data_dir: PathBuf, pub cookie_secure: bool, pub post_lock: Mutex<()>, pub posts_cache: RwLock>, pub image_dims_cache: RwLock>, pub contact_rate_limit: Mutex>>, } #[tokio::main] async fn main() { tracing_subscriber::fmt::init(); dotenvy::dotenv().ok(); let port = env::var("PORT").unwrap_or_else(|_| "3000".to_string()); let admin_token = env::var("ADMIN_TOKEN") .ok() .filter(|t| !t.trim().is_empty()) .expect("ADMIN_TOKEN must be set to a non-empty value"); if admin_token.len() < 16 { warn!( "ADMIN_TOKEN is shorter than 16 characters. Use a long random string in production." ); } let data_dir_str = env::var("DATA_DIR").unwrap_or_else(|_| "../data".to_string()); let data_dir = PathBuf::from(data_dir_str); let cookie_secure = env::var("COOKIE_SECURE") .map(|v| v != "false" && v != "0") .unwrap_or(true); info!("Initializing backend with data dir: {:?}", data_dir); let posts_dir = data_dir.join("posts"); let uploads_dir = data_dir.join("uploads"); if let Err(e) = tokio::fs::create_dir_all(&posts_dir).await { error!("Failed to create posts directory: {}", e); } if let Err(e) = tokio::fs::create_dir_all(&uploads_dir).await { error!("Failed to create uploads directory: {}", e); } let state = Arc::new(AppState { admin_token, data_dir, cookie_secure, post_lock: Mutex::new(()), posts_cache: RwLock::new(Vec::new()), image_dims_cache: RwLock::new(HashMap::new()), contact_rate_limit: Mutex::new(HashMap::new()), }); handlers::posts::rebuild_posts_cache(&state).await; info!("Posts cache primed with {} entries", state.posts_cache.read().await.len()); spawn_rate_limit_reaper(state.clone()); // CORS — locked down by default. Set FRONTEND_ORIGIN to the public URL of // the frontend if you ever expose the backend directly to browsers. // Normal deployments hit the backend through the Astro proxy, which is // server-to-server and not subject to CORS. let cors = match env::var("FRONTEND_ORIGIN").ok().filter(|s| !s.is_empty()) { Some(origin) => { let value = HeaderValue::from_str(&origin) .expect("FRONTEND_ORIGIN must be a valid origin URL"); CorsLayer::new() .allow_origin(AllowOrigin::exact(value)) .allow_methods([ axum::http::Method::GET, axum::http::Method::POST, axum::http::Method::DELETE, axum::http::Method::OPTIONS, ]) .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE]) .allow_credentials(true) } None => CorsLayer::new(), }; // JSON routes get a tight 1 MB cap; the upload route keeps 50 MB. const JSON_BODY_LIMIT: usize = 1024 * 1024; const UPLOAD_BODY_LIMIT: usize = 50 * 1024 * 1024; let app = Router::new() .route("/api/auth/login", post(handlers::auth::login)) .route("/api/auth/logout", post(handlers::auth::logout)) .route("/api/auth/me", get(handlers::auth::me)) .route( "/api/config", get(handlers::config::get_config).post(handlers::config::update_config), ) .route( "/api/posts", get(handlers::posts::list_posts).post(handlers::posts::create_post), ) .route( "/api/posts/{slug}", get(handlers::posts::get_post).delete(handlers::posts::delete_post), ) .route("/api/uploads", get(handlers::upload::list_uploads)) .route( "/api/uploads/{filename}", delete(handlers::upload::delete_upload), ) .route( "/api/upload", post(handlers::upload::upload_file) .layer(DefaultBodyLimit::max(UPLOAD_BODY_LIMIT)), ) .route("/api/contact", post(handlers::contact::submit_contact)) .route("/api/messages", get(handlers::contact::list_messages)) .route( "/api/messages/{id}", delete(handlers::contact::delete_message), ) .route("/healthz", get(|| async { "ok" })) .nest_service("/uploads", ServeDir::new(uploads_dir)) .layer(DefaultBodyLimit::max(JSON_BODY_LIMIT)) .layer(CompressionLayer::new().br(true).gzip(true)) .layer(cors) .with_state(state); let addr = format!("0.0.0.0:{}", port); info!("Starting server on {}", addr); let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); axum::serve(listener, app).await.unwrap(); } /// Periodically prunes expired entries from the contact rate-limit map so it /// can't grow unbounded across the lifetime of the process. fn spawn_rate_limit_reaper(state: Arc) { tokio::spawn(async move { let mut ticker = tokio::time::interval(Duration::from_secs(300)); ticker.tick().await; loop { ticker.tick().await; let now_ms = chrono::Utc::now().timestamp_millis(); let mut map = state.contact_rate_limit.lock().await; map.retain(|_, times| { times.retain(|t| now_ms - *t < RATE_LIMIT_WINDOW_MS); !times.is_empty() }); } }); }