194 lines
6.5 KiB
Rust
194 lines
6.5 KiB
Rust
pub mod auth;
|
|
pub mod error;
|
|
pub mod handlers;
|
|
pub mod models;
|
|
pub mod post;
|
|
|
|
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, SiteMode};
|
|
|
|
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 site_mode: SiteMode,
|
|
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);
|
|
let site_mode = env::var("SITE_MODE")
|
|
.map(|v| {
|
|
if v.to_lowercase() == "blog" {
|
|
SiteMode::Blog
|
|
} else {
|
|
SiteMode::Atelier
|
|
}
|
|
})
|
|
.unwrap_or(SiteMode::Atelier);
|
|
|
|
info!(
|
|
"Initializing backend with data dir: {:?}, mode: {:?}",
|
|
data_dir,
|
|
if site_mode == SiteMode::Blog {
|
|
"blog"
|
|
} else {
|
|
"atelier"
|
|
}
|
|
);
|
|
|
|
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,
|
|
site_mode,
|
|
post_lock: Mutex::new(()),
|
|
posts_cache: RwLock::new(Vec::new()),
|
|
image_dims_cache: RwLock::new(HashMap::new()),
|
|
contact_rate_limit: Mutex::new(HashMap::new()),
|
|
});
|
|
|
|
post::cache::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()
|
|
});
|
|
}
|
|
});
|
|
}
|