use super::*;
pub(super) fn negotiate_encoding<'a>(
entry: &'a FileEntry,
req_headers: &HeaderMap,
) -> Option<(&'a str, &'a boatramp_core::deploy::Variant)> {
if entry.variants.is_empty() {
return None;
}
let accept = req_headers
.get(header::ACCEPT_ENCODING)
.and_then(|value| value.to_str().ok())?;
for enc in ["br", "gzip"] {
if accepts_encoding(accept, enc) {
if let Some(variant) = entry.variants.get(enc) {
if variant.size < entry.size {
return Some((enc, variant));
}
}
}
}
None
}
#[cfg(feature = "compression")]
fn is_compressible(content_type: &str) -> bool {
let ct = content_type.split(';').next().unwrap_or("").trim();
matches!(
ct,
"application/javascript"
| "application/json"
| "application/manifest+json"
| "application/xml"
| "application/rss+xml"
| "application/atom+xml"
| "image/svg+xml"
| "application/wasm"
) || ct.starts_with("text/")
}
#[cfg(feature = "compression")]
pub(super) fn maybe_compress(
response: Response,
accept_encoding: Option<&str>,
min_size: u64,
) -> Response {
use tokio_util::io::{ReaderStream, StreamReader};
if response.status() != StatusCode::OK {
return response;
}
let headers = response.headers();
if headers.contains_key(header::CONTENT_ENCODING) || headers.contains_key(header::SET_COOKIE) {
return response;
}
let compressible = headers
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.is_some_and(is_compressible);
if !compressible {
return response;
}
if let Some(len) = headers
.get(header::CONTENT_LENGTH)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
{
if len < min_size {
return response;
}
}
let accept = accept_encoding.unwrap_or("");
let encoding = if accepts_encoding(accept, "gzip") {
"gzip"
} else if accepts_encoding(accept, "br") {
"br"
} else {
return response;
};
let (mut parts, body) = response.into_parts();
let reader = StreamReader::new(
body.into_data_stream()
.map(|chunk| chunk.map_err(std::io::Error::other)),
);
let compressed = if encoding == "gzip" {
Body::from_stream(ReaderStream::new(
async_compression::tokio::bufread::GzipEncoder::new(reader),
))
} else {
Body::from_stream(ReaderStream::new(
async_compression::tokio::bufread::BrotliEncoder::new(reader),
))
};
parts.headers.remove(header::CONTENT_LENGTH);
parts
.headers
.insert(header::CONTENT_ENCODING, HeaderValue::from_static(encoding));
append_vary_accept_encoding(&mut parts.headers);
Response::from_parts(parts, compressed)
}
#[cfg(feature = "compression")]
fn append_vary_accept_encoding(headers: &mut HeaderMap) {
let existing = headers
.get(header::VARY)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if existing
.split(',')
.any(|t| t.trim().eq_ignore_ascii_case("accept-encoding"))
{
return;
}
let merged = if existing.is_empty() {
"accept-encoding".to_string()
} else {
format!("{existing}, accept-encoding")
};
if let Ok(value) = HeaderValue::from_str(&merged) {
headers.insert(header::VARY, value);
}
}
fn accepts_encoding(accept: &str, enc: &str) -> bool {
accept.split(',').any(|part| {
let mut bits = part.trim().split(';');
let token = bits.next().unwrap_or("").trim();
let refused = bits.any(|p| matches!(p.trim(), "q=0" | "q=0.0" | "q=0.00" | "q=0.000"));
!refused && (token.eq_ignore_ascii_case(enc) || token == "*")
})
}
pub(super) fn set_content_encoding(headers: &mut HeaderMap, encoding: Option<&str>) {
if let Some(enc) = encoding {
set_header(headers, header::CONTENT_ENCODING, enc);
}
}
pub(super) fn response_headers(
config: &DeployConfig,
request_path: &str,
served_path: &str,
entry: &FileEntry,
etag: &str,
) -> HeaderMap {
let mut headers = HeaderMap::new();
let content_type = mime_override(config, served_path).or_else(|| entry.content_type.clone());
if let Some(value) = content_type
.as_deref()
.and_then(|ct| HeaderValue::from_str(ct).ok())
{
headers.insert(header::CONTENT_TYPE, value);
}
set_header(&mut headers, header::ETAG, etag);
headers.insert(header::ACCEPT_RANGES, HeaderValue::from_static("bytes"));
headers.insert(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
);
headers.insert(
header::REFERRER_POLICY,
HeaderValue::from_static("strict-origin-when-cross-origin"),
);
if !entry.variants.is_empty() {
headers.insert(header::VARY, HeaderValue::from_static("accept-encoding"));
}
let cache = config
.cache
.default
.as_deref()
.or_else(|| route::cache_control_default(served_path, content_type.as_deref()));
if let Some(cache) = cache {
set_header(&mut headers, header::CACHE_CONTROL, cache);
}
apply_header_rules(config, request_path, &mut headers);
headers
}
fn apply_header_rules(config: &DeployConfig, request_path: &str, headers: &mut HeaderMap) {
for rule in &config.headers {
let matches = Pattern::compile(&rule.matches)
.ok()
.is_some_and(|pattern| pattern.is_match(request_path));
if !matches {
continue;
}
for name in &rule.unset {
if let Ok(name) = HeaderName::from_bytes(name.as_bytes()) {
headers.remove(name);
}
}
for (key, value) in &rule.set {
if let (Ok(name), Ok(value)) = (
HeaderName::from_bytes(key.as_bytes()),
HeaderValue::from_str(value),
) {
headers.insert(name, value);
}
}
}
}
fn mime_override(config: &DeployConfig, served_path: &str) -> Option<String> {
let ext = std::path::Path::new(served_path).extension()?.to_str()?;
config.mime_overrides.get(&format!(".{ext}")).cloned()
}
pub(super) const MAX_RANGES: usize = 64;
pub(super) fn parse_ranges(spec: &str, total: u64) -> Option<Vec<(u64, u64)>> {
let spec = spec.strip_prefix("bytes=")?;
let mut out = Vec::new();
let mut saw_range = false;
for part in spec.split(',') {
let part = part.trim();
if part.is_empty() {
continue;
}
saw_range = true;
if let Some(range) = parse_one_range(part, total) {
out.push(range);
}
}
if !saw_range || out.is_empty() {
return None;
}
Some(out)
}
fn parse_one_range(part: &str, total: u64) -> Option<(u64, u64)> {
let (start, end) = part.split_once('-')?;
if start.is_empty() {
let suffix: u64 = end.parse().ok()?;
if suffix == 0 || total == 0 {
return None;
}
let len = suffix.min(total);
return Some((total - len, len));
}
let start: u64 = start.parse().ok()?;
if start >= total {
return None;
}
let end = if end.is_empty() {
total - 1
} else {
end.parse::<u64>().ok()?.min(total - 1)
};
if end < start {
return None;
}
Some((start, end - start + 1))
}
pub(super) async fn multipart_byteranges(
deploy: &DeployStore,
config: &DeployConfig,
request_path: &str,
served_path: &str,
entry: &FileEntry,
etag: &str,
ranges: &[(u64, u64)],
) -> Response {
let total = entry.size;
let boundary = format!("boatramp{}", &entry.hash[..entry.hash.len().min(24)]);
let part_ct = mime_override(config, served_path).or_else(|| entry.content_type.clone());
let part_ct_line = match &part_ct {
Some(ct) => format!("Content-Type: {ct}\r\n"),
None => String::new(),
};
let mut segments: Vec<boatramp_core::ByteStream> = Vec::with_capacity(ranges.len() * 2 + 1);
let mut content_length: u64 = 0;
for &(offset, len) in ranges {
let header = format!(
"\r\n--{boundary}\r\n{part_ct_line}Content-Range: bytes {}-{}/{}\r\n\r\n",
offset,
offset + len - 1,
total
);
content_length += header.len() as u64 + len;
let object = match deploy.open_blob_range(&entry.hash, offset, Some(len)).await {
Ok(object) => object,
Err(err) => return deploy_error_response(err),
};
segments.push(futures::stream::once(async move { Ok(bytes::Bytes::from(header)) }).boxed());
segments.push(object.body);
}
let closing = format!("\r\n--{boundary}--\r\n");
content_length += closing.len() as u64;
segments.push(futures::stream::once(async move { Ok(bytes::Bytes::from(closing)) }).boxed());
let mut headers = response_headers(config, request_path, served_path, entry, etag);
set_header(
&mut headers,
header::CONTENT_TYPE,
&format!("multipart/byteranges; boundary={boundary}"),
);
set_header(
&mut headers,
header::CONTENT_LENGTH,
&content_length.to_string(),
);
let body = futures::stream::iter(segments).flatten();
(
StatusCode::PARTIAL_CONTENT,
headers,
Body::from_stream(body),
)
.into_response()
}