213 lines
6.3 KiB
Rust
213 lines
6.3 KiB
Rust
use actix_cors::Cors;
|
|
use actix_files::Files;
|
|
use actix_web::dev::{Service, ServiceRequest, ServiceResponse};
|
|
use actix_web::{App, HttpResponse, HttpServer, http::header, web};
|
|
use futures::future::LocalBoxFuture;
|
|
use log::info;
|
|
use observability::{HttpMetrics, init_tracing_subscriber, install_recorder, push::MetricsPusher};
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
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<S, B> actix_web::dev::Transform<S, ServiceRequest> for RequestLogger
|
|
where
|
|
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = actix_web::Error>,
|
|
S::Future: 'static,
|
|
B: 'static,
|
|
{
|
|
type Response = ServiceResponse<B>;
|
|
type Error = actix_web::Error;
|
|
type Transform = RequestLoggerService<S>;
|
|
type InitError = ();
|
|
type Future = futures::future::Ready<Result<Self::Transform, Self::InitError>>;
|
|
|
|
fn new_transform(&self, service: S) -> Self::Future {
|
|
futures::future::ok(RequestLoggerService {
|
|
service,
|
|
_marker: std::marker::PhantomData,
|
|
})
|
|
}
|
|
}
|
|
|
|
struct RequestLoggerService<S> {
|
|
service: S,
|
|
_marker: std::marker::PhantomData<fn(ServiceRequest)>,
|
|
}
|
|
|
|
impl<S, B> Service<ServiceRequest> for RequestLoggerService<S>
|
|
where
|
|
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = actix_web::Error>,
|
|
S::Future: 'static,
|
|
B: 'static,
|
|
{
|
|
type Response = ServiceResponse<B>;
|
|
type Error = actix_web::Error;
|
|
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
|
|
|
|
fn poll_ready(&self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
|
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<()> {
|
|
init_tracing_subscriber("info", false);
|
|
let prometheus_handle = Arc::new(install_recorder());
|
|
let http_metrics = Arc::new(HttpMetrics::new());
|
|
|
|
// Metrics pusher: periodically push all metrics to apps/metrics aggregator
|
|
if let Some(push_url) = std::env::var("METRICS_PUSH_URL").ok() {
|
|
let pusher = MetricsPusher::new(&push_url, "static");
|
|
pusher.spawn(
|
|
http_metrics.clone(),
|
|
prometheus_handle.clone(),
|
|
std::time::Duration::from_secs(15),
|
|
);
|
|
info!("Metrics pusher started (interval 15s, url: {})", push_url);
|
|
}
|
|
|
|
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 {
|
|
// WARNING: allow_any_origin is intentional for static asset serving (CDN mode)
|
|
// Ensure no sensitive files are served from this directory
|
|
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(())
|
|
}
|