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::{env, fs, path::PathBuf, sync::Arc}; use tokio::sync::Mutex; use tower_http::{ cors::{AllowOrigin, CorsLayer}, services::ServeDir, }; use tracing::{error, info, warn}; pub struct AppState { pub admin_token: String, pub data_dir: PathBuf, pub cookie_secure: bool, pub post_lock: 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) = fs::create_dir_all(&posts_dir) { error!("Failed to create posts directory: {}", e); } if let Err(e) = fs::create_dir_all(&uploads_dir) { error!("Failed to create uploads directory: {}", e); } let state = Arc::new(AppState { admin_token, data_dir, cookie_secure, post_lock: Mutex::new(()), }); // 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(), }; 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)) .route("/healthz", get(|| async { "ok" })) .nest_service("/uploads", ServeDir::new(uploads_dir)) .layer(DefaultBodyLimit::max(50 * 1024 * 1024)) .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(); }