use std::collections::BTreeMap;
use std::fmt;
use std::fs::File;
use std::io::{Read, Seek, SeekFrom};
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::path::Path;
use std::sync::Arc;
use tauri::http::response::Builder;
use tauri::http::{header, Method, Request, Response, StatusCode};
use crate::broker::PathBroker;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StreamEventKind {
Resolved { size: u64 },
Head {
size: u64,
range: Option<(u64, u64)>,
},
RangeServed { start: u64, end: u64, size: u64 },
FullServed { size: u64 },
RangeRejected { size: u64 },
BytesRead { bytes: u64, ranged: bool },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StreamEvent {
pub mime: &'static str,
pub kind: StreamEventKind,
}
#[derive(Clone)]
pub struct StreamObserver(Arc<dyn Fn(&StreamEvent) + Send + Sync>);
impl StreamObserver {
pub fn new(f: impl Fn(&StreamEvent) + Send + Sync + 'static) -> Self {
Self(Arc::new(f))
}
fn emit(&self, mime: &'static str, kind: StreamEventKind) {
let event = StreamEvent { mime, kind };
let _ = catch_unwind(AssertUnwindSafe(|| (self.0)(&event)));
}
}
impl fmt::Debug for StreamObserver {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("StreamObserver(..)")
}
}
fn emit(observer: &Option<StreamObserver>, mime: &'static str, kind: StreamEventKind) {
if let Some(o) = observer {
o.emit(mime, kind);
}
}
#[derive(Debug, Clone)]
pub struct StreamPolicy {
pub max_bytes: u64,
pub extensions: Option<Vec<String>>,
pub max_bytes_by_kind: BTreeMap<String, u64>,
pub observer: Option<StreamObserver>,
}
impl Default for StreamPolicy {
fn default() -> Self {
Self {
max_bytes: 150 * 1024 * 1024,
extensions: None,
max_bytes_by_kind: BTreeMap::new(),
observer: None,
}
}
}
impl StreamPolicy {
pub fn with_kind_cap(mut self, kind: &str, max_bytes: u64) -> Self {
self.max_bytes_by_kind
.insert(normalize_kind(kind), max_bytes);
self
}
pub fn observer(mut self, f: impl Fn(&StreamEvent) + Send + Sync + 'static) -> Self {
self.observer = Some(StreamObserver::new(f));
self
}
pub fn max_bytes_for(&self, path: &Path) -> u64 {
if self.max_bytes_by_kind.is_empty() {
return self.max_bytes;
}
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase());
if let Some(cap) = ext.as_deref().and_then(|e| self.max_bytes_by_kind.get(e)) {
return *cap;
}
let Some(mime) = mime_for(path) else {
return self.max_bytes;
};
let mime = mime.split(';').next().unwrap_or(mime).trim();
if let Some(cap) = self.max_bytes_by_kind.get(mime) {
return *cap;
}
let family = mime.split('/').next().unwrap_or(mime);
self.max_bytes_by_kind
.get(family)
.copied()
.unwrap_or(self.max_bytes)
}
}
fn normalize_kind(kind: &str) -> String {
kind.trim()
.trim_start_matches('.')
.trim_end_matches("/*")
.trim_end_matches('/')
.to_ascii_lowercase()
}
pub fn mime_for(path: &Path) -> Option<&'static str> {
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
Some(match ext.as_str() {
"pdf" => "application/pdf",
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"avif" => "image/avif",
"svg" => "image/svg+xml",
"bmp" => "image/bmp",
"ico" => "image/x-icon",
"tif" | "tiff" => "image/tiff",
"mp3" => "audio/mpeg",
"wav" => "audio/wav",
"m4a" => "audio/mp4",
"flac" => "audio/flac",
"ogg" | "oga" => "audio/ogg",
"mp4" | "m4v" => "video/mp4",
"mov" => "video/quicktime",
"webm" => "video/webm",
"txt" | "log" => "text/plain; charset=utf-8",
"md" => "text/markdown; charset=utf-8",
"json" => "application/json",
"csv" => "text/csv; charset=utf-8",
"bin" | "dat" => "application/octet-stream",
_ => return None,
})
}
pub(crate) fn internal_error() -> Response<Vec<u8>> {
let mut r = Response::new(Vec::new());
*r.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
r
}
pub(crate) fn finish(b: Builder, body: Vec<u8>) -> Response<Vec<u8>> {
b.body(body).unwrap_or_else(|_| internal_error())
}
pub(crate) fn cors(status: StatusCode) -> Builder {
Response::builder()
.status(status)
.header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
.header(header::ACCESS_CONTROL_ALLOW_METHODS, "GET, HEAD, OPTIONS")
.header(header::ACCESS_CONTROL_ALLOW_HEADERS, "Range")
.header(
header::ACCESS_CONTROL_EXPOSE_HEADERS,
"Accept-Ranges, Content-Length, Content-Range",
)
}
pub(crate) fn ranged(status: StatusCode, mime: &str, len: u64) -> Builder {
Response::builder()
.status(status)
.header(header::CONTENT_TYPE, mime)
.header(header::ACCEPT_RANGES, "bytes")
.header(header::CONTENT_LENGTH, len.to_string())
.header(header::CACHE_CONTROL, "no-store")
.header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
.header(
header::ACCESS_CONTROL_EXPOSE_HEADERS,
"Accept-Ranges, Content-Length, Content-Range",
)
}
pub(crate) fn empty(status: StatusCode) -> Response<Vec<u8>> {
finish(cors(status), Vec::new())
}
pub(crate) fn method_gate_status(method: &Method) -> Option<StatusCode> {
if method == Method::OPTIONS {
return Some(StatusCode::NO_CONTENT);
}
if method != Method::GET && method != Method::HEAD {
return Some(StatusCode::METHOD_NOT_ALLOWED);
}
None
}
pub fn parse_range(value: &str, len: u64) -> Option<(u64, u64)> {
let spec = value.strip_prefix("bytes=")?;
if spec.contains(',') || len == 0 {
return None;
}
let (a, b) = spec.split_once('-')?;
if a.is_empty() {
let suffix = b.parse::<u64>().ok()?;
if suffix == 0 {
return None;
}
return Some((len.saturating_sub(suffix), len - 1));
}
let start = a.parse::<u64>().ok()?;
if start >= len {
return None;
}
let end = if b.is_empty() {
len - 1
} else {
b.parse::<u64>().ok()?.min(len - 1)
};
(start <= end).then_some((start, end))
}
pub(crate) fn read_range(path: &Path, start: u64, end: u64) -> std::io::Result<Vec<u8>> {
let mut f = File::open(path)?;
f.seek(SeekFrom::Start(start))?;
let mut buf = vec![0; (end - start + 1) as usize];
f.read_exact(&mut buf)?;
Ok(buf)
}
pub fn serve<F>(broker: &PathBroker, policy: &StreamPolicy, request: &Request<Vec<u8>>, respond: F)
where
F: FnOnce(Response<Vec<u8>>) + Send + 'static,
{
if let Some(status) = method_gate_status(request.method()) {
return respond(empty(status));
}
let id = request.uri().path().trim_start_matches('/').trim();
if id.is_empty() {
return respond(empty(StatusCode::BAD_REQUEST));
}
let Ok(target) = broker.resolve_stream(id) else {
return respond(empty(StatusCode::FORBIDDEN));
};
let path = target.path;
let allowed_ext = policy.extensions.as_ref().is_none_or(|exts| {
path.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| exts.iter().any(|x| x.eq_ignore_ascii_case(e)))
});
let mime = match mime_for(&path) {
Some(m) if allowed_ext => m,
_ => return respond(empty(StatusCode::UNSUPPORTED_MEDIA_TYPE)),
};
let Ok(meta) = std::fs::metadata(&path) else {
return respond(empty(StatusCode::NOT_FOUND));
};
if !meta.is_file() || meta.len() > policy.max_bytes_for(&path) {
return respond(empty(StatusCode::PAYLOAD_TOO_LARGE));
}
let total = meta.len();
let head = method_is_head(request);
let observer = &policy.observer;
emit(observer, mime, StreamEventKind::Resolved { size: total });
if let Some(range) = request.headers().get(header::RANGE) {
let parsed = range.to_str().ok().and_then(|r| parse_range(r, total));
let Some((start, end)) = parsed else {
emit(
observer,
mime,
StreamEventKind::RangeRejected { size: total },
);
return respond(finish(
cors(StatusCode::RANGE_NOT_SATISFIABLE)
.header(header::CONTENT_RANGE, format!("bytes */{total}")),
Vec::new(),
));
};
let builder = ranged(StatusCode::PARTIAL_CONTENT, mime, end - start + 1).header(
header::CONTENT_RANGE,
format!("bytes {start}-{end}/{total}"),
);
if head {
emit(
observer,
mime,
StreamEventKind::Head {
size: total,
range: Some((start, end)),
},
);
return respond(finish(builder, Vec::new()));
}
emit(
observer,
mime,
StreamEventKind::RangeServed {
start,
end,
size: total,
},
);
let observer = observer.clone();
std::thread::spawn(move || {
respond(match read_range(&path, start, end) {
Ok(bytes) => {
emit(
&observer,
mime,
StreamEventKind::BytesRead {
bytes: bytes.len() as u64,
ranged: true,
},
);
finish(builder, bytes)
}
Err(_) => empty(StatusCode::NOT_FOUND),
})
});
return;
}
if head {
emit(
observer,
mime,
StreamEventKind::Head {
size: total,
range: None,
},
);
return respond(finish(ranged(StatusCode::OK, mime, total), Vec::new()));
}
emit(observer, mime, StreamEventKind::FullServed { size: total });
let observer = observer.clone();
std::thread::spawn(move || {
respond(match std::fs::read(&path) {
Ok(bytes) => {
emit(
&observer,
mime,
StreamEventKind::BytesRead {
bytes: bytes.len() as u64,
ranged: false,
},
);
finish(ranged(StatusCode::OK, mime, bytes.len() as u64), bytes)
}
Err(_) => empty(StatusCode::NOT_FOUND),
})
});
}
fn method_is_head<B>(request: &Request<B>) -> bool {
request.method() == Method::HEAD
}