use actix_web::{http::header, web, HttpRequest, HttpResponse}; use mime_guess2::MimeGuess; fn cache_control_header(path: &str) -> &'static str { if path == "index.html" { "no-cache, no-store, must-revalidate" } else if path.ends_with(".js") || path.ends_with(".css") || path.ends_with(".woff2") || path.ends_with(".woff") || path.ends_with(".ttf") || path.ends_with(".otf") || path.ends_with(".png") || path.ends_with(".jpg") || path.ends_with(".jpeg") || path.ends_with(".gif") || path.ends_with(".svg") || path.ends_with(".ico") || path.ends_with(".webp") || path.ends_with(".avif") || path.ends_with(".map") { "public, max-age=31536000, immutable" } else { "no-cache" } } /// Returns true if the client explicitly accepts the given encoding via Accept-Encoding header. /// "*" is treated as accepting all encodings. fn accepts_encoding(req: &HttpRequest, target: &str) -> bool { let header_val = match req.headers().get(header::ACCEPT_ENCODING) { Some(v) => v.to_str().unwrap_or(""), None => return false, }; for part in header_val.split(',') { let part = part.trim(); // Strip quality value if present let enc = part.split(';').next().unwrap_or(part).trim(); if enc.eq_ignore_ascii_case(target) || enc == "*" { return true; } } false } /// Determines the Content-Type for a path, with explicit fallback for common frontend extensions. fn content_type_for_path(path: &str) -> String { if let Some(ext) = path.rsplit('.').next() { match ext { "html" | "htm" => return "text/html; charset=utf-8".into(), "js" | "mjs" => return "application/javascript; charset=utf-8".into(), "jsx" | "ts" | "tsx" => return "text/plain; charset=utf-8".into(), "css" => return "text/css; charset=utf-8".into(), "json" => return "application/json; charset=utf-8".into(), "svg" => return "image/svg+xml".into(), "png" => return "image/png".into(), "jpg" | "jpeg" => return "image/jpeg".into(), "gif" => return "image/gif".into(), "webp" => return "image/webp".into(), "ico" => return "image/x-icon".into(), "woff" => return "font/woff".into(), "woff2" => return "font/woff2".into(), "ttf" => return "font/ttf".into(), "otf" => return "font/otf".into(), "txt" => return "text/plain; charset=utf-8".into(), "xml" => return "application/xml; charset=utf-8".into(), _ => {} } } MimeGuess::from_path(path).first_or_octet_stream().to_string() } /// Build an HttpResponse for the given asset. /// Sets Content-Type, Cache-Control, ETag, and Content-Encoding (only if non-empty). fn build_asset_response( req: &HttpRequest, data: &[u8], etag: &str, path: &str, cc: &str, content_encoding: &str, ) -> HttpResponse { // 304 Not Modified when client has the same ETag if let Some(if_none_match) = req.headers().get(header::IF_NONE_MATCH) { if let Ok(client_etag) = if_none_match.to_str() { if client_etag == etag { let mut resp = HttpResponse::NotModified(); resp.insert_header(("Cache-Control", cc)); resp.insert_header(("ETag", etag)); if !content_encoding.is_empty() { resp.insert_header(("Content-Encoding", content_encoding)); } return resp.finish(); } } } let mime = content_type_for_path(path); let mut resp = HttpResponse::Ok(); resp.content_type(mime); resp.insert_header(("Cache-Control", cc)); resp.insert_header(("ETag", etag)); if !content_encoding.is_empty() { resp.insert_header(("Content-Encoding", content_encoding)); } resp.body(data.to_vec()) } pub async fn serve_frontend(req: HttpRequest, path: web::Path) -> HttpResponse { let path = path.into_inner(); let path_str = if path.is_empty() || path == "/" { "index.html" } else { path.as_str() }; let cc = cache_control_header(path_str); // Try brotli/gzip compressed variant first (best compression), // then fall back to uncompressed if client doesn't accept the encoding. let compressed = crate::frontend::get_frontend_asset_compressed(path_str); let uncompressed = crate::frontend::get_frontend_asset_with_etag(path_str); let (data, encoding, etag, content_path) = if let Some((c_data, c_enc, c_etag)) = compressed { if accepts_encoding(&req, c_enc) { (c_data, c_enc, c_etag, path_str) } else if let Some((u_data, u_etag)) = uncompressed { // Client doesn't accept the pre-compressed encoding — serve uncompressed. (u_data, "", u_etag, path_str) } else { // No uncompressed fallback — still serve compressed (client must handle it). (c_data, c_enc, c_etag, path_str) } } else if let Some((data, etag)) = uncompressed { (data, "", etag, path_str) } else { // Path not found — try index.html as SPA fallback. match crate::frontend::get_frontend_asset_with_etag("index.html") { Some((data, etag)) => (data, "", etag, "index.html"), None => return HttpResponse::NotFound().finish(), } }; if !encoding.is_empty() && accepts_encoding(&req, &encoding) { build_asset_response(&req, data, etag, content_path, cc, &encoding) } else { build_asset_response(&req, data, etag, content_path, cc, "") } } #[cfg(test)] mod tests { use super::*; #[test] fn test_content_type_for_html() { assert_eq!( content_type_for_path("index.html"), "text/html; charset=utf-8" ); } #[test] fn test_content_type_for_hashed_js() { assert_eq!( content_type_for_path("index-De8T6ILu.js"), "application/javascript; charset=utf-8" ); } #[test] fn test_content_type_for_extensionless() { let ct = content_type_for_path("auth/login"); assert!(!ct.is_empty()); } }