use std::time::UNIX_EPOCH;
use axum::body::Body;
use axum::http::header::{CACHE_CONTROL, CONTENT_TYPE, ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH, LAST_MODIFIED};
use axum::http::{HeaderMap, 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"))
}
const REVALIDATE: HeaderValue = HeaderValue::from_static("no-cache");
fn object_validator(stat: &crate::zebfs::ZebFsStat) -> Option<(HeaderValue, Option<HeaderValue>)> {
let modified = stat.modified?;
let seconds = modified.duration_since(UNIX_EPOCH).ok()?.as_secs();
let etag = HeaderValue::from_str(&format!("W/\"{}-{}\"", stat.size, seconds)).ok()?;
Some((etag, http_date(seconds).and_then(|date| HeaderValue::from_str(&date).ok())))
}
fn http_date(seconds: u64) -> Option<String> {
const DAYS: [&str; 7] = ["Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed"];
const MONTHS: [&str; 12] =
["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
let days = (seconds / 86_400) as i64;
let time = seconds % 86_400;
let weekday = DAYS[(days.rem_euclid(7)) as usize];
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
Some(format!(
"{weekday}, {day:02} {} {year:04} {:02}:{:02}:{:02} GMT",
MONTHS[(month - 1) as usize],
time / 3_600,
(time % 3_600) / 60,
time % 60,
))
}
fn still_fresh(request: &HeaderMap, etag: &HeaderValue, last_modified: Option<&HeaderValue>) -> bool {
if let Some(sent) = request.get(IF_NONE_MATCH).and_then(|value| value.to_str().ok()) {
return sent.split(',').any(|candidate| {
let candidate = candidate.trim();
candidate == "*" || candidate.as_bytes() == etag.as_bytes()
});
}
match (request.get(IF_MODIFIED_SINCE), last_modified) {
(Some(sent), Some(known)) => sent.as_bytes() == known.as_bytes(),
_ => false,
}
}
fn object_response(rel: &str, bytes: Vec<u8>, stat: &crate::zebfs::ZebFsStat, request: &HeaderMap) -> Response {
let validator = object_validator(stat);
if let Some((etag, last_modified)) = validator.as_ref()
&& still_fresh(request, etag, last_modified.as_ref())
{
let mut resp = Response::new(Body::empty());
*resp.status_mut() = StatusCode::NOT_MODIFIED;
write_cache_headers(resp.headers_mut(), validator.as_ref());
return resp;
}
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);
}
write_cache_headers(resp.headers_mut(), validator.as_ref());
resp
}
fn write_cache_headers(headers: &mut HeaderMap, validator: Option<&(HeaderValue, Option<HeaderValue>)>) {
headers.insert(CACHE_CONTROL, REVALIDATE);
if let Some((etag, last_modified)) = validator {
headers.insert(ETAG, etag.clone());
if let Some(last_modified) = last_modified {
headers.insert(LAST_MODIFIED, last_modified.clone());
}
}
}
pub(super) async fn file_host_response(
state: &PlatformAppState,
owner: &str,
project: &str,
path: &str,
request: &HeaderMap,
) -> 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, &object.stat, request)).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,
request: &HeaderMap,
) -> 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, &object.stat, request);
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, http_date, object_response, object_validator, origin_matches, percent_decode,
site_candidates, still_fresh,
};
use axum::http::header::{CACHE_CONTROL, ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH, LAST_MODIFIED};
use axum::http::{HeaderMap, HeaderValue, StatusCode};
use std::time::{Duration, UNIX_EPOCH};
fn stat(size: u64, seconds: u64) -> crate::zebfs::ZebFsStat {
crate::zebfs::ZebFsStat {
path: "site/index.html".to_string(),
size,
modified: Some(UNIX_EPOCH + Duration::from_secs(seconds)),
kind: crate::zebfs::ZebFsEntryKind::Object,
}
}
fn header(response: &axum::response::Response, name: axum::http::HeaderName) -> String {
response.headers().get(name).and_then(|v| v.to_str().ok()).unwrap_or_default().to_string()
}
#[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 a_served_file_carries_a_validator_and_asks_before_it_is_reused() {
let stat = stat(1234, 1_762_000_000);
let response = object_response("site/index.html", b"<html></html>".to_vec(), &stat, &HeaderMap::new());
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(header(&response, CACHE_CONTROL), "no-cache");
assert_eq!(header(&response, ETAG), "W/\"1234-1762000000\"");
assert!(!header(&response, LAST_MODIFIED).is_empty());
}
#[test]
fn a_reader_holding_this_version_is_told_so_and_sent_no_body() {
let stat = stat(1234, 1_762_000_000);
let mut request = HeaderMap::new();
request.insert(IF_NONE_MATCH, HeaderValue::from_static("W/\"1234-1762000000\""));
let response = object_response("site/index.html", b"<html></html>".to_vec(), &stat, &request);
assert_eq!(response.status(), StatusCode::NOT_MODIFIED);
assert_eq!(header(&response, ETAG), "W/\"1234-1762000000\"");
}
#[test]
fn a_rebuilt_file_does_not_match_the_tag_the_reader_holds() {
let before = object_validator(&stat(1234, 1_762_000_000)).expect("validator").0;
let after = object_validator(&stat(1200, 1_762_000_900)).expect("validator").0;
assert_ne!(before, after);
let mut request = HeaderMap::new();
request.insert(IF_NONE_MATCH, before.clone());
assert!(!still_fresh(&request, &after, None));
assert!(still_fresh(&request, &before, None));
}
#[test]
fn a_tag_decides_over_a_date() {
let (etag, last_modified) = object_validator(&stat(10, 1_762_000_000)).expect("validator");
let last_modified = last_modified.expect("a date");
let mut request = HeaderMap::new();
request.insert(IF_MODIFIED_SINCE, last_modified.clone());
request.insert(IF_NONE_MATCH, HeaderValue::from_static("W/\"999-1\""));
assert!(!still_fresh(&request, &etag, Some(&last_modified)));
}
#[test]
fn a_date_is_written_the_one_way_http_accepts() {
assert_eq!(http_date(784_111_777).as_deref(), Some("Sun, 06 Nov 1994 08:49:37 GMT"));
assert_eq!(http_date(0).as_deref(), Some("Thu, 01 Jan 1970 00:00:00 GMT"));
}
#[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");
}
}