use super::error::ApiError;
#[cfg(feature = "ui")]
use axum::http::header;
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, Uri};
use axum::response::{IntoResponse, Response};
#[cfg(feature = "ui")]
#[derive(rust_embed::RustEmbed)]
#[folder = "$CYBERBRAIN_UI_DIR"]
pub struct Ui;
const FALLBACK_CSP: &str = "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; \
img-src 'self' data:; font-src 'self'; connect-src 'self'; manifest-src 'self'; \
object-src 'none'; base-uri 'none'; form-action 'none'";
#[cfg(not(feature = "ui"))]
pub fn meta_csp() -> Option<String> {
None
}
#[cfg(feature = "ui")]
pub fn meta_csp() -> Option<String> {
let index = Ui::get("index.html")?;
let html = String::from_utf8_lossy(&index.data);
let at = html.find("Content-Security-Policy")?;
let rest = &html[at..];
let start = rest.find("content=\"")? + "content=\"".len();
let end = rest[start..].find('"')?;
Some(unescape_attr(&rest[start..start + end]))
}
#[cfg(feature = "ui")]
fn unescape_attr(s: &str) -> String {
s.replace("'", "'")
.replace("'", "'")
.replace(""", "\"")
.replace(""", "\"")
.replace("<", "<")
.replace(">", ">")
.replace("&", "&")
}
pub fn csp_header() -> HeaderValue {
let base = meta_csp().unwrap_or_else(|| FALLBACK_CSP.to_string());
let base = base.trim().trim_end_matches(';').to_string();
let value = if base.contains("frame-ancestors") {
base
} else {
format!("{base}; frame-ancestors 'none'")
};
HeaderValue::from_str(&value)
.unwrap_or_else(|_| HeaderValue::from_static("default-src 'none'; frame-ancestors 'none'"))
}
#[cfg(feature = "ui")]
fn etag_of(file: &rust_embed::EmbeddedFile) -> String {
let h = file.metadata.sha256_hash();
let hex: String = h.iter().take(8).map(|b| format!("{b:02x}")).collect();
format!("\"{hex}\"")
}
fn clean_path(uri: &Uri) -> Option<String> {
let raw = uri.path().trim_start_matches('/');
let mut parts = Vec::new();
for seg in raw.split('/') {
match seg {
"" | "." => continue,
".." => return None,
s => parts.push(s),
}
}
Some(parts.join("/"))
}
#[cfg(feature = "ui")]
fn serve_file(path: &str, file: rust_embed::EmbeddedFile, headers: &HeaderMap) -> Response {
let etag = etag_of(&file);
let mime = mime_guess::from_path(path).first_or_octet_stream();
let cache = if path.starts_with("assets/") || path.starts_with("fonts/") {
"public, max-age=31536000, immutable"
} else {
"no-cache"
};
let matches = headers
.get(header::IF_NONE_MATCH)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.split(',').any(|t| t.trim() == etag || t.trim() == "*"));
let mut resp = if matches {
StatusCode::NOT_MODIFIED.into_response()
} else {
file.data.into_owned().into_response()
};
let h = resp.headers_mut();
if !matches {
h.insert(
header::CONTENT_TYPE,
HeaderValue::from_str(mime.as_ref())
.unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")),
);
}
h.insert(
header::ETAG,
HeaderValue::from_str(&etag).expect("hex etag"),
);
h.insert(header::CACHE_CONTROL, HeaderValue::from_static(cache));
resp
}
pub async fn fallback(method: Method, uri: Uri, headers: HeaderMap) -> Response {
let Some(path) = clean_path(&uri) else {
return ApiError::bad_request("path escapes the site root").into_response();
};
if path == "api" || path.starts_with("api/") {
return ApiError::not_found(format!("no such route: {} /{path}", method)).into_response();
}
if method != Method::GET && method != Method::HEAD {
return ApiError::new(
StatusCode::METHOD_NOT_ALLOWED,
"bad-request",
format!("{method} is not allowed on /{path}"),
)
.into_response();
}
let path = if path.is_empty() {
"index.html".to_string()
} else {
path
};
#[cfg(feature = "ui")]
{
if let Some(f) = Ui::get(&path) {
return serve_file(&path, f, &headers);
}
let last = path.rsplit('/').next().unwrap_or("");
if !last.contains('.')
&& let Some(index) = Ui::get("index.html")
{
return serve_file("index.html", index, &headers);
}
ApiError::not_found(format!("/{path} is not part of the embedded UI")).into_response()
}
#[cfg(not(feature = "ui"))]
{
let _ = &headers;
ApiError::not_found(format!(
"/{path}: this binary was built without the `ui` feature, so no web page is \
embedded. The HTTP API under /api/v1 is unaffected."
))
.into_response()
}
}
pub fn bundle_present() -> bool {
#[cfg(feature = "ui")]
{
Ui::get("index.html").is_some()
}
#[cfg(not(feature = "ui"))]
{
false
}
}