use axum::{
http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode},
response::{IntoResponse, Response},
};
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
use std::collections::{BTreeMap, HashSet};
use crate::world::Stage;
use crate::{precondition_failed, storage_error, to_header_map, world, Core};
#[derive(Default, Clone)]
pub(crate) struct HeaderAllowlist {
exact: HashSet<String>,
prefixes: Vec<String>,
}
impl HeaderAllowlist {
#[allow(dead_code)]
pub(crate) fn empty() -> Self {
Self::default()
}
pub(crate) fn parse(raw: &str) -> Self {
let mut exact = HashSet::new();
let mut prefixes: Vec<String> = Vec::new();
for entry in raw.split(',') {
let entry = entry.trim().to_ascii_lowercase();
if entry.is_empty() {
continue;
}
if let Some(prefix) = entry.strip_suffix('*') {
if !prefix.is_empty() {
prefixes.push(prefix.to_string());
}
continue;
}
exact.insert(entry);
}
Self { exact, prefixes }
}
pub(crate) fn matches(&self, name_lower: &str) -> bool {
self.exact.contains(name_lower) || self.prefixes.iter().any(|p| name_lower.starts_with(p))
}
#[allow(dead_code)]
pub(crate) fn is_empty(&self) -> bool {
self.exact.is_empty() && self.prefixes.is_empty()
}
}
const DEFAULT_PERSIST_HEADERS: &[&str] = &[
"content-disposition",
"content-encoding",
"content-language",
"content-md5",
"cache-control",
"expires",
"access-control-allow-origin",
"access-control-allow-methods",
"access-control-allow-headers",
"access-control-allow-credentials",
"access-control-expose-headers",
"access-control-max-age",
"content-security-policy",
"content-security-policy-report-only",
"x-frame-options",
"permissions-policy",
"cross-origin-resource-policy",
"cross-origin-opener-policy",
"cross-origin-embedder-policy",
"referrer-policy",
"x-robots-tag",
];
fn is_default_persisted_header(name_lower: &str) -> bool {
DEFAULT_PERSIST_HEADERS.contains(&name_lower)
}
pub(crate) fn should_persist_for_storage(
name_lower: &str,
user_allow: &HeaderAllowlist,
user_deny: &HeaderAllowlist,
) -> bool {
if is_never_persisted_header(name_lower) {
return false;
}
if user_deny.matches(name_lower) {
return false;
}
if is_default_persisted_header(name_lower) {
return true;
}
user_allow.matches(name_lower)
}
pub(crate) fn apply_meta_headers(
headers: &[(String, String)],
out: &mut Vec<(HeaderName, HeaderValue)>,
) {
for (k, v) in headers {
if is_never_persisted_header(&k.to_ascii_lowercase()) {
continue;
}
let Ok(name) = HeaderName::from_bytes(k.as_bytes()) else {
continue;
};
let Ok(val) = HeaderValue::from_str(v) else {
continue;
};
out.push((name, val));
}
}
const URL_PATH_ENCODE: &AsciiSet = &CONTROLS
.add(b' ')
.add(b'"')
.add(b'#')
.add(b'%')
.add(b'<')
.add(b'>')
.add(b'?')
.add(b'[')
.add(b'\\')
.add(b']')
.add(b'^')
.add(b'`')
.add(b'{')
.add(b'|')
.add(b'}');
pub(crate) fn world_url(world_name: &str) -> String {
format!("/{}", utf8_percent_encode(world_name, URL_PATH_ENCODE))
}
pub(crate) fn apply_world_links(world_name: &str, out: &mut Vec<(HeaderName, HeaderValue)>) {
let monitor = format!("</listen{}>; rel=\"monitor\"", world_url(world_name));
if let Ok(v) = HeaderValue::from_str(&monitor) {
out.push((header::LINK, v));
}
out.push((
header::LINK,
HeaderValue::from_static("</proc/worlds>; rel=\"collection\""),
));
}
pub(crate) fn hmac_etag(hmac: &str) -> String {
format!("hmac-{hmac}")
}
pub(crate) fn body_etag(body: &[u8]) -> String {
format!("sha256-{}", world::sha256_hex(body))
}
pub(crate) fn etag_header(etag: &str) -> HeaderValue {
HeaderValue::from_str(&format!("\"{etag}\""))
.unwrap_or_else(|_| HeaderValue::from_static("\"invalid\""))
}
#[allow(clippy::result_large_err)]
pub(crate) fn check_write_preconditions(
core: &Core,
world_name: &str,
req_headers: &HeaderMap,
) -> Result<(), Response> {
if !req_headers.contains_key(header::IF_MATCH)
&& !req_headers.contains_key(header::IF_NONE_MATCH)
{
return Ok(());
}
let current = core
.read_world_with_etag(world_name)
.map_err(|e| storage_error("precondition read", e))?;
let current_tag = current.as_ref().map(|(_, etag)| etag.clone());
if let Some(h) = req_headers
.get(header::IF_MATCH)
.and_then(|v| v.to_str().ok())
{
let Some(tag) = ¤t_tag else {
return Err(precondition_failed("If-Match requires an existing world"));
};
if !etag_list_strong_matches(h, tag) {
return Err(precondition_failed("If-Match did not match current ETag"));
}
}
if let Some(h) = req_headers
.get(header::IF_NONE_MATCH)
.and_then(|v| v.to_str().ok())
{
if let Some(tag) = ¤t_tag {
if etag_list_weak_matches(h, tag) {
return Err(precondition_failed("If-None-Match matched current ETag"));
}
}
}
Ok(())
}
pub(crate) fn read_not_modified(req_headers: &HeaderMap, current: &str) -> bool {
req_headers
.get(header::IF_NONE_MATCH)
.and_then(|v| v.to_str().ok())
.map(|h| etag_list_weak_matches(h, current))
.unwrap_or(false)
}
pub(crate) fn etag_list_strong_matches(header_value: &str, current: &str) -> bool {
let quoted = format!("\"{current}\"");
header_value
.split(',')
.map(str::trim)
.any(|candidate| candidate == "*" || candidate == quoted.as_str())
}
pub(crate) fn etag_list_weak_matches(header_value: &str, current: &str) -> bool {
let quoted = format!("\"{current}\"");
header_value.split(',').map(str::trim).any(|candidate| {
candidate == "*"
|| candidate == quoted.as_str()
|| candidate
.strip_prefix("W/")
.map(|weak| weak == quoted.as_str())
.unwrap_or(false)
})
}
pub(crate) fn effective_range(
req_headers: &HeaderMap,
len: usize,
current_etag: &str,
) -> Result<Option<(usize, usize)>, ()> {
if let Some(if_range) = req_headers
.get(header::IF_RANGE)
.and_then(|v| v.to_str().ok())
{
if !if_range_strong_matches(if_range, current_etag) {
return Ok(None);
}
}
parse_range(req_headers, len)
}
pub(crate) fn parse_range(
req_headers: &HeaderMap,
len: usize,
) -> Result<Option<(usize, usize)>, ()> {
let Some(raw) = req_headers.get(header::RANGE).and_then(|v| v.to_str().ok()) else {
return Ok(None);
};
let Some(spec) = raw.trim().strip_prefix("bytes=") else {
return Err(());
};
if spec.contains(',') {
return Ok(None);
}
let Some((left, right)) = spec.split_once('-') else {
return Err(());
};
if len == 0 {
return Err(());
}
if left.is_empty() {
let suffix: usize = right.parse().map_err(|_| ())?;
if suffix == 0 {
return Err(());
}
let take = suffix.min(len);
return Ok(Some((len - take, len - 1)));
}
let start: usize = left.parse().map_err(|_| ())?;
if start >= len {
return Err(());
}
let end = if right.is_empty() {
len - 1
} else {
right.parse().map_err(|_| ())?
};
if end < start {
return Err(());
}
Ok(Some((start, end.min(len - 1))))
}
pub(crate) fn if_range_strong_matches(header_value: &str, current: &str) -> bool {
header_value.trim() == format!("\"{current}\"")
}
pub(crate) fn request_content_type(headers: &HeaderMap) -> String {
headers
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(str::trim)
.filter(|v| !v.is_empty())
.unwrap_or("application/octet-stream")
.to_owned()
}
pub(crate) fn request_meta_headers(
headers: &HeaderMap,
user_allow: &HeaderAllowlist,
user_deny: &HeaderAllowlist,
) -> Vec<(String, String)> {
let mut out = BTreeMap::new();
for (k, v) in headers {
let name = k.as_str().to_ascii_lowercase();
if should_persist_for_storage(&name, user_allow, user_deny) {
if let Ok(val) = v.to_str() {
out.insert(name, val.to_string());
}
}
}
out.into_iter().collect()
}
pub(crate) fn is_never_persisted_header(name_lower: &str) -> bool {
let name = name_lower;
name.starts_with("sec-")
|| name.starts_with("access-control-request-")
|| name.starts_with("want-")
|| name.starts_with(":")
|| name.starts_with("x-b3-")
|| name.starts_with("x-amzn-")
|| name.starts_with("cf-")
|| matches!(
name,
"authorization"
| "proxy-authorization"
| "cookie"
| "set-cookie"
| "host"
| "connection"
| "keep-alive"
| "proxy-authenticate"
| "proxy-connection"
| "te"
| "trailer"
| "transfer-encoding"
| "upgrade"
| "http2-settings"
| "accept"
| "accept-charset"
| "accept-encoding"
| "accept-language"
| "expect"
| "from"
| "max-forwards"
| "origin"
| "prefer"
| "range"
| "referer"
| "referrer"
| "dnt"
| "user-agent"
| "if-match"
| "if-none-match"
| "if-range"
| "if-modified-since"
| "if-unmodified-since"
| "device-memory"
| "downlink"
| "dpr"
| "ect"
| "rtt"
| "save-data"
| "width"
| "viewport-width"
| "accept-ch"
| "alt-used"
| "attribution-reporting-eligible"
| "available-dictionary"
| "dictionary-id"
| "early-data"
| "idempotency-key"
| "service-worker"
| "service-worker-navigation-preload"
| "upgrade-insecure-requests"
| "alt-svc"
| "server-timing"
| "retry-after"
| "x-powered-by"
| "preference-applied"
| "priority"
| "critical-ch"
| "clear-site-data"
| "content-type"
| "content-length"
| "etag"
| "accept-ranges"
| "content-range"
| "link"
| "location"
| "allow"
| "date"
| "server"
| "www-authenticate"
| "age"
| "vary"
| "x-request-id"
| "x-elapsed-us"
| "x-elapsed-ms"
| "x-content-type-options"
| "forwarded"
| "via"
| "x-forwarded-for"
| "x-forwarded-host"
| "x-forwarded-proto"
| "x-real-ip"
| "true-client-ip"
| "client-ip"
| "traceparent"
| "tracestate"
| "baggage"
| "b3"
| "http3-settings"
| "pragma"
)
}
pub(crate) fn not_modified(world_name: &str, etag: &str, stage: &Stage) -> Response {
let mut headers = vec![
(header::ETAG, etag_header(etag)),
(
header::CONTENT_TYPE,
HeaderValue::from_str(&stage.content_type)
.unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")),
),
(header::ACCEPT_RANGES, HeaderValue::from_static("bytes")),
];
apply_world_links(world_name, &mut headers);
apply_meta_headers(&stage.headers, &mut headers);
(StatusCode::NOT_MODIFIED, to_header_map(headers), "").into_response()
}
pub(crate) fn range_not_satisfiable(len: usize) -> Response {
let headers = vec![
(
header::CONTENT_TYPE,
HeaderValue::from_static("text/plain; charset=utf-8"),
),
(header::ACCEPT_RANGES, HeaderValue::from_static("bytes")),
(
header::CONTENT_RANGE,
HeaderValue::from_str(&format!("bytes */{len}")).unwrap(),
),
];
(
StatusCode::RANGE_NOT_SATISFIABLE,
to_header_map(headers),
"range not satisfiable\n",
)
.into_response()
}