use axum::body::Body;
use axum::http::header::{CACHE_CONTROL, CONTENT_TYPE};
use axum::http::{HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use super::{PlatformAppState, content_type_for_path, harden_byte_response, internal_error};
use crate::platform::error::PlatformError;
use crate::zebfs::acl::serve_origin_host;
use crate::zebfs::{ZebFsAccess, normalize_object_path};
fn not_found() -> Response {
(StatusCode::NOT_FOUND, "There is nothing at this address.").into_response()
}
fn hidden_path(decoded: &str) -> bool {
decoded
.trim_start_matches('/')
.split('/')
.enumerate()
.any(|(index, segment)| segment.starts_with('.') && !(index == 0 && segment == ".well-known"))
}
fn object_response(rel: &str, bytes: Vec<u8>) -> Response {
let mut resp = Response::new(Body::from(bytes));
*resp.status_mut() = StatusCode::OK;
if let Ok(value) = HeaderValue::from_str(content_type_for_path(std::path::Path::new(rel))) {
resp.headers_mut().insert(CONTENT_TYPE, value);
}
resp.headers_mut()
.insert(CACHE_CONTROL, HeaderValue::from_static("public, max-age=300"));
resp
}
pub(super) async fn file_host_response(
state: &PlatformAppState,
owner: &str,
project: &str,
path: &str,
) -> Response {
let decoded = percent_decode(path);
if hidden_path(&decoded) {
return not_found();
}
let Ok(rel) = normalize_object_path(&decoded) else {
return not_found();
};
let layout = match state.platform.file.ensure_project_layout(owner, project) {
Ok(layout) => layout,
Err(err) => return internal_error(err),
};
match crate::platform::services::zebfs_acl::effective_access(&layout.data_store_dir(), &rel) {
Ok(access) if access.is_exposed() => {}
Ok(_) => return not_found(),
Err(err) => return internal_error(PlatformError::new(err.code, err.message)),
}
match layout.open_files().get(&rel) {
Ok(object) => harden_byte_response(object_response(&rel, object.bytes)).await,
Err(err) if err.code == "ZEBFS_NOT_FOUND" || err.code == "ZEBFS_INVALID_PATH" => not_found(),
Err(err) => internal_error(PlatformError::new(err.code, err.message)),
}
}
pub(super) fn execute_site_response(
state: &PlatformAppState,
owner: &str,
project: &str,
request_host: &str,
request_port: Option<&str>,
path: &str,
) -> Option<Response> {
let layout = state.platform.file.ensure_project_layout(owner, project).ok()?;
let store_dir = layout.data_store_dir();
let manifest = crate::platform::services::zebfs_acl::read(&store_dir).ok()?;
let folder = manifest.rules.iter().find_map(|(folder, rule)| {
(rule.access == ZebFsAccess::PublicExecute
&& rule.serve.iter().any(|origin| origin_matches(origin, request_host, request_port)))
.then(|| folder.clone())
})?;
let request_rel = percent_decode(path);
if hidden_path(&request_rel) {
return Some(not_found());
}
let request_rel = request_rel.trim_start_matches('/');
let zebfs = layout.open_files();
for candidate in site_candidates(&folder, request_rel) {
let Ok(rel) = normalize_object_path(&candidate) else {
continue;
};
match manifest.effective_rule(&rel) {
Ok(Some((_, rule)))
if rule.access == ZebFsAccess::PublicExecute
&& rule.serve.iter().any(|origin| origin_matches(origin, request_host, request_port)) => {}
_ => continue,
}
if let Ok(object) = zebfs.get(&rel) {
let mut resp = object_response(&rel, object.bytes);
resp.headers_mut().insert(
"x-content-type-options",
HeaderValue::from_static("nosniff"),
);
return Some(resp);
}
}
Some(not_found())
}
fn site_candidates(folder: &str, request_rel: &str) -> Vec<String> {
let base = if request_rel.is_empty() {
folder.to_string()
} else {
format!("{folder}/{}", request_rel.trim_end_matches('/'))
};
let mut out = Vec::new();
if !request_rel.is_empty() && !request_rel.ends_with('/') {
out.push(base.clone());
}
out.push(format!("{base}/index.html"));
if !request_rel.is_empty() && !request_rel.ends_with('/') {
out.push(format!("{base}.html"));
}
out
}
fn origin_matches(origin: &str, request_host: &str, request_port: Option<&str>) -> bool {
if serve_origin_host(origin) != request_host {
return false;
}
let authority = origin
.split_once("://")
.map(|(_, rest)| rest.trim_end_matches('/'))
.unwrap_or(origin);
match authority.rsplit_once(':') {
Some((_, port)) => request_port == Some(port),
None => true,
}
}
fn percent_decode(path: &str) -> String {
let bytes = path.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%'
&& i + 3 <= bytes.len()
&& let Ok(hex) = std::str::from_utf8(&bytes[i + 1..i + 3])
&& let Ok(value) = u8::from_str_radix(hex, 16)
{
out.push(value);
i += 3;
continue;
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
#[cfg(test)]
mod tests {
use super::{hidden_path, origin_matches, percent_decode, site_candidates};
#[test]
fn a_dot_segment_is_hidden_except_well_known() {
assert!(hidden_path("/.zebflow-static-site.json"));
assert!(hidden_path("/docs/.zebflow-static-site.json"));
assert!(hidden_path(&percent_decode("/%2Ezebflow-static-site.json")));
assert!(hidden_path("/a/.git/config"));
assert!(hidden_path("/.well-known/.hidden"));
assert!(!hidden_path("/.well-known/security.txt"));
assert!(!hidden_path("/docs/v1.2/index.html"));
assert!(!hidden_path("/"));
assert!(hidden_path(&percent_decode("/a/%2e")), "an escape at the very end decodes too");
assert_eq!(percent_decode("/caf%C3%A9/%é"), "/café/%é", "a % before a multi-byte character is kept");
}
#[test]
fn a_site_path_tries_the_object_then_its_index_then_html() {
assert_eq!(site_candidates("site", ""), vec!["site/index.html"]);
assert_eq!(
site_candidates("site", "artists/k"),
vec!["site/artists/k", "site/artists/k/index.html", "site/artists/k.html"]
);
assert_eq!(site_candidates("site", "docs/"), vec!["site/docs/index.html"]);
}
#[test]
fn origins_match_on_host_and_on_port_when_named() {
assert!(origin_matches("https://site.example/", "site.example", None));
assert!(origin_matches("https://site.example/", "site.example", Some("443")));
assert!(!origin_matches("https://site.example/", "www.site.example", None));
assert!(origin_matches("http://d.o.localhost:10610/", "d.o.localhost", Some("10610")));
assert!(!origin_matches("http://d.o.localhost:10610/", "d.o.localhost", Some("8080")));
}
#[test]
fn encoded_paths_decode_before_matching() {
assert_eq!(percent_decode("/a%20b.png"), "/a b.png");
assert_eq!(percent_decode("/%2e%2e/x"), "/../x");
}
}