1use std::fs::File;
30use std::io::{Read, Seek, SeekFrom};
31use std::path::{Path, PathBuf};
32use std::sync::Arc;
33
34use log::{debug, error, info, warn};
35use rand::Rng;
36use tiny_http::{Header, Response, Server, StatusCode};
37
38const CHUNK_LEN: u64 = 4 * 1024 * 1024;
43
44pub struct MediaServerWrapper(pub Option<MediaServer>);
47
48pub struct MediaServer {
51 port: u16,
52 token: String,
53}
54
55impl MediaServer {
56 pub fn base_url(&self) -> String {
58 format!("http://127.0.0.1:{}/{}", self.port, self.token)
59 }
60
61 pub fn url_for(&self, path: &str) -> String {
63 format!("{}/{}", self.base_url(), urlencoding::encode(path))
64 }
65}
66
67pub fn start(root: PathBuf) -> Result<MediaServer, String> {
72 let server =
75 Server::http("127.0.0.1:0").map_err(|e| format!("Failed to bind media server: {e}"))?;
76
77 let port = server
78 .server_addr()
79 .to_ip()
80 .ok_or_else(|| "Media server bound to a non-IP address".to_string())?
81 .port();
82
83 let token: String = {
84 let mut rng = rand::thread_rng();
85 (0..32)
86 .map(|_| char::from_digit(rng.gen_range(0..16), 16).unwrap())
87 .collect()
88 };
89
90 info!(
91 "[MediaServer] Serving {} on 127.0.0.1:{}",
92 root.display(),
93 port
94 );
95
96 let server = Arc::new(server);
97 let shared = Arc::new((root, token.clone()));
98
99 std::thread::Builder::new()
100 .name("media-server".into())
101 .spawn(move || loop {
102 let request = match server.recv() {
103 Ok(r) => r,
104 Err(e) => {
105 error!("[MediaServer] accept failed: {e}");
106 continue;
107 }
108 };
109 let shared = Arc::clone(&shared);
110 if let Err(e) = std::thread::Builder::new()
113 .name("media-server-req".into())
114 .spawn(move || {
115 let (root, token) = &*shared;
116 handle(request, root, token);
117 })
118 {
119 error!("[MediaServer] could not spawn handler: {e}");
120 }
121 })
122 .map_err(|e| format!("Failed to start media server thread: {e}"))?;
123
124 Ok(MediaServer { port, token })
125}
126
127fn header(name: &str, value: &str) -> Header {
128 Header::from_bytes(name.as_bytes(), value.as_bytes())
129 .expect("static header name/value are valid")
130}
131
132fn empty(status: u16) -> Response<std::io::Empty> {
133 Response::empty(StatusCode(status)).with_header(header("Accept-Ranges", "bytes"))
134}
135
136fn handle(request: tiny_http::Request, root: &Path, token: &str) {
137 let url = request.url().to_string();
138 let method = request.method().as_str().to_string();
139
140 let outcome = match route(&url, root, token) {
141 Ok(path) => path,
142 Err(status) => {
143 let _ = request.respond(empty(status));
144 return;
145 }
146 };
147
148 if method != "GET" && method != "HEAD" {
149 let _ = request.respond(empty(405));
150 return;
151 }
152
153 let mut file = match File::open(&outcome) {
154 Ok(f) => f,
155 Err(e) => {
156 warn!("[MediaServer] {}: {}", outcome.display(), e);
157 let _ = request.respond(empty(404));
158 return;
159 }
160 };
161 let len = match file.metadata() {
162 Ok(m) => m.len(),
163 Err(e) => {
164 warn!(
165 "[MediaServer] metadata failed for {}: {}",
166 outcome.display(),
167 e
168 );
169 let _ = request.respond(empty(404));
170 return;
171 }
172 };
173
174 let range = request
175 .headers()
176 .iter()
177 .find(|h| h.field.equiv("Range"))
178 .map(|h| h.value.as_str().to_string());
179
180 debug!(
181 "[MediaServer] {} {} ({} bytes) range={:?}",
182 method,
183 outcome.display(),
184 len,
185 range
186 );
187
188 let Some(span) = span_for(range.as_deref(), len) else {
189 let _ = request
190 .respond(empty(416).with_header(header("Content-Range", &format!("bytes */{len}"))));
191 return;
192 };
193
194 let mut head = [0u8; 16];
196 let head_len = file.read(&mut head).unwrap_or(0);
197 let mime = content_type(&outcome, &head[..head_len]);
198
199 if method == "HEAD" {
200 let _ = request.respond(
201 empty(200)
202 .with_header(header("Content-Type", mime))
203 .with_header(header("Content-Length", &len.to_string())),
204 );
205 return;
206 }
207
208 if let Err(e) = file.seek(SeekFrom::Start(span.start)) {
209 warn!("[MediaServer] seek failed for {}: {}", outcome.display(), e);
210 let _ = request.respond(empty(500));
211 return;
212 }
213
214 let nbytes = span.len();
217 let body = file.take(nbytes);
218 let response = Response::new(
223 StatusCode(206),
224 vec![
225 header("Accept-Ranges", "bytes"),
226 header("Content-Type", mime),
227 header(
228 "Content-Range",
229 &format!("bytes {}-{}/{}", span.start, span.end, len),
230 ),
231 ],
232 body,
233 Some(nbytes as usize),
234 None,
235 )
236 .with_chunked_threshold(usize::MAX);
237
238 if let Err(e) = request.respond(response) {
239 debug!("[MediaServer] response ended early: {e}");
242 }
243}
244
245fn route(url: &str, root: &Path, token: &str) -> Result<PathBuf, u16> {
247 let trimmed = url.trim_start_matches('/');
248 let (got_token, rest) = trimmed.split_once('/').ok_or(404u16)?;
249
250 if got_token.len() != token.len() || got_token != token {
253 warn!("[MediaServer] Rejected a request with a bad token");
254 return Err(403);
255 }
256
257 let raw = rest.split('?').next().unwrap_or("");
259 match resolve_path(raw, root) {
260 Resolved::Allow(p) => Ok(p),
261 Resolved::Forbidden => {
262 warn!("[MediaServer] Refused a path outside the app data directory");
263 Err(403)
264 }
265 }
266}
267
268#[derive(Debug, PartialEq, Eq)]
270pub enum Resolved {
271 Allow(PathBuf),
272 Forbidden,
274}
275
276pub fn resolve_path(raw: &str, root: &Path) -> Resolved {
284 let decoded = match urlencoding::decode(raw) {
285 Ok(d) => d.into_owned(),
286 Err(_) => raw.to_string(),
287 };
288
289 let mut normalised = PathBuf::new();
290 for part in Path::new(&decoded).components() {
291 match part {
292 std::path::Component::ParentDir => {
293 normalised.pop();
294 }
295 std::path::Component::CurDir => {}
296 other => normalised.push(other),
297 }
298 }
299
300 if normalised.starts_with(root) {
301 Resolved::Allow(normalised)
302 } else {
303 Resolved::Forbidden
304 }
305}
306
307#[derive(Debug, PartialEq, Eq)]
309pub struct Span {
310 pub start: u64,
311 pub end: u64,
312}
313
314impl Span {
315 pub fn len(&self) -> u64 {
316 self.end + 1 - self.start
317 }
318}
319
320pub fn span_for(range: Option<&str>, len: u64) -> Option<Span> {
329 if len == 0 {
330 return Some(Span { start: 0, end: 0 });
331 }
332 let last = len - 1;
333 let first_chunk = Span {
334 start: 0,
335 end: (CHUNK_LEN - 1).min(last),
336 };
337
338 let Some(raw) = range else {
339 return Some(first_chunk);
340 };
341 let Some(spec) = raw.trim().strip_prefix("bytes=") else {
342 return Some(first_chunk);
343 };
344 let spec = spec.split(',').next().unwrap_or("").trim();
347 let Some((from, to)) = spec.split_once('-') else {
348 return Some(first_chunk);
349 };
350
351 let (start, end) = if from.is_empty() {
352 let suffix: u64 = match to.parse() {
354 Ok(n) => n,
355 Err(_) => return Some(first_chunk),
356 };
357 if suffix == 0 {
358 return None;
359 }
360 (len.saturating_sub(suffix), last)
361 } else {
362 let start: u64 = match from.parse() {
363 Ok(n) => n,
364 Err(_) => return Some(first_chunk),
365 };
366 let end = if to.is_empty() {
367 last
368 } else {
369 match to.parse::<u64>() {
370 Ok(n) => n.min(last),
371 Err(_) => return Some(first_chunk),
372 }
373 };
374 (start, end)
375 };
376
377 if start > last || end < start {
378 return None;
379 }
380
381 Some(Span {
382 start,
383 end: end.min(start + CHUNK_LEN - 1),
384 })
385}
386
387fn content_type(path: &Path, head: &[u8]) -> &'static str {
400 if head.len() > 11 && &head[4..8] == b"ftyp" {
402 return "video/mp4";
403 }
404 if head.len() > 11 && head.starts_with(b"RIFF") && &head[8..11] == b"AVI" {
405 return "video/x-msvideo";
406 }
407 if head.starts_with(b"\x1aE\xdf\xa3") {
408 return "video/x-matroska";
409 }
410 if head.starts_with(b"ID3") || head.starts_with(b"\xff\xfb") {
411 return "audio/mpeg";
412 }
413 if head.starts_with(b"OggS") {
414 return "audio/ogg";
415 }
416 if head.starts_with(b"fLaC") {
417 return "audio/flac";
418 }
419
420 match path
421 .extension()
422 .and_then(|e| e.to_str())
423 .map(|e| e.to_ascii_lowercase())
424 .as_deref()
425 {
426 Some("mp4" | "m4v" | "mov") => return "video/mp4",
427 Some("mkv") => return "video/x-matroska",
428 Some("webm") => return "video/webm",
429 Some("mp3") => return "audio/mpeg",
430 Some("m4a" | "aac") => return "audio/mp4",
431 Some("flac") => return "audio/flac",
432 Some("ogg" | "opus") => return "audio/ogg",
433 Some("wav") => return "audio/wav",
434 Some("avi") => return "video/x-msvideo",
435 _ => {}
436 }
437
438 "application/octet-stream"
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444
445 #[test]
451 fn a_rangeless_request_is_answered_with_one_chunk_not_the_file() {
452 let huge = 8 * 1024 * 1024 * 1024; let span = span_for(None, huge).unwrap();
454 assert_eq!(span.start, 0);
455 assert_eq!(span.len(), CHUNK_LEN);
456 }
457
458 #[test]
460 fn no_response_ever_exceeds_one_chunk() {
461 let len = 8 * 1024 * 1024 * 1024;
462 for h in [
463 "bytes=0-",
464 "bytes=0-99999999999",
465 "bytes=1024-",
466 "bytes=-99999999",
467 ] {
468 let span = span_for(Some(h), len).unwrap();
469 assert!(span.len() <= CHUNK_LEN, "{h} produced {} bytes", span.len());
470 }
471 }
472
473 #[test]
475 fn ranges_are_honoured() {
476 let len = 1000u64;
477 assert_eq!(
478 span_for(Some("bytes=100-199"), len).unwrap(),
479 Span {
480 start: 100,
481 end: 199
482 }
483 );
484 assert_eq!(
486 span_for(Some("bytes=900-"), len).unwrap(),
487 Span {
488 start: 900,
489 end: 999
490 }
491 );
492 assert_eq!(
494 span_for(Some("bytes=-100"), len).unwrap(),
495 Span {
496 start: 900,
497 end: 999
498 }
499 );
500 assert!(span_for(Some("bytes=1000-"), len).is_none());
503 }
504
505 #[test]
510 fn a_malformed_range_falls_back_to_the_first_chunk() {
511 assert_eq!(span_for(Some("pages=1-2"), 5000).unwrap().start, 0);
512 assert_eq!(span_for(Some("bytes=abc-def"), 5000).unwrap().start, 0);
513 }
514
515 #[test]
517 fn reads_are_confined_to_the_app_data_directory() {
518 let root = Path::new("/data/user/0/app");
519
520 assert_eq!(
521 resolve_path("/data/user/0/app/videos/f.mp4", root),
522 Resolved::Allow(PathBuf::from("/data/user/0/app/videos/f.mp4"))
523 );
524 assert_eq!(
526 resolve_path("%2Fdata%2Fuser%2F0%2Fapp%2Fa%20b.mp4", root),
527 Resolved::Allow(PathBuf::from("/data/user/0/app/a b.mp4"))
528 );
529 assert_eq!(
531 resolve_path("/data/user/0/app/../../../etc/passwd", root),
532 Resolved::Forbidden
533 );
534 assert_eq!(resolve_path("/etc/passwd", root), Resolved::Forbidden);
535 }
536
537 #[test]
542 fn a_request_without_the_right_token_is_refused() {
543 let root = Path::new("/data/user/0/app");
544 let good = "0123456789abcdef0123456789abcdef";
545
546 assert_eq!(
547 route(
548 &format!("/{good}/%2Fdata%2Fuser%2F0%2Fapp%2Ff.mp4"),
549 root,
550 good
551 ),
552 Ok(PathBuf::from("/data/user/0/app/f.mp4"))
553 );
554 assert_eq!(
555 route("/wrong-token/%2Fdata%2Fuser%2F0%2Fapp%2Ff.mp4", root, good),
556 Err(403)
557 );
558 assert_eq!(route("/f.mp4", root, good), Err(404));
560 assert_eq!(
562 route(&format!("/{good}/%2Fetc%2Fpasswd"), root, good),
563 Err(403)
564 );
565 }
566
567 #[test]
569 fn content_type_uses_the_extension_then_the_magic_bytes() {
570 assert_eq!(content_type(Path::new("/a/f.mp4"), &[]), "video/mp4");
571 assert_eq!(content_type(Path::new("/a/f.mp3"), &[]), "audio/mpeg");
572 let avi_head = b"RIFF\xcc\xf3\xbc\x2bAVI LIST";
576 assert_eq!(
577 content_type(Path::new("/a/film.mp4"), avi_head),
578 "video/x-msvideo"
579 );
580 let mp4_head = b"\x00\x00\x00\x20ftypisom\x00\x00\x02\x00";
582 assert_eq!(content_type(Path::new("/a/abc123"), mp4_head), "video/mp4");
583 assert_eq!(
584 content_type(Path::new("/a/abc123"), b"ID3\x03junk"),
585 "audio/mpeg"
586 );
587 }
588}