// Static file server that can plug into the PTTH reverse server use std::{ borrow::Cow, cmp::{min, max}, collections::*, convert::{Infallible, TryInto}, error::Error, io::SeekFrom, path::{Path, PathBuf}, }; use handlebars::Handlebars; use serde::Serialize; use tokio::{ fs::{ DirEntry, File, read_dir, ReadDir, }, io::AsyncReadExt, sync::mpsc::{ channel, }, }; use tracing::instrument; use regex::Regex; use crate::{ http_serde::{ Method, Response, StatusCode, }, prelude::*, prefix_match, }; #[derive (Serialize)] struct ServerInfo <'a> { server_name: &'a str, } #[derive (Serialize)] struct TemplateDirEntry { icon: &'static str, trailing_slash: &'static str, // Unfortunately file_name will allocate as long as some platforms // (Windows!) aren't UTF-8. Cause I don't want to write separate code // for such a small problem. file_name: String, // This could be a Cow with file_name if no encoding was done but // it's simpler to allocate. encoded_file_name: String, size: Cow <'static, str>, error: bool, } #[derive (Serialize)] struct TemplateDirPage <'a> { #[serde (flatten)] server_info: ServerInfo <'a>, path: Cow <'a, str>, entries: Vec , } fn parse_range_header (range_str: &str) -> (Option , Option ) { use lazy_static::*; lazy_static! { static ref RE: Regex = Regex::new (r"^bytes=(\d*)-(\d*)$").expect ("Couldn't compile regex for Range header"); } debug! ("{}", range_str); let caps = match RE.captures (range_str) { Some (x) => x, _ => return (None, None), }; let start = caps.get (1).map (|x| x.as_str ()); let end = caps.get (2).map (|x| x.as_str ()); let start = start.map (|x| u64::from_str_radix (x, 10).ok ()).flatten (); let end = end.map (|x| u64::from_str_radix (x, 10).ok ()).flatten (); (start, end) } async fn read_dir_entry (entry: DirEntry) -> TemplateDirEntry { let file_name = match entry.file_name ().into_string () { Ok (x) => x, Err (_) => return TemplateDirEntry { icon: "⚠️", trailing_slash: "", file_name: "File / directory name is not UTF-8".into (), encoded_file_name: "".into (), size: "".into (), error: true, }, }; let metadata = match entry.metadata ().await { Ok (x) => x, Err (_) => return TemplateDirEntry { icon: "⚠️", trailing_slash: "", file_name: "Could not fetch metadata".into (), encoded_file_name: "".into (), size: "".into (), error: true, }, }; let (trailing_slash, icon, size) = { let t = metadata.file_type (); if t.is_dir () { ("/", "📁", "".into ()) } else { let icon = if file_name.ends_with (".mp4") { "🎞️" } else if file_name.ends_with (".avi") { "🎞️" } else if file_name.ends_with (".mkv") { "🎞️" } else if file_name.ends_with (".jpg") { "📷" } else if file_name.ends_with (".jpeg") { "📷" } else if file_name.ends_with (".png") { "📷" } else if file_name.ends_with (".bmp") { "📷" } else { "📄" }; ("", icon, pretty_print_bytes (metadata.len ()).into ()) } }; use percent_encoding::*; let encoded_file_name = utf8_percent_encode (&file_name, CONTROLS).to_string (); TemplateDirEntry { icon, trailing_slash: &trailing_slash, file_name, encoded_file_name, size, error: false, } } async fn serve_root ( handlebars: &Handlebars <'static>, ) -> Response { let server_info = ServerInfo { server_name: "PTTH file server", }; let s = handlebars.render ("file_server_root", &server_info).unwrap (); let body = s.into_bytes (); let mut resp = Response::default (); resp .header ("content-type".to_string (), "text/html".to_string ().into_bytes ()) .body_bytes (body) ; resp } #[instrument (level = "debug", skip (handlebars, dir))] async fn serve_dir ( handlebars: &Handlebars <'static>, path: Cow <'_, str>, mut dir: ReadDir ) -> Response { let server_info = ServerInfo { server_name: "PTTH file server", }; let mut entries = vec! []; while let Ok (Some (entry)) = dir.next_entry ().await { entries.push (read_dir_entry (entry).await); } entries.sort_unstable_by (|a, b| a.file_name.partial_cmp (&b.file_name).unwrap ()); let s = handlebars.render ("file_server_dir", &TemplateDirPage { path, entries, server_info, }).unwrap (); let body = s.into_bytes (); let mut resp = Response::default (); resp .header ("content-type".to_string (), "text/html".to_string ().into_bytes ()) .body_bytes (body) ; resp } #[instrument (level = "debug", skip (f))] async fn serve_file ( mut f: File, should_send_body: bool, range_start: Option , range_end: Option ) -> Response { let (tx, rx) = channel (1); let body = if should_send_body { Some (rx) } else { None }; let file_md = f.metadata ().await.unwrap (); let file_len = file_md.len (); let start = range_start.unwrap_or (0); let end = range_end.unwrap_or (file_len); let start = max (0, min (start, file_len)); let end = max (0, min (end, file_len)); f.seek (SeekFrom::Start (start)).await.unwrap (); info! ("Serving range {}-{}", start, end); if should_send_body { tokio::spawn (async move { //println! ("Opening file {:?}", path); let mut tx = tx; let mut bytes_sent = 0; let mut bytes_left = end - start; loop { let mut buffer = vec! [0u8; 65_536]; let bytes_read: u64 = f.read (&mut buffer).await.unwrap ().try_into ().unwrap (); let bytes_read = min (bytes_left, bytes_read); buffer.truncate (bytes_read.try_into ().unwrap ()); if bytes_read == 0 { break; } if tx.send (Ok::<_, Infallible> (buffer)).await.is_err () { warn! ("Cancelling file stream (Sent {} out of {} bytes)", bytes_sent, end - start); break; } bytes_left -= bytes_read; if bytes_left == 0 { debug! ("Finished"); break; } bytes_sent += bytes_read; trace! ("Sent {} bytes", bytes_sent); //delay_for (Duration::from_millis (50)).await; } }); } let mut response = Response::default (); response.header (String::from ("accept-ranges"), b"bytes".to_vec ()); if should_send_body { if range_start.is_none () && range_end.is_none () { response.status_code (StatusCode::Ok); response.header (String::from ("content-length"), end.to_string ().into_bytes ()); } else { response.status_code (StatusCode::PartialContent); response.header (String::from ("content-range"), format! ("bytes {}-{}/{}", start, end - 1, end).into_bytes ()); } response.content_length = Some (end - start); } if let Some (body) = body { response.body (body); } response } fn serve_error ( status_code: StatusCode, msg: &str ) -> Response { let mut resp = Response::default (); resp.status_code (status_code); resp.body_bytes (msg.as_bytes ().to_vec ()); resp } fn serve_307 (location: String) -> Response { let mut resp = Response::default (); resp.status_code (StatusCode::TemporaryRedirect); resp.header ("location".to_string (), location.into_bytes ()); resp.body_bytes (b"Redirecting...".to_vec ()); resp } #[instrument (level = "debug", skip (handlebars, headers))] pub async fn serve_all ( handlebars: &Handlebars <'static>, root: &Path, method: Method, uri: &str, headers: &HashMap >, hidden_path: Option <&Path> ) -> Response { info! ("Client requested {}", uri); use percent_encoding::*; if uri == "/favicon.ico" { return serve_error (StatusCode::NotFound, ""); } let uri = match prefix_match (uri, "/files") { Some (x) => x, None => { return serve_root (handlebars).await; }, }; // TODO: There is totally a dir traversal attack in here somewhere let encoded_path = &uri [1..]; let path_s = percent_decode (encoded_path.as_bytes ()).decode_utf8 ().unwrap (); let path = Path::new (&*path_s); let mut full_path = PathBuf::from (root); full_path.push (path); debug! ("full_path = {:?}", full_path); if let Some (hidden_path) = hidden_path { if full_path == hidden_path { return serve_error (StatusCode::Forbidden, "403 Forbidden"); } } let has_trailing_slash = path_s.is_empty () || path_s.ends_with ("/"); if let Ok (dir) = read_dir (&full_path).await { if ! has_trailing_slash { return serve_307 (format! ("{}/", path.file_name ().unwrap ().to_str ().unwrap ())); } serve_dir ( handlebars, full_path.to_string_lossy (), dir ).await } else if let Ok (file) = File::open (&full_path).await { let mut range_start = None; let mut range_end = None; if let Some (v) = headers.get ("range") { let v = std::str::from_utf8 (v).unwrap (); let (start, end) = parse_range_header (v); range_start = start; range_end = end; } let should_send_body = match &method { Method::Get => true, Method::Head => false, m => { debug! ("Unsupported method {:?}", m); return serve_error (StatusCode::MethodNotAllowed, "Unsupported method"); } }; serve_file ( file, should_send_body, range_start, range_end ).await } else { serve_error (StatusCode::NotFound, "404 Not Found") } } pub fn load_templates () -> Result , Box > { let mut handlebars = Handlebars::new (); handlebars.set_strict_mode (true); for (k, v) in vec! [ ("file_server_dir", "file_server_dir.html"), ("file_server_root", "file_server_root.html"), ].into_iter () { handlebars.register_template_file (k, format! ("ptth_handlebars/{}", v))?; } Ok (handlebars) } fn pretty_print_bytes (b: u64) -> String { if b < 1024 { format! ("{} B", b) } else if (b + 512) < 1024 * 1024 { format! ("{} KiB", (b + 512) / 1024) } else if (b + 512 * 1024) < 1024 * 1024 * 1024 { format! ("{} MiB", (b + 512 * 1024) / 1024 / 1024) } else { format! ("{} GiB", (b + 512 * 1024 * 1024) / 1024 / 1024 / 1024) } } #[cfg (test)] mod tests { use std::{ ffi::OsStr, path::{ Component, Path, PathBuf }, }; use tokio::runtime::Runtime; use crate::http_serde::{ StatusCode, }; #[test] fn pretty_print_bytes () { for (input_after, expected_before, expected_after) in vec! [ (1, "0 B", "1 B"), (1024, "1023 B", "1 KiB"), (1024 + 512, "1 KiB", "2 KiB"), (1023 * 1024 + 512, "1023 KiB", "1 MiB"), ((1024 + 512) * 1024, "1 MiB", "2 MiB"), (1023 * 1024 * 1024 + 512 * 1024, "1023 MiB", "1 GiB"), ((1024 + 512) * 1024 * 1024, "1 GiB", "2 GiB"), ].into_iter () { let actual = super::pretty_print_bytes (input_after - 1); assert_eq! (&actual, expected_before); let actual = super::pretty_print_bytes (input_after); assert_eq! (&actual, expected_after); } } #[test] fn i_hate_paths () { let mut components = Path::new ("/home/user").components (); assert_eq! (components.next (), Some (Component::RootDir)); assert_eq! (components.next (), Some (Component::Normal (OsStr::new ("home")))); assert_eq! (components.next (), Some (Component::Normal (OsStr::new ("user")))); assert_eq! (components.next (), None); let mut components = Path::new ("./home/user").components (); assert_eq! (components.next (), Some (Component::CurDir)); assert_eq! (components.next (), Some (Component::Normal (OsStr::new ("home")))); assert_eq! (components.next (), Some (Component::Normal (OsStr::new ("user")))); assert_eq! (components.next (), None); let mut components = Path::new (".").components (); assert_eq! (components.next (), Some (Component::CurDir)); assert_eq! (components.next (), None); } #[test] fn file_server () { use crate::{ http_serde::Method, prelude::*, }; tracing_subscriber::fmt ().try_init ().ok (); let mut rt = Runtime::new ().unwrap (); rt.block_on (async { let handlebars = super::load_templates ().unwrap (); let file_server_root = PathBuf::from ("./"); let headers = Default::default (); for (uri_path, expected_status) in vec! [ ("/", StatusCode::Ok), ("/files/src", StatusCode::TemporaryRedirect), ("/files/src/", StatusCode::Ok), ].into_iter () { let resp = super::serve_all ( &handlebars, &file_server_root, Method::Get, uri_path, &headers, None ).await; assert_eq! (resp.parts.status_code, expected_status); } }); } }