use actix_cors::Cors; use actix_files::Files; use actix_web::dev::{Service, ServiceRequest, ServiceResponse}; use actix_web::{http::header, middleware::Logger, web, App, HttpResponse, HttpServer}; use futures::future::LocalBoxFuture; use std::path::PathBuf; use std::task::{Context, Poll}; /// Static file server for avatar, blob, and other static files /// Serves files from /data/{type} directories #[derive(Clone)] struct StaticConfig { root: PathBuf, cors_enabled: bool, } impl StaticConfig { fn from_env() -> Self { let root = std::env::var("STATIC_ROOT").unwrap_or_else(|_| "/data".to_string()); let cors = std::env::var("STATIC_CORS").unwrap_or_else(|_| "true".to_string()); Self { root: PathBuf::from(root), cors_enabled: cors == "true" || cors == "1", } } fn ensure_dir(&self, name: &str) -> PathBuf { let dir = self.root.join(name); if !dir.exists() { std::fs::create_dir_all(&dir).ok(); } dir } } async fn health() -> HttpResponse { HttpResponse::Ok().json(serde_json::json!({ "status": "ok", "service": "static-server" })) } /// Skips Logger terminal output for noisy health/WS endpoints. struct SkipNoisyPaths(S); impl SkipNoisyPaths { fn new(logger: S) -> Self { Self(logger) } } impl actix_web::dev::Transform for SkipNoisyPaths where S: actix_web::dev::Transform< ServiceRequest, Response = ServiceResponse, Error = actix_web::Error, InitError = (), >, S::Future: 'static, B: 'static, { type Response = ServiceResponse; type Error = actix_web::Error; type Transform = SkipNoisyPathsService; type InitError = (); fn new_transform(&self, service: S::Transform) -> , Error = actix_web::Error, InitError = (), >>::Future { futures::future::ok(SkipNoisyPathsService { service, _marker: std::marker::PhantomData, }) } } struct SkipNoisyPathsService { service: S, _marker: std::marker::PhantomData, } impl Service for SkipNoisyPathsService where S: Service, Error = actix_web::Error>, S::Future: 'static, B: 'static, { type Response = ServiceResponse; type Error = actix_web::Error; type Future = LocalBoxFuture<'static, Result>; fn poll_ready(&self, cx: &mut Context<'_>) -> Poll> { self.service.poll_ready(cx) } fn call(&self, req: ServiceRequest) -> Self::Future { let path = req.path().to_string(); let should_skip = path == "/health" || path == "/metrics" || path.starts_with("/ws"); if should_skip { let fut = self.service.call(req); Box::pin(async move { let res = fut.await?; Ok(res.map_body(|_, _| actix_web::body::Empty::new())) }) } else { self.service.call(req) } } } #[actix_web::main] async fn main() -> anyhow::Result<()> { env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); let cfg = StaticConfig::from_env(); let bind = std::env::var("STATIC_BIND").unwrap_or_else(|_| "0.0.0.0:8081".to_string()); println!("Static file server starting..."); println!(" Root: {:?}", cfg.root); println!(" Bind: {}", bind); println!(" CORS: {}", if cfg.cors_enabled { "enabled" } else { "disabled" }); // Ensure all directories exist for name in ["avatar", "blob", "media", "static"] { let dir = cfg.ensure_dir(name); println!(" {} dir: {:?}", name, dir); } let root = cfg.root.clone(); let cors_enabled = cfg.cors_enabled; HttpServer::new(move || { let root = root.clone(); let cors = if cors_enabled { Cors::default() .allow_any_origin() .allowed_methods(vec!["GET", "HEAD", "OPTIONS"]) .allowed_headers(vec![ header::AUTHORIZATION, header::ACCEPT, header::CONTENT_TYPE, ]) .max_age(3600) } else { Cors::permissive() }; App::new() .wrap(cors) .wrap(SkipNoisyPaths::new(Logger::default())) .route("/health", web::get().to(health)) .service( Files::new("/avatar", root.join("avatar")) .prefer_utf8(true) .index_file("index.html"), ) .service( Files::new("/blob", root.join("blob")) .prefer_utf8(true) .index_file("index.html"), ) .service( Files::new("/media", root.join("media")) .prefer_utf8(true) .index_file("index.html"), ) .service( Files::new("/static", root.join("static")) .prefer_utf8(true) .index_file("index.html"), ) }) .bind(&bind)? .run() .await?; Ok(()) }