389 lines
13 KiB
Rust
389 lines
13 KiB
Rust
use super::{
|
|
config::CompiledCacheMatcher,
|
|
filesystem::{DirectoryEntry, Opened},
|
|
htaccess,
|
|
CompiledService,
|
|
};
|
|
use axum::{body::Body, response::Response};
|
|
use http::{
|
|
header::{
|
|
ACCEPT_RANGES, CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING,
|
|
CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH,
|
|
IF_RANGE, LAST_MODIFIED, LOCATION, RANGE, VARY,
|
|
},
|
|
HeaderMap, HeaderValue, Method, StatusCode, Uri,
|
|
};
|
|
use std::{
|
|
io,
|
|
path::Path,
|
|
time::{Duration, SystemTime, UNIX_EPOCH},
|
|
};
|
|
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
|
use tokio_util::io::ReaderStream;
|
|
|
|
pub(super) fn status_response(status: StatusCode, message: &'static str) -> Response {
|
|
let mut response = Response::new(Body::from(message));
|
|
*response.status_mut() = status;
|
|
response.headers_mut().insert(
|
|
CONTENT_TYPE,
|
|
HeaderValue::from_static("text/plain; charset=utf-8"),
|
|
);
|
|
response
|
|
}
|
|
|
|
pub(super) fn builtin_error_response(status: StatusCode, method: &Method) -> Response {
|
|
let html = crate::pages::builtin_gateway_error(status)
|
|
.unwrap_or("<!doctype html><html><body><h1>Error</h1></body></html>");
|
|
let mut response = Response::new(if *method == Method::HEAD {
|
|
Body::empty()
|
|
} else {
|
|
Body::from(html)
|
|
});
|
|
*response.status_mut() = status;
|
|
response.headers_mut().insert(
|
|
CONTENT_TYPE,
|
|
HeaderValue::from_static("text/html; charset=utf-8"),
|
|
);
|
|
if let Ok(length) = HeaderValue::from_str(&html.len().to_string()) {
|
|
response.headers_mut().insert(CONTENT_LENGTH, length);
|
|
}
|
|
response
|
|
.headers_mut()
|
|
.insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
|
response.headers_mut().insert(
|
|
"x-content-type-options",
|
|
HeaderValue::from_static("nosniff"),
|
|
);
|
|
response
|
|
}
|
|
|
|
pub(super) fn io_response(error: &io::Error) -> Response {
|
|
#[cfg(unix)]
|
|
if matches!(error.raw_os_error(), Some(libc::ELOOP)) {
|
|
return status_response(StatusCode::FORBIDDEN, "RustProxy: Web Server path is not allowed\n");
|
|
}
|
|
match error.kind() {
|
|
io::ErrorKind::NotFound | io::ErrorKind::NotADirectory => {
|
|
status_response(StatusCode::NOT_FOUND, "RustProxy: Web Server resource not found\n")
|
|
}
|
|
io::ErrorKind::PermissionDenied | io::ErrorKind::InvalidInput => {
|
|
status_response(StatusCode::FORBIDDEN, "RustProxy: Web Server path is not allowed\n")
|
|
}
|
|
_ => status_response(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"RustProxy: Web Server filesystem error\n",
|
|
),
|
|
}
|
|
}
|
|
|
|
fn builtin_content_type(path: &Path) -> Option<&'static str> {
|
|
let ext = path
|
|
.extension()
|
|
.and_then(|value| value.to_str())
|
|
.unwrap_or("")
|
|
.to_ascii_lowercase();
|
|
Some(match ext.as_str() {
|
|
"html" | "htm" => "text/html; charset=utf-8",
|
|
"css" => "text/css; charset=utf-8",
|
|
"js" | "mjs" | "cjs" => "text/javascript; charset=utf-8",
|
|
"txt" | "log" | "md" => "text/plain; charset=utf-8",
|
|
"csv" => "text/csv; charset=utf-8",
|
|
"tsv" => "text/tab-separated-values; charset=utf-8",
|
|
"json" | "map" => "application/json; charset=utf-8",
|
|
"yaml" | "yml" => "application/yaml; charset=utf-8",
|
|
"xml" => "application/xml; charset=utf-8",
|
|
"rss" => "application/rss+xml; charset=utf-8",
|
|
"atom" => "application/atom+xml; charset=utf-8",
|
|
"ics" => "text/calendar; charset=utf-8",
|
|
"vcf" => "text/vcard; charset=utf-8",
|
|
"pdf" => "application/pdf",
|
|
"zip" => "application/zip",
|
|
"7z" => "application/x-7z-compressed",
|
|
"rar" => "application/vnd.rar",
|
|
"gz" => "application/gzip",
|
|
"bz2" => "application/x-bzip2",
|
|
"xz" => "application/x-xz",
|
|
"tar" => "application/x-tar",
|
|
"wasm" => "application/wasm",
|
|
"webmanifest" => "application/manifest+json; charset=utf-8",
|
|
"svg" => "image/svg+xml",
|
|
"png" => "image/png",
|
|
"apng" => "image/apng",
|
|
"jpg" | "jpeg" => "image/jpeg",
|
|
"gif" => "image/gif",
|
|
"webp" => "image/webp",
|
|
"avif" => "image/avif",
|
|
"heic" => "image/heic",
|
|
"heif" => "image/heif",
|
|
"jxl" => "image/jxl",
|
|
"ico" => "image/x-icon",
|
|
"bmp" => "image/bmp",
|
|
"mp3" => "audio/mpeg",
|
|
"m4a" => "audio/mp4",
|
|
"ogg" | "oga" => "audio/ogg",
|
|
"wav" => "audio/wav",
|
|
"flac" => "audio/flac",
|
|
"mp4" | "m4v" => "video/mp4",
|
|
"mov" => "video/quicktime",
|
|
"mpeg" | "mpg" => "video/mpeg",
|
|
"avi" => "video/x-msvideo",
|
|
"webm" => "video/webm",
|
|
"ogv" => "video/ogg",
|
|
"woff" => "font/woff",
|
|
"woff2" => "font/woff2",
|
|
"ttf" => "font/ttf",
|
|
"otf" => "font/otf",
|
|
"eot" => "application/vnd.ms-fontobject",
|
|
"doc" => "application/msword",
|
|
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
"xls" => "application/vnd.ms-excel",
|
|
"xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
"ppt" => "application/vnd.ms-powerpoint",
|
|
"pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
_ => return None,
|
|
})
|
|
}
|
|
|
|
fn content_type(service: &CompiledService, path: &Path) -> HeaderValue {
|
|
if let Some(value) = htaccess::mime_type(service.htaccess.as_ref(), path) {
|
|
return value;
|
|
}
|
|
let extension = path
|
|
.extension()
|
|
.and_then(|value| value.to_str())
|
|
.unwrap_or("")
|
|
.to_ascii_lowercase();
|
|
if let Some(value) = service.mime_types.get(&extension) {
|
|
return value.clone();
|
|
}
|
|
if let Some(value) = builtin_content_type(path) {
|
|
return HeaderValue::from_static(value);
|
|
}
|
|
service.default_mime_type.clone()
|
|
}
|
|
|
|
fn cache_control_for(
|
|
service: &CompiledService,
|
|
path: &Path,
|
|
content_type: &HeaderValue,
|
|
) -> Option<HeaderValue> {
|
|
let extension = path
|
|
.extension()
|
|
.and_then(|value| value.to_str())
|
|
.unwrap_or("")
|
|
.to_ascii_lowercase();
|
|
let mime = content_type
|
|
.to_str()
|
|
.unwrap_or("")
|
|
.to_ascii_lowercase();
|
|
for rule in &service.cache_rules {
|
|
let matches = match &rule.matcher {
|
|
CompiledCacheMatcher::Extension(value) => extension == *value,
|
|
CompiledCacheMatcher::MimePrefix(value) => mime.starts_with(value),
|
|
};
|
|
if matches {
|
|
return Some(rule.cache_control.clone());
|
|
}
|
|
}
|
|
service.cache_control.clone()
|
|
}
|
|
|
|
fn add_cache_control(
|
|
response: &mut Response,
|
|
service: &CompiledService,
|
|
path: &Path,
|
|
content_type: &HeaderValue,
|
|
) {
|
|
if let Some(value) = cache_control_for(service, path, content_type) {
|
|
response.headers_mut().insert(CACHE_CONTROL, value);
|
|
}
|
|
}
|
|
|
|
pub(super) fn add_service_headers(response: &mut Response, service: &CompiledService) {
|
|
for (name, value) in &service.response_headers {
|
|
response.headers_mut().insert(name.clone(), value.clone());
|
|
}
|
|
}
|
|
|
|
fn download_extension(service: &CompiledService, path: &Path) -> bool {
|
|
path.extension()
|
|
.and_then(|value| value.to_str())
|
|
.map(|value| value.to_ascii_lowercase())
|
|
.is_some_and(|value| service.download_extensions.contains(&value))
|
|
}
|
|
|
|
fn add_download_header(response: &mut Response, service: &CompiledService, path: &Path) {
|
|
if !download_extension(service, path) {
|
|
return;
|
|
}
|
|
let filename = path.file_name().and_then(|value| value.to_str()).unwrap_or("download");
|
|
let value = format!("attachment; filename*=UTF-8''{}", percent_encode_segment(filename));
|
|
if let Ok(value) = HeaderValue::from_str(&value) {
|
|
response.headers_mut().insert(CONTENT_DISPOSITION, value);
|
|
}
|
|
}
|
|
|
|
pub(super) fn accepts_content_encoding(headers: &HeaderMap, wanted: &str) -> bool {
|
|
let Some(value) = headers
|
|
.get(http::header::ACCEPT_ENCODING)
|
|
.and_then(|value| value.to_str().ok())
|
|
else {
|
|
return false;
|
|
};
|
|
let mut wildcard = None;
|
|
for item in value.split(',') {
|
|
let mut parts = item.trim().split(';');
|
|
let encoding = parts.next().unwrap_or("").trim();
|
|
let mut q = 1.0f32;
|
|
for parameter in parts {
|
|
if let Some(value) = parameter.trim().strip_prefix("q=") {
|
|
q = value.parse::<f32>().unwrap_or(0.0);
|
|
}
|
|
}
|
|
if encoding.eq_ignore_ascii_case(wanted) {
|
|
return q > 0.0;
|
|
}
|
|
if encoding == "*" {
|
|
wildcard = Some(q > 0.0);
|
|
}
|
|
}
|
|
wildcard.unwrap_or(false)
|
|
}
|
|
|
|
fn metadata_validators(metadata: &std::fs::Metadata) -> (Option<String>, Option<SystemTime>) {
|
|
let modified = metadata.modified().ok();
|
|
let etag = modified.and_then(|modified| {
|
|
modified.duration_since(UNIX_EPOCH).ok().map(|stamp| {
|
|
format!(
|
|
"\"{:x}-{:x}-{:x}\"",
|
|
metadata.len(),
|
|
stamp.as_secs(),
|
|
stamp.subsec_nanos()
|
|
)
|
|
})
|
|
});
|
|
(etag, modified)
|
|
}
|
|
|
|
fn weak_etag_value(value: &str) -> &str {
|
|
value.trim().strip_prefix("W/").unwrap_or(value.trim())
|
|
}
|
|
|
|
fn if_none_match_matches(value: &HeaderValue, etag: &str) -> bool {
|
|
value.to_str().ok().is_some_and(|value| {
|
|
value.split(',').any(|candidate| {
|
|
let candidate = candidate.trim();
|
|
candidate == "*" || weak_etag_value(candidate) == weak_etag_value(etag)
|
|
})
|
|
})
|
|
}
|
|
|
|
fn second_precision(value: SystemTime) -> Option<SystemTime> {
|
|
value
|
|
.duration_since(UNIX_EPOCH)
|
|
.ok()
|
|
.map(|duration| UNIX_EPOCH + Duration::from_secs(duration.as_secs()))
|
|
}
|
|
|
|
fn not_modified(headers: &HeaderMap, etag: Option<&str>, modified: Option<SystemTime>) -> bool {
|
|
if let (Some(etag), Some(value)) = (etag, headers.get(IF_NONE_MATCH)) {
|
|
return if_none_match_matches(value, etag);
|
|
}
|
|
if headers.contains_key(IF_NONE_MATCH) {
|
|
return false;
|
|
}
|
|
let Some(modified) = modified.and_then(second_precision) else {
|
|
return false;
|
|
};
|
|
headers
|
|
.get(IF_MODIFIED_SINCE)
|
|
.and_then(|value| value.to_str().ok())
|
|
.and_then(|value| httpdate::parse_http_date(value).ok())
|
|
.is_some_and(|since| modified <= since)
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
struct ByteRange {
|
|
start: u64,
|
|
end: u64,
|
|
}
|
|
|
|
const MAX_BYTE_RANGES: usize = 16;
|
|
|
|
fn parse_range_item(value: &str, length: u64) -> Result<Option<ByteRange>, ()> {
|
|
if length == 0 {
|
|
return Ok(None);
|
|
}
|
|
let (start, end) = value.split_once('-').ok_or(())?;
|
|
if start.is_empty() {
|
|
let suffix: u64 = end.parse().map_err(|_| ())?;
|
|
if suffix == 0 {
|
|
return Ok(None);
|
|
}
|
|
let size = suffix.min(length);
|
|
return Ok(Some(ByteRange {
|
|
start: length - size,
|
|
end: length - 1,
|
|
}));
|
|
}
|
|
let start: u64 = start.parse().map_err(|_| ())?;
|
|
if start >= length {
|
|
return Ok(None);
|
|
}
|
|
let end = if end.is_empty() {
|
|
length - 1
|
|
} else {
|
|
end.parse::<u64>().map_err(|_| ())?.min(length - 1)
|
|
};
|
|
Ok((end >= start).then_some(ByteRange { start, end }))
|
|
}
|
|
|
|
fn parse_ranges(value: &str, length: u64) -> Option<Vec<ByteRange>> {
|
|
let value = value.strip_prefix("bytes=")?.trim();
|
|
if value.is_empty() {
|
|
return None;
|
|
}
|
|
let mut ranges = Vec::new();
|
|
for (index, item) in value.split(',').enumerate() {
|
|
if index >= MAX_BYTE_RANGES {
|
|
return None;
|
|
}
|
|
match parse_range_item(item.trim(), length).ok()? {
|
|
Some(range) => ranges.push(range),
|
|
None => continue,
|
|
}
|
|
}
|
|
(!ranges.is_empty()).then_some(ranges)
|
|
}
|
|
|
|
fn if_range_allows(headers: &HeaderMap, etag: Option<&str>, modified: Option<SystemTime>) -> bool {
|
|
let Some(value) = headers.get(IF_RANGE).and_then(|value| value.to_str().ok()) else {
|
|
return true;
|
|
};
|
|
let value = value.trim();
|
|
if value.starts_with('"') || value.starts_with("W/") {
|
|
return etag.is_some_and(|etag| !value.starts_with("W/") && value == etag);
|
|
}
|
|
let Some(modified) = modified.and_then(second_precision) else {
|
|
return false;
|
|
};
|
|
httpdate::parse_http_date(value)
|
|
.ok()
|
|
.is_some_and(|date| modified <= date)
|
|
}
|
|
|
|
fn add_validators(
|
|
response: &mut Response,
|
|
etag: Option<&str>,
|
|
modified: Option<SystemTime>,
|
|
) {
|
|
if let Some(etag) = etag.and_then(|value| HeaderValue::from_str(value).ok()) {
|
|
response.headers_mut().insert(ETAG, etag);
|
|
}
|
|
if let Some(modified) = modified {
|
|
if let Ok(value) = HeaderValue::from_str(&httpdate::fmt_http_date(modified)) {
|
|
response.headers_mut().insert(LAST_MODIFIED, value);
|
|
}
|
|
}
|
|
}
|