use axum::{
http::{
HeaderMap, HeaderName, HeaderValue, StatusCode,
header::{CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, ETAG, IF_NONE_MATCH, VARY},
},
response::{IntoResponse, Response},
};
use fs_err as fs;
use tracing::debug;
use crate::{
options::ServeOptions,
util::{
compression::{compress_brotli, compress_gzip, decompress_brotli},
headers::{encoding_qvalue, if_none_match_matches},
},
};
const BROTLI_ENCODING: &str = "br";
const BROTLI_HEADER: (HeaderName, HeaderValue) =
(CONTENT_ENCODING, HeaderValue::from_static(BROTLI_ENCODING));
const GZIP_ENCODING: &str = "gzip";
const GZIP_HEADER: (HeaderName, HeaderValue) =
(CONTENT_ENCODING, HeaderValue::from_static(GZIP_ENCODING));
const VARY_HEADER: (HeaderName, HeaderValue) = (VARY, HeaderValue::from_static("accept-encoding"));
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OnDemandEncoding {
Identity,
Brotli,
Gzip,
}
#[derive(Debug)]
pub struct Asset {
pub route: &'static str,
pub path: &'static str,
pub etag: &'static str,
pub content_type: &'static str,
pub bytes: Option<&'static [u8]>,
pub is_compressed: bool,
pub should_compress: bool,
}
struct AssetResponse<'t, B> {
options: &'t ServeOptions,
headers: &'t HeaderMap,
status: StatusCode,
asset: &'t Asset,
etag: &'t str,
bytes: B,
bytes_len: usize,
brotli_bytes: B,
brotli_bytes_len: usize,
gzip_bytes: B,
gzip_bytes_len: usize,
}
impl<B: IntoResponse> AssetResponse<'_, B> {
fn into_response(self) -> Response {
let quoted_etag = format!("\"{}\"", self.etag);
let varies_by_encoding =
self.asset.should_compress && (self.options.enable_brotli || self.options.enable_gzip);
let mut headers = HeaderMap::new();
headers.extend([
self.asset.content_type(),
self.asset.cache_control(self.options),
(ETAG, HeaderValue::from_str("ed_etag).unwrap()),
]);
if varies_by_encoding {
headers.extend([VARY_HEADER]);
}
if let Some(if_none_match) = self.headers.get(IF_NONE_MATCH)
&& let Ok(if_none_match) = if_none_match.to_str()
&& if_none_match_matches(if_none_match, "ed_etag)
{
return (StatusCode::NOT_MODIFIED, headers).into_response();
}
let brotli_q = (self.options.enable_brotli && self.brotli_bytes_len > 0)
.then(|| encoding_qvalue(self.headers, BROTLI_ENCODING))
.flatten();
let gzip_q = (self.options.enable_gzip && self.gzip_bytes_len > 0)
.then(|| encoding_qvalue(self.headers, GZIP_ENCODING))
.flatten();
let prefer_brotli = match (brotli_q, gzip_q) {
(Some(br), Some(gz)) => br >= gz,
(Some(_), None) => true,
_ => false,
};
if prefer_brotli {
headers.extend([
(CONTENT_LENGTH, HeaderValue::from(self.brotli_bytes_len)),
BROTLI_HEADER,
]);
return (self.status, headers, self.brotli_bytes).into_response();
}
if gzip_q.is_some() {
headers.extend([
(CONTENT_LENGTH, HeaderValue::from(self.gzip_bytes_len)),
GZIP_HEADER,
]);
return (self.status, headers, self.gzip_bytes).into_response();
}
headers.extend([(CONTENT_LENGTH, HeaderValue::from(self.bytes_len))]);
(self.status, headers, self.bytes).into_response()
}
}
impl Asset {
fn cache_control(&self, options: &ServeOptions) -> (HeaderName, HeaderValue) {
match self.content_type {
"text/html" => options.html_cache_control.as_header(),
_ => options.cache_control.as_header(),
}
}
fn content_type(&self) -> (HeaderName, HeaderValue) {
(CONTENT_TYPE, HeaderValue::from_static(self.content_type))
}
pub(crate) fn leak_bytes(
&self,
options: &'static ServeOptions,
) -> (&'static [u8], &'static [u8], &'static [u8]) {
let mut uncompressed = self.bytes.unwrap_or_default();
if self.is_compressed {
uncompressed = Box::new(decompress_brotli(uncompressed).unwrap_or_default()).leak()
}
let gzip_bytes = if self.should_compress && options.enable_gzip {
Box::new(compress_gzip(uncompressed).unwrap_or_default()).leak()
} else {
Default::default()
};
let brotli_bytes = if self.should_compress && options.enable_brotli {
if self.is_compressed {
self.bytes.unwrap_or_default()
} else {
Box::new(compress_brotli(uncompressed).unwrap_or_default()).leak()
}
} else {
Default::default()
};
(uncompressed, brotli_bytes, gzip_bytes)
}
fn read_source_bytes(&self) -> Result<Vec<u8>, StatusCode> {
fs::read(self.path).map_err(|_| StatusCode::NOT_FOUND)
}
fn negotiate_dynamic_encoding(
&self,
headers: &HeaderMap,
options: &ServeOptions,
) -> OnDemandEncoding {
if !self.should_compress {
return OnDemandEncoding::Identity;
}
let brotli_q = options
.enable_brotli
.then(|| encoding_qvalue(headers, BROTLI_ENCODING))
.flatten();
let gzip_q = options
.enable_gzip
.then(|| encoding_qvalue(headers, GZIP_ENCODING))
.flatten();
match (brotli_q, gzip_q) {
(Some(br), Some(gz)) if br >= gz => OnDemandEncoding::Brotli,
(Some(_), None) => OnDemandEncoding::Brotli,
(_, Some(_)) => OnDemandEncoding::Gzip,
_ => OnDemandEncoding::Identity,
}
}
fn encode_dynamic_bytes(&self, bytes: &[u8], encoding: OnDemandEncoding) -> (Vec<u8>, Vec<u8>) {
match encoding {
OnDemandEncoding::Brotli => (compress_brotli(bytes).unwrap_or_default(), Vec::new()),
OnDemandEncoding::Gzip => (Vec::new(), compress_gzip(bytes).unwrap_or_default()),
OnDemandEncoding::Identity => (Vec::new(), Vec::new()),
}
}
fn dynamic_handler(
&self,
headers: &HeaderMap,
status: StatusCode,
options: &ServeOptions,
) -> Response {
let bytes = match self.read_source_bytes() {
Ok(bytes) => bytes,
Err(status) => return status.into_response(),
};
let encoding = self.negotiate_dynamic_encoding(headers, options);
let (brotli_bytes, gzip_bytes) = self.encode_dynamic_bytes(&bytes, encoding);
let etag = sha256::digest(&bytes);
AssetResponse {
options,
headers,
status,
asset: self,
etag: &etag,
bytes_len: bytes.len(),
bytes,
brotli_bytes_len: brotli_bytes.len(),
brotli_bytes,
gzip_bytes_len: gzip_bytes.len(),
gzip_bytes,
}
.into_response()
}
pub(super) fn handler(
&self,
headers: &HeaderMap,
status: StatusCode,
bytes: &'static [u8],
brotli_bytes: &'static [u8],
gzip_bytes: &'static [u8],
options: &ServeOptions,
) -> Response {
if bytes.is_empty() {
debug!("using dynamic handler for {}", self.path);
return self.dynamic_handler(headers, status, options);
}
AssetResponse {
options,
headers,
status,
asset: self,
etag: self.etag,
bytes_len: bytes.len(),
bytes,
brotli_bytes_len: brotli_bytes.len(),
brotli_bytes,
gzip_bytes_len: gzip_bytes.len(),
gzip_bytes,
}
.into_response()
}
}