Files
narlblog/backend/src/main.rs
T
2026-05-14 18:34:07 +02:00

174 lines
6.0 KiB
Rust

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<Vec<CachedPost>>,
pub image_dims_cache: RwLock<HashMap<String, ImageDim>>,
pub contact_rate_limit: Mutex<HashMap<String, Vec<i64>>>,
}
#[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<AppState>) {
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()
});
}
});
}