use axum::body::Body;
use axum::extract::Request;
use axum::http::header::{ACCEPT, CACHE_CONTROL, VARY};
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode};
use axum::middleware::Next;
use axum::response::Response;
pub(crate) async fn dispatch(req: Request, next: Next) -> Response {
if req.method() != Method::GET && req.method() != Method::HEAD {
return next.run(req).await;
}
let is_head = req.method() == Method::HEAD;
let path = req.uri().path();
if is_asset_path(path) {
return asset(path, is_head);
}
if is_page_path(path) && prefers_html(req.headers()) {
return index(is_head);
}
next.run(req).await
}
pub(crate) async fn dispatch_admin(req: Request, next: Next) -> Response {
if req.method() != Method::GET && req.method() != Method::HEAD {
return next.run(req).await;
}
let path = req.uri().path();
if is_asset_path(path) {
return asset(path, req.method() == Method::HEAD);
}
if is_admin_page_path(path) && prefers_html(req.headers()) {
return index(req.method() == Method::HEAD);
}
next.run(req).await
}
fn index(is_head: bool) -> Response {
let mut response = sbol_db_ui::index_response();
response
.headers_mut()
.append(VARY, HeaderValue::from_static("Accept"));
if is_head {
*response.body_mut() = Body::empty();
}
response
}
fn asset(path: &str, is_head: bool) -> Response {
let mut response = match sbol_db_ui::asset_response(path) {
Some(response) => response,
None => Response::builder()
.status(StatusCode::NOT_FOUND)
.header(CACHE_CONTROL, "no-store")
.body(Body::empty())
.expect("asset 404 response is well-formed"),
};
if is_head {
*response.body_mut() = Body::empty();
}
response
}
fn is_asset_path(path: &str) -> bool {
path.starts_with("/assets/")
|| matches!(
path,
"/favicon.svg"
| "/favicon.ico"
| "/robots.txt"
| "/site.webmanifest"
| "/apple-touch-icon.png"
)
}
fn is_admin_page_path(path: &str) -> bool {
matches!(path, "/login" | "/setup") || is_path_family(path, "/admin")
}
fn is_page_path(path: &str) -> bool {
if path == "/" {
return true;
}
if [
"/login",
"/register",
"/profile",
"/setup",
"/submit",
"/contribute",
"/advanced-search",
"/root-collections",
"/sequence-search",
"/sparql",
"/change-password",
"/about",
"/reset-password",
]
.contains(&path)
{
return true;
}
if [
"/search",
"/collections",
"/workspace",
"/submissions",
"/account",
"/admin",
"/tools",
"/objects/view",
]
.iter()
.any(|root| is_path_family(path, root))
{
return true;
}
is_canonical_object_path(path)
}
fn is_path_family(path: &str, root: &str) -> bool {
path == root
|| path
.strip_prefix(root)
.is_some_and(|suffix| suffix.starts_with('/'))
}
fn is_canonical_object_path(path: &str) -> bool {
let segments: Vec<_> = path
.trim_matches('/')
.split('/')
.filter(|segment| !segment.is_empty())
.collect();
match segments.as_slice() {
["public", _, _] | ["user", _, _, _] => true,
["public", _, _, version] | ["user", _, _, _, version] => {
!is_reserved_identity_suffix(version)
}
_ => false,
}
}
fn is_reserved_identity_suffix(segment: &str) -> bool {
matches!(
segment,
"full"
| "sbol"
| "sbolnr"
| "gb"
| "fasta"
| "gff"
| "omex"
| "summary"
| "metadata"
| "subCollections"
| "uses"
| "usesCount"
| "similar"
| "similarCount"
| "twins"
| "twinsCount"
| "attach"
| "attachUrl"
| "download"
| "remove"
| "replace"
| "removeCollection"
| "icon"
| "copyFromRemote"
| "makePublic"
| "addOwner"
)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum AcceptKind {
Html,
Machine,
}
fn prefers_html(headers: &HeaderMap) -> bool {
let mut best: Option<(f32, usize, AcceptKind)> = None;
let mut order = 0usize;
for value in headers.get_all(ACCEPT).iter() {
let Ok(value) = value.to_str() else {
return false;
};
for range in value.split(',') {
let mut parts = range.split(';');
let media_type = parts.next().unwrap_or_default().trim().to_ascii_lowercase();
let mut quality = 1.0f32;
for parameter in parts {
let Some((name, value)) = parameter.trim().split_once('=') else {
continue;
};
if name.trim().eq_ignore_ascii_case("q") {
quality = value.trim().parse::<f32>().unwrap_or(0.0);
}
}
let kind = match media_type.as_str() {
"text/html" | "application/xhtml+xml" => Some(AcceptKind::Html),
"*/*" => None,
media if media.ends_with("/*") => None,
"" => None,
_ => Some(AcceptKind::Machine),
};
if quality > 0.0 {
if let Some(kind) = kind {
let replace = best
.as_ref()
.map(|(best_quality, best_order, _)| {
quality > *best_quality
|| (quality == *best_quality && order < *best_order)
})
.unwrap_or(true);
if replace {
best = Some((quality, order, kind));
}
}
}
order += 1;
}
}
matches!(best, Some((_, _, AcceptKind::Html)))
}
#[cfg(test)]
mod tests {
use super::*;
fn headers(accept: Option<&str>) -> HeaderMap {
let mut headers = HeaderMap::new();
if let Some(accept) = accept {
headers.insert(ACCEPT, HeaderValue::from_str(accept).unwrap());
}
headers
}
#[test]
fn browser_navigation_prefers_explicit_html() {
assert!(prefers_html(&headers(Some(
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
))));
assert!(prefers_html(&headers(Some(
"application/json;q=0.5, text/html;q=0.9"
))));
}
#[test]
fn api_defaults_remain_machine_requests() {
assert!(!prefers_html(&headers(None)));
assert!(!prefers_html(&headers(Some("*/*"))));
assert!(!prefers_html(&headers(Some("application/*"))));
assert!(!prefers_html(&headers(Some(
"application/json, text/html;q=0.1"
))));
assert!(!prefers_html(&headers(Some("application/json, text/html"))));
assert!(!prefers_html(&headers(Some("text/html;q=0, */*;q=1"))));
}
#[test]
fn page_allowlist_includes_product_routes_and_bare_identities() {
for path in [
"/",
"/login",
"/search",
"/search/objectType=ComponentDefinition",
"/sequence-search",
"/about",
"/advanced-search",
"/collections/featured",
"/workspace/shared",
"/admin/operations/jobs",
"/public/igem/BBa_F2620/1",
"/public/igem/BBa_F2620",
"/user/alice/designs/toggle/1",
"/user/alice/designs/toggle",
] {
assert!(is_page_path(path), "expected portal page: {path}");
}
}
#[test]
fn api_and_object_subresources_are_not_pages() {
for path in [
"/api/v2/search",
"/healthz",
"/browse",
"/rootCollections",
"/searchCount",
"/public/igem/BBa_F2620/1/full",
"/public/igem/BBa_F2620/1/remove",
"/public/igem/BBa_F2620/sbol",
"/public/igem/BBa_F2620/uses",
"/user/alice/designs/toggle/sbolnr",
"/user/alice/designs/toggle/1/download",
"/callPlugin",
] {
assert!(!is_page_path(path), "must remain an API path: {path}");
}
}
#[test]
fn removed_connect_route_is_not_a_page() {
assert!(!is_page_path("/connect"));
}
}