gitdataai/libs/api/dist.rs

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