179 lines
6.3 KiB
Rust
179 lines
6.3 KiB
Rust
use actix_web::{HttpRequest, HttpResponse, http::header, web};
|
|
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<String>) -> 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());
|
|
}
|
|
}
|