use actix_cors::Cors; use actix_files::Files; use actix_web::dev::{Service, ServiceRequest, ServiceResponse}; use actix_web::{http::header, web, App, HttpResponse, HttpServer}; use futures::future::LocalBoxFuture; use log::info; use std::path::PathBuf; use std::task::{Context, Poll}; use std::time::Instant; /// 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" })) } /// Custom middleware that logs requests except for noisy paths (health, metrics, static files). struct RequestLogger; impl actix_web::dev::Transform for RequestLogger where S: Service, Error = actix_web::Error>, S::Future: 'static, B: 'static, { type Response = ServiceResponse; type Error = actix_web::Error; type Transform = RequestLoggerService; type InitError = (); type Future = futures::future::Ready>; fn new_transform(&self, service: S) -> Self::Future { futures::future::ok(RequestLoggerService { service, _marker: std::marker::PhantomData, }) } } struct RequestLoggerService { service: S, _marker: std::marker::PhantomData, } impl Service for RequestLoggerService 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 method = req.method().to_string(); let should_log = !(path == "/health" || path == "/metrics" || path.starts_with("/ws") || path.starts_with("/avatar") || path.starts_with("/blob") || path.starts_with("/media") || path.starts_with("/static")); let start = Instant::now(); let fut = self.service.call(req); Box::pin(async move { let res = fut.await?; if should_log { info!( target: "static_server", "{} {} {} {:?}", method, path, res.status().as_u16(), start.elapsed() ); } Ok(res) }) } } #[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(RequestLogger) .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(()) }