//! A loopback HTTP server for locally downloaded media. //! //! Tauri's built-in `asset` protocol cannot serve a downloaded film to the //! webview. Its response to a request *without* a `Range` header reads the whole //! file into a `Vec`, and it only advertises `Accept-Ranges: bytes` from //! inside the range branch — so the first request never learns ranges are //! available and a multi-gigabyte body is attempted instead. Chromium abandoned //! it with `PIPELINE_ERROR_READ` after ~31s, which reached the user as //! "downloaded video does not play offline". //! //! Serving over real HTTP on 127.0.0.1 rather than a custom URI scheme is //! deliberate: it makes range support a property of the transport instead of //! depending on whether a platform's webview forwards `Range` to a custom //! scheme, which differs between Android and the desktop webviews. //! //! Two things confine it, because **loopback is shared between apps on //! Android** — any other installed app can connect to this port: //! //! - it binds `127.0.0.1` only, so nothing off-device can reach it; and //! - every URL carries a random per-session token, so another app cannot guess a //! working URL, and paths are confined to the app data directory even if one //! did. //! //! Phase 1 serves local files only. The same origin is the intended home for //! remote passthrough (and download-while-watching) later; see the stage-2 spec. //! //! TRACES: UR-071 | DR-137 | UT-127 use std::fs::File; use std::io::{Read, Seek, SeekFrom}; use std::path::{Path, PathBuf}; use std::sync::Arc; use log::{debug, error, info, warn}; use rand::Rng; use tiny_http::{Header, Response, Server, StatusCode}; /// Bytes per response. Large enough that a film needs relatively few round /// trips, small enough that one response is never a memory problem on a phone. /// Tauri's asset protocol uses 1 MiB; 4 MiB quarters the request count for the /// multi-gigabyte files this exists to serve. const CHUNK_LEN: u64 = 4 * 1024 * 1024; /// Managed state. `None` when the server could not bind — local playback then /// fails with a clear error instead of the app refusing to start. pub struct MediaServerWrapper(pub Option); /// A running server. Dropping this does not stop the thread; the server lives /// for the life of the process by design, since playback can start at any time. pub struct MediaServer { port: u16, token: String, } impl MediaServer { /// The base a media URL is built on, e.g. `http://127.0.0.1:53412/`. pub fn base_url(&self) -> String { format!("http://127.0.0.1:{}/{}", self.port, self.token) } /// A playable URL for an absolute on-disk path. pub fn url_for(&self, path: &str) -> String { format!("{}/{}", self.base_url(), urlencoding::encode(path)) } } /// Bind to an ephemeral loopback port and start serving `root` in a background /// thread. /// /// TRACES: UR-071 | DR-137 pub fn start(root: PathBuf) -> Result { // Port 0 → the OS picks a free one. Binding 127.0.0.1 (not 0.0.0.0) keeps // this off the network. let server = Server::http("127.0.0.1:0").map_err(|e| format!("Failed to bind media server: {e}"))?; let port = server .server_addr() .to_ip() .ok_or_else(|| "Media server bound to a non-IP address".to_string())? .port(); let token: String = { let mut rng = rand::thread_rng(); (0..32) .map(|_| char::from_digit(rng.gen_range(0..16), 16).unwrap()) .collect() }; info!( "[MediaServer] Serving {} on 127.0.0.1:{}", root.display(), port ); let server = Arc::new(server); let shared = Arc::new((root, token.clone())); std::thread::Builder::new() .name("media-server".into()) .spawn(move || loop { let request = match server.recv() { Ok(r) => r, Err(e) => { error!("[MediaServer] accept failed: {e}"); continue; } }; let shared = Arc::clone(&shared); // A thread per request: media clients open several connections at // once, and a blocking read of one must not stall the others. if let Err(e) = std::thread::Builder::new() .name("media-server-req".into()) .spawn(move || { let (root, token) = &*shared; handle(request, root, token); }) { error!("[MediaServer] could not spawn handler: {e}"); } }) .map_err(|e| format!("Failed to start media server thread: {e}"))?; Ok(MediaServer { port, token }) } fn header(name: &str, value: &str) -> Header { Header::from_bytes(name.as_bytes(), value.as_bytes()) .expect("static header name/value are valid") } fn empty(status: u16) -> Response { Response::empty(StatusCode(status)).with_header(header("Accept-Ranges", "bytes")) } fn handle(request: tiny_http::Request, root: &Path, token: &str) { let url = request.url().to_string(); let method = request.method().as_str().to_string(); let outcome = match route(&url, root, token) { Ok(path) => path, Err(status) => { let _ = request.respond(empty(status)); return; } }; if method != "GET" && method != "HEAD" { let _ = request.respond(empty(405)); return; } let mut file = match File::open(&outcome) { Ok(f) => f, Err(e) => { warn!("[MediaServer] {}: {}", outcome.display(), e); let _ = request.respond(empty(404)); return; } }; let len = match file.metadata() { Ok(m) => m.len(), Err(e) => { warn!( "[MediaServer] metadata failed for {}: {}", outcome.display(), e ); let _ = request.respond(empty(404)); return; } }; let range = request .headers() .iter() .find(|h| h.field.equiv("Range")) .map(|h| h.value.as_str().to_string()); debug!( "[MediaServer] {} {} ({} bytes) range={:?}", method, outcome.display(), len, range ); let Some(span) = span_for(range.as_deref(), len) else { let _ = request .respond(empty(416).with_header(header("Content-Range", &format!("bytes */{len}")))); return; }; // Sniff before seeking to the span, for extension-less files. let mut head = [0u8; 16]; let head_len = file.read(&mut head).unwrap_or(0); let mime = content_type(&outcome, &head[..head_len]); if method == "HEAD" { let _ = request.respond( empty(200) .with_header(header("Content-Type", mime)) .with_header(header("Content-Length", &len.to_string())), ); return; } if let Err(e) = file.seek(SeekFrom::Start(span.start)) { warn!("[MediaServer] seek failed for {}: {}", outcome.display(), e); let _ = request.respond(empty(500)); return; } // Streamed straight from the file handle: at no point is more than the // span in memory, and the span is capped at CHUNK_LEN. let nbytes = span.len(); let body = file.take(nbytes); // tiny_http switches to chunked transfer above a 32 KiB default, which drops // Content-Length — and a 206 without one is unusable to Chromium's media // loader, which needs the range's size. Raising the threshold past our own // cap keeps every response length-delimited. let response = Response::new( StatusCode(206), vec![ header("Accept-Ranges", "bytes"), header("Content-Type", mime), header( "Content-Range", &format!("bytes {}-{}/{}", span.start, span.end, len), ), ], body, Some(nbytes as usize), None, ) .with_chunked_threshold(usize::MAX); if let Err(e) = request.respond(response) { // A client that seeks away closes the connection mid-body; that is // normal and must not be logged as a failure. debug!("[MediaServer] response ended early: {e}"); } } /// Check the token and resolve the path, or return the status to answer with. fn route(url: &str, root: &Path, token: &str) -> Result { let trimmed = url.trim_start_matches('/'); let (got_token, rest) = trimmed.split_once('/').ok_or(404u16)?; // Constant-time-ish: length check first, then a byte compare. The token is // the only thing standing between another app on the device and this server. if got_token.len() != token.len() || got_token != token { warn!("[MediaServer] Rejected a request with a bad token"); return Err(403); } // Strip any query string before decoding. let raw = rest.split('?').next().unwrap_or(""); match resolve_path(raw, root) { Resolved::Allow(p) => Ok(p), Resolved::Forbidden => { warn!("[MediaServer] Refused a path outside the app data directory"); Err(403) } } } /// What a request path resolved to, before any file is touched. #[derive(Debug, PartialEq, Eq)] pub enum Resolved { Allow(PathBuf), /// Escaped the allowed root. Forbidden, } /// Resolve a percent-encoded request path to a file inside `root`. /// /// `..` segments are folded away lexically rather than through `canonicalize`, /// so a missing file still resolves (and then 404s) instead of being reported as /// a scope violation. /// /// TRACES: UR-071 | DR-137 | UT-127 pub fn resolve_path(raw: &str, root: &Path) -> Resolved { let decoded = match urlencoding::decode(raw) { Ok(d) => d.into_owned(), Err(_) => raw.to_string(), }; let mut normalised = PathBuf::new(); for part in Path::new(&decoded).components() { match part { std::path::Component::ParentDir => { normalised.pop(); } std::path::Component::CurDir => {} other => normalised.push(other), } } if normalised.starts_with(root) { Resolved::Allow(normalised) } else { Resolved::Forbidden } } /// The byte range a response should carry. `end` is inclusive. #[derive(Debug, PartialEq, Eq)] pub struct Span { pub start: u64, pub end: u64, } impl Span { pub fn len(&self) -> u64 { self.end + 1 - self.start } } /// Decide which span to send for a `Range` header (or its absence). /// /// `None` means unsatisfiable — answer 416. A missing or unparseable header /// yields the first chunk, so a client that did not ask for a range still gets a /// bounded response it can continue from, which is exactly the case the asset /// protocol answered with the whole file. /// /// TRACES: UR-071 | DR-137 | UT-127 pub fn span_for(range: Option<&str>, len: u64) -> Option { if len == 0 { return Some(Span { start: 0, end: 0 }); } let last = len - 1; let first_chunk = Span { start: 0, end: (CHUNK_LEN - 1).min(last), }; let Some(raw) = range else { return Some(first_chunk); }; let Some(spec) = raw.trim().strip_prefix("bytes=") else { return Some(first_chunk); }; // Only the first range of a multi-range request is honoured; media clients // ask for one, and a single 206 is a valid answer either way. let spec = spec.split(',').next().unwrap_or("").trim(); let Some((from, to)) = spec.split_once('-') else { return Some(first_chunk); }; let (start, end) = if from.is_empty() { // Suffix form: `-500` is the final 500 bytes. let suffix: u64 = match to.parse() { Ok(n) => n, Err(_) => return Some(first_chunk), }; if suffix == 0 { return None; } (len.saturating_sub(suffix), last) } else { let start: u64 = match from.parse() { Ok(n) => n, Err(_) => return Some(first_chunk), }; let end = if to.is_empty() { last } else { match to.parse::() { Ok(n) => n.min(last), Err(_) => return Some(first_chunk), } }; (start, end) }; if start > last || end < start { return None; } Some(Span { start, end: end.min(start + CHUNK_LEN - 1), }) } /// Guess a content type. /// /// **Magic bytes win over the extension.** Downloading at `original` quality /// asks Jellyfin for a direct static copy, which returns the *source file's* /// bytes under a `.mp4` name whatever the real container is — a downloaded film /// named `.mp4` turned out to be an AVI holding XVID. Trusting the extension /// there labels it `video/mp4` and the player is handed a container that is not /// what the header claims. The extension is only a fallback for a file whose /// bytes are unrecognised, and for the extension-less files the offline queue /// writes under an item id. /// /// TRACES: UR-071 | DR-137 | UT-127 fn content_type(path: &Path, head: &[u8]) -> &'static str { // `ftyp` at offset 4 marks an ISO base media file (mp4 and friends). if head.len() > 11 && &head[4..8] == b"ftyp" { return "video/mp4"; } if head.len() > 11 && head.starts_with(b"RIFF") && &head[8..11] == b"AVI" { return "video/x-msvideo"; } if head.starts_with(b"\x1aE\xdf\xa3") { return "video/x-matroska"; } if head.starts_with(b"ID3") || head.starts_with(b"\xff\xfb") { return "audio/mpeg"; } if head.starts_with(b"OggS") { return "audio/ogg"; } if head.starts_with(b"fLaC") { return "audio/flac"; } match path .extension() .and_then(|e| e.to_str()) .map(|e| e.to_ascii_lowercase()) .as_deref() { Some("mp4" | "m4v" | "mov") => return "video/mp4", Some("mkv") => return "video/x-matroska", Some("webm") => return "video/webm", Some("mp3") => return "audio/mpeg", Some("m4a" | "aac") => return "audio/mp4", Some("flac") => return "audio/flac", Some("ogg" | "opus") => return "audio/ogg", Some("wav") => return "audio/wav", Some("avi") => return "video/x-msvideo", _ => {} } "application/octet-stream" } #[cfg(test)] mod tests { use super::*; /// The whole point: a request with no `Range` must still come back bounded. /// That is the case Tauri's asset protocol answers with the entire file — /// the read Chromium abandoned after 31s. /// /// TRACES: UR-071 | DR-137 | UT-127 #[test] fn a_rangeless_request_is_answered_with_one_chunk_not_the_file() { let huge = 8 * 1024 * 1024 * 1024; // 8 GiB let span = span_for(None, huge).unwrap(); assert_eq!(span.start, 0); assert_eq!(span.len(), CHUNK_LEN); } /// TRACES: UR-071 | DR-137 | UT-127 #[test] fn no_response_ever_exceeds_one_chunk() { let len = 8 * 1024 * 1024 * 1024; for h in [ "bytes=0-", "bytes=0-99999999999", "bytes=1024-", "bytes=-99999999", ] { let span = span_for(Some(h), len).unwrap(); assert!(span.len() <= CHUNK_LEN, "{h} produced {} bytes", span.len()); } } /// TRACES: UR-071 | DR-137 | UT-127 #[test] fn ranges_are_honoured() { let len = 1000u64; assert_eq!( span_for(Some("bytes=100-199"), len).unwrap(), Span { start: 100, end: 199 } ); // Open-ended runs to the end of a small file. assert_eq!( span_for(Some("bytes=900-"), len).unwrap(), Span { start: 900, end: 999 } ); // Suffix form. assert_eq!( span_for(Some("bytes=-100"), len).unwrap(), Span { start: 900, end: 999 } ); // Past the end is unsatisfiable, not a clamp — a clamp would make a // seek past the end silently replay earlier bytes. assert!(span_for(Some("bytes=1000-"), len).is_none()); } /// A malformed header must not fail the request: playing from the start is /// strictly better than refusing to open the file. /// /// TRACES: UR-071 | DR-137 | UT-127 #[test] fn a_malformed_range_falls_back_to_the_first_chunk() { assert_eq!(span_for(Some("pages=1-2"), 5000).unwrap().start, 0); assert_eq!(span_for(Some("bytes=abc-def"), 5000).unwrap().start, 0); } /// TRACES: UR-071 | DR-137 | UT-127 #[test] fn reads_are_confined_to_the_app_data_directory() { let root = Path::new("/data/user/0/app"); assert_eq!( resolve_path("/data/user/0/app/videos/f.mp4", root), Resolved::Allow(PathBuf::from("/data/user/0/app/videos/f.mp4")) ); // Percent-encoded, as the URL builder produces. assert_eq!( resolve_path("%2Fdata%2Fuser%2F0%2Fapp%2Fa%20b.mp4", root), Resolved::Allow(PathBuf::from("/data/user/0/app/a b.mp4")) ); // Traversal out of the root, and an unrelated absolute path, are refused. assert_eq!( resolve_path("/data/user/0/app/../../../etc/passwd", root), Resolved::Forbidden ); assert_eq!(resolve_path("/etc/passwd", root), Resolved::Forbidden); } /// Loopback is shared between apps on Android, so the token is the only /// thing stopping another installed app from reading downloaded media. /// /// TRACES: UR-071 | DR-137 | UT-127 #[test] fn a_request_without_the_right_token_is_refused() { let root = Path::new("/data/user/0/app"); let good = "0123456789abcdef0123456789abcdef"; assert_eq!( route( &format!("/{good}/%2Fdata%2Fuser%2F0%2Fapp%2Ff.mp4"), root, good ), Ok(PathBuf::from("/data/user/0/app/f.mp4")) ); assert_eq!( route("/wrong-token/%2Fdata%2Fuser%2F0%2Fapp%2Ff.mp4", root, good), Err(403) ); // No token segment at all. assert_eq!(route("/f.mp4", root, good), Err(404)); // Right token, but a path outside the root is still refused. assert_eq!( route(&format!("/{good}/%2Fetc%2Fpasswd"), root, good), Err(403) ); } /// TRACES: UR-071 | DR-137 | UT-127 #[test] fn content_type_uses_the_extension_then_the_magic_bytes() { assert_eq!(content_type(Path::new("/a/f.mp4"), &[]), "video/mp4"); assert_eq!(content_type(Path::new("/a/f.mp3"), &[]), "audio/mpeg"); // A `.mp4` that is really an AVI: downloading at `original` quality // copies the source bytes under an mp4 name, so the extension lies and // the magic bytes must win. let avi_head = b"RIFF\xcc\xf3\xbc\x2bAVI LIST"; assert_eq!( content_type(Path::new("/a/film.mp4"), avi_head), "video/x-msvideo" ); // Extension-less, as the offline queue writes them: sniff instead. let mp4_head = b"\x00\x00\x00\x20ftypisom\x00\x00\x02\x00"; assert_eq!(content_type(Path::new("/a/abc123"), mp4_head), "video/mp4"); assert_eq!( content_type(Path::new("/a/abc123"), b"ID3\x03junk"), "audio/mpeg" ); } }