gitdataai/apps/static/src/main.rs
ZhenYi 8b47f677bb
Some checks are pending
CI / Rust Lint & Check (push) Waiting to run
CI / Rust Tests (push) Waiting to run
CI / Frontend Lint & Type Check (push) Waiting to run
CI / Frontend Build (push) Blocked by required conditions
fix(avatar): add upload API routes and fix URL path prefix
- Add /api/users/me/avatar and /api/projects/{name}/avatar multipart upload endpoints
- Fix avatar URL path: missing /avatar prefix (static.gitdata.ai/avatar/{file})
- Fix project avatar: Utc::now() → .timestamp(), missing extension, wrong return type
- Replace broken SkipNoisyPaths middleware with self-contained RequestLogger
  (actix-web 4.13 body type incompatibility with newer actix-http)
- Exclude /assets/* requests from main app logger
- Exclude /avatar/*, /blob/*, /media/*, /static/* from static server logger
- Fix TypingEvent missing sender_type field in ws_universal.rs and connection.rs
- Wire real fetch-based upload in user profile settings
- Add project avatar upload UI to project settings page
2026-04-25 23:19:22 +08:00

189 lines
5.5 KiB
Rust

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<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<()> {
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(())
}