// Static file server that can plug into the PTTH reverse server // I'm not sure if I like this one #![allow (clippy::enum_glob_use)] use std::{ borrow::Cow, cmp::min, collections::HashMap, convert::{Infallible, TryFrom}, fmt::Debug, io::SeekFrom, path::Path, }; use handlebars::Handlebars; use serde::Serialize; use tokio::{ fs::{ DirEntry, File, ReadDir, }, io::AsyncReadExt, sync::mpsc::{ channel, }, }; use tracing::instrument; use ptth_core::{ http_serde::{ Method, Response, StatusCode, }, prelude::*, }; pub mod errors; mod internal; mod markdown; mod range; use errors::FileServerError; mod emoji { pub const VIDEO: &str = "\u{1f39e}\u{fe0f}"; pub const PICTURE: &str = "\u{1f4f7}"; pub const FILE: &str = "\u{1f4c4}"; pub const FOLDER: &str = "\u{1f4c1}"; pub const ERROR: &str = "\u{26a0}\u{fe0f}"; } #[derive (Debug, Serialize)] pub struct ServerInfo { pub server_name: String, } #[derive (Serialize)] struct DirEntryJson { name: String, size: u64, is_dir: bool, } #[derive (Serialize)] struct DirJson { entries: Vec , } #[derive (Serialize)] struct DirEntryHtml { 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 DirHtml <'a> { #[serde (flatten)] server_info: &'a ServerInfo, path: Cow <'a, str>, entries: Vec , } fn get_icon (file_name: &str) -> &'static str { if file_name.ends_with (".mp4") || file_name.ends_with (".avi") || file_name.ends_with (".mkv") || file_name.ends_with (".webm") { emoji::VIDEO } else if file_name.ends_with (".jpg") || file_name.ends_with (".jpeg") || file_name.ends_with (".png") || file_name.ends_with (".bmp") { emoji::PICTURE } else { emoji::FILE } } async fn read_dir_entry_html (entry: DirEntry) -> DirEntryHtml { use percent_encoding::{ CONTROLS, utf8_percent_encode, }; let file_name = match entry.file_name ().into_string () { Ok (x) => x, Err (_) => return DirEntryHtml { icon: emoji::ERROR, 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 DirEntryHtml { icon: emoji::ERROR, 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 () { ("/", emoji::FOLDER, "".into ()) } else { ("", get_icon (&file_name), pretty_print_bytes (metadata.len ()).into ()) } }; let encoded_file_name = utf8_percent_encode (&file_name, CONTROLS).to_string (); DirEntryHtml { icon, trailing_slash: &trailing_slash, file_name, encoded_file_name, size, error: false, } } async fn read_dir_entry_json (entry: DirEntry) -> Option { let name = entry.file_name ().into_string ().ok ()?; let metadata = entry.metadata ().await.ok ()?; let is_dir = metadata.is_dir (); let size = metadata.len (); Some (DirEntryJson { name, size, is_dir, }) } async fn serve_root ( handlebars: &Handlebars <'static>, server_info: &ServerInfo ) -> Result { let s = handlebars.render ("file_server_root", &server_info)?; Ok (serve_html (s)) } fn serve_html (s: String) -> Response { let mut resp = Response::default (); resp .header ("content-type".to_string (), "text/html; charset=UTF-8".to_string ().into_bytes ()) .body_bytes (s.into_bytes ()) ; resp } async fn serve_dir_json ( mut dir: ReadDir ) -> Result { let mut entries = vec! []; while let Ok (Some (entry)) = dir.next_entry ().await { if let Some (entry) = read_dir_entry_json (entry).await { entries.push (entry); } } entries.sort_unstable_by (|a, b| a.name.cmp (&b.name)); let dir = DirJson { entries, }; let mut response = Response::default (); response.header ("content-type".to_string (), "application/json; charset=UTF-8".to_string ().into_bytes ()); response.body_bytes (serde_json::to_string (&dir).unwrap ().into_bytes ()); Ok (response) } #[instrument (level = "debug", skip (handlebars, dir))] async fn serve_dir_html ( handlebars: &Handlebars <'static>, server_info: &ServerInfo, path: Cow <'_, str>, mut dir: ReadDir ) -> Result { let mut entries = vec! []; while let Ok (Some (entry)) = dir.next_entry ().await { entries.push (read_dir_entry_html (entry).await); } entries.sort_unstable_by (|a, b| a.file_name.cmp (&b.file_name)); let s = handlebars.render ("file_server_dir", &DirHtml { path, entries, server_info, })?; Ok (serve_html (s)) } #[instrument (level = "debug", skip (f))] async fn serve_file ( mut f: File, should_send_body: bool, range: range::ValidParsed ) -> Result { let (tx, rx) = channel (1); let body = if should_send_body { Some (rx) } else { None }; let (range, range_requested) = (range.range, range.range_requested); info! ("Serving range {}-{}", range.start, range.end); let content_length = range.end - range.start; let seek = SeekFrom::Start (range.start); f.seek (seek).await?; if should_send_body { tokio::spawn (async move { let mut tx = tx; let mut bytes_sent = 0; let mut bytes_left = content_length; let mark_interval = 200_000; let mut next_mark = mark_interval; loop { let mut buffer = vec! [0_u8; 65_536]; let bytes_read = f.read (&mut buffer).await.expect ("Couldn't read from file"); if bytes_read == 0 { break; } buffer.truncate (bytes_read); let bytes_read_64 = u64::try_from (bytes_read).expect ("Couldn't fit usize into u64"); let bytes_read_64 = min (bytes_left, bytes_read_64); if tx.send (Ok::<_, Infallible> (buffer)).await.is_err () { warn! ("Cancelling file stream (Sent {} out of {} bytes)", bytes_sent, content_length); break; } bytes_left -= bytes_read_64; if bytes_left == 0 { debug! ("Finished"); break; } bytes_sent += bytes_read_64; while next_mark <= bytes_sent { trace! ("Sent {} bytes", next_mark); next_mark += mark_interval; } //delay_for (Duration::from_millis (50)).await; } }); } let mut response = Response::default (); response.header (String::from ("accept-ranges"), b"bytes".to_vec ()); if range_requested { response.status_code (StatusCode::PartialContent); response.header (String::from ("content-range"), format! ("bytes {}-{}/{}", range.start, range.end - 1, range.end).into_bytes ()); } else { response.status_code (StatusCode::Ok); response.header (String::from ("content-length"), range.end.to_string ().into_bytes ()); } if should_send_body { response.content_length = Some (content_length); } else { response.status_code (StatusCode::NoContent); } if let Some (body) = body { response.body (body); } Ok (response) } #[instrument (level = "debug", skip (handlebars, headers))] pub async fn serve_all ( handlebars: &Handlebars <'static>, server_info: &ServerInfo, root: &Path, method: Method, uri: &str, headers: &HashMap >, hidden_path: Option <&Path> ) -> Result { use internal::{ OutputFormat, Response::*, }; fn serve_error >> ( status_code: StatusCode, msg: S ) -> Response { let mut resp = Response::default (); resp.status_code (status_code); resp.body_bytes (msg.into ()); resp } Ok (match internal::serve_all (root, method, uri, headers, hidden_path).await? { Favicon => serve_error (StatusCode::NotFound, "Not found\n"), Forbidden => serve_error (StatusCode::Forbidden, "403 Forbidden\n"), MethodNotAllowed => serve_error (StatusCode::MethodNotAllowed, "Unsupported method\n"), NotFound => serve_error (StatusCode::NotFound, "404 Not Found\nAre you missing a trailing slash?\n"), RangeNotSatisfiable (file_len) => { let mut resp = Response::default (); resp.status_code (StatusCode::RangeNotSatisfiable) .header ("content-range".to_string (), format! ("bytes */{}", file_len).into_bytes ()); resp }, Redirect (location) => { let mut resp = Response::default (); resp.status_code (StatusCode::TemporaryRedirect) .header ("location".to_string (), location.into_bytes ()); resp.body_bytes (b"Redirecting...\n".to_vec ()); resp }, InvalidQuery => serve_error (StatusCode::BadRequest, "Query is invalid for this object\n"), Root => serve_root (handlebars, server_info).await?, ServeDir (internal::ServeDirParams { path, dir, format }) => match format { OutputFormat::Json => serve_dir_json (dir.into_inner ()).await?, OutputFormat::Html => serve_dir_html (handlebars, server_info, path.to_string_lossy (), dir.into_inner ()).await?, }, ServeFile (internal::ServeFileParams { file, send_body, range, }) => serve_file (file.into_inner (), send_body, range).await?, MarkdownErr (e) => { use markdown::Error::*; let code = match &e { TooBig => StatusCode::InternalServerError, //NotMarkdown => serve_error (StatusCode::BadRequest, "File is not Markdown"), NotUtf8 => StatusCode::BadRequest, }; serve_error (code, e.to_string ()) }, MarkdownPreview (s) => serve_html (s), }) } pub fn load_templates ( asset_root: &Path ) -> Result , handlebars::TemplateFileError> { let mut handlebars = Handlebars::new (); handlebars.set_strict_mode (true); let asset_root = asset_root.join ("handlebars/server"); for (k, v) in &[ ("file_server_dir", "file_server_dir.html"), ("file_server_root", "file_server_root.html"), ] { handlebars.register_template_file (k, asset_root.join (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;