// Sort of an internal API endpoint to make testing easy. // Eventually we could expose this as JSON or Msgpack or whatever. For now // it's just a Rust struct that we can test on without caring about // human-readable HTML use std::{ collections::HashMap, convert::TryInto, path::{Path, PathBuf}, }; use percent_encoding::percent_decode; use tokio::{ fs::{ File, read_dir, ReadDir, }, io::AsyncReadExt, }; #[cfg (test)] use always_equal::test::AlwaysEqual; #[cfg (not (test))] use always_equal::prod::AlwaysEqual; use ptth_core::{ http_serde::Method, prefix_match, prelude::*, }; use crate::{ load_toml, }; use super::{ errors::FileServerError, markdown, markdown::render_styled, range, }; #[derive (Debug, PartialEq)] pub enum OutputFormat { Json, Html, } #[derive (Debug, PartialEq)] pub struct ServeDirParams { pub path: PathBuf, pub dir: AlwaysEqual , pub format: OutputFormat, } #[derive (Debug, PartialEq)] pub struct ServeFileParams { pub send_body: bool, pub range: range::ValidParsed, pub file: AlwaysEqual , } #[derive (Debug, PartialEq)] pub enum Response { Favicon, Forbidden, MethodNotAllowed, NotFound, RangeNotSatisfiable (u64), Redirect (String), InvalidQuery, Root, ServeDir (ServeDirParams), ServeFile (ServeFileParams), MarkdownErr (markdown::Error), MarkdownPreview (String), } fn serve_dir ( path_s: &str, path: &Path, dir: tokio::fs::ReadDir, full_path: PathBuf, uri: &http::Uri, format: OutputFormat ) -> Result { let has_trailing_slash = path_s.is_empty () || path_s.ends_with ('/'); if ! has_trailing_slash { let file_name = path.file_name ().ok_or (FileServerError::NoFileNameRequested)?; let file_name = file_name.to_str ().ok_or (FileServerError::FilePathNotUtf8)?; return Ok (Response::Redirect (format! ("{}/", file_name))); } if uri.query ().is_some () { return Ok (Response::InvalidQuery); } let dir = dir.into (); Ok (Response::ServeDir (ServeDirParams { dir, path: full_path, format, })) } async fn serve_file ( mut file: tokio::fs::File, uri: &http::Uri, send_body: bool, headers: &HashMap > ) -> Result { let file_md = file.metadata ().await.map_err (FileServerError::CantGetFileMetadata)?; #[cfg (unix)] { use std::os::unix::fs::PermissionsExt; if file_md.permissions ().mode () == load_toml::CONFIG_PERMISSIONS_MODE { return Ok (Response::Forbidden); } } let file_len = file_md.len (); let range_header = headers.get ("range").and_then (|v| std::str::from_utf8 (v).ok ()); Ok (match range::check (range_header, file_len) { range::Parsed::NotSatisfiable (file_len) => Response::RangeNotSatisfiable (file_len), range::Parsed::Valid (range) => { if uri.query () == Some ("as_markdown") { const MAX_BUF_SIZE: u32 = 1_000_000; if range.range_requested { return Ok (Response::InvalidQuery); } if file_len > MAX_BUF_SIZE.into () { Response::MarkdownErr (markdown::Error::TooBig) } else { let mut buffer = vec! [0_u8; MAX_BUF_SIZE.try_into ().expect ("Couldn't fit u32 into usize")]; let bytes_read = file.read (&mut buffer).await?; buffer.truncate (bytes_read); match render_styled (&buffer) { Ok (x) => Response::MarkdownPreview (x), Err (x) => Response::MarkdownErr (x), } } } else { let file = file.into (); Response::ServeFile (ServeFileParams { file, send_body, range, }) } }, }) } async fn serve_api ( root: &Path, uri: &http::Uri, hidden_path: Option <&Path>, path: &str ) -> Result { use Response::*; match prefix_match ("/v1/dir/", path) { None => (), Some (path) => { let encoded_path = &path [0..]; let path_s = percent_decode (encoded_path.as_bytes ()).decode_utf8 ().map_err (FileServerError::PathNotUtf8)?; let path = Path::new (&*path_s); let full_path = root.join (path); debug! ("full_path = {:?}", full_path); if let Some (hidden_path) = hidden_path { if full_path == hidden_path { return Ok (Forbidden); } } return if let Ok (dir) = read_dir (&full_path).await { serve_dir ( &path_s, path, dir, full_path, &uri, OutputFormat::Json ) } else { Ok (NotFound) }; }, }; Ok (NotFound) } pub async fn serve_all ( root: &Path, method: Method, uri: &str, headers: &HashMap >, hidden_path: Option <&Path> ) -> Result { use std::str::FromStr; use Response::*; info! ("Client requested {}", uri); let uri = http::Uri::from_str (uri).map_err (FileServerError::InvalidUri)?; let send_body = match &method { Method::Get => true, Method::Head => false, m => { debug! ("Unsupported method {:?}", m); return Ok (MethodNotAllowed); } }; let path = uri.path (); if path == "/favicon.ico" { return Ok (Favicon); } if path == "/" { return Ok (Root); } if let Some (path) = prefix_match ("/api", path) { return serve_api (root, &uri, hidden_path, path).await; } let path = match prefix_match ("/files/", path) { Some (x) => x, None => return Ok (NotFound), }; // TODO: There is totally a dir traversal attack in here somewhere let encoded_path = &path [0..]; let path_s = percent_decode (encoded_path.as_bytes ()).decode_utf8 ().map_err (FileServerError::PathNotUtf8)?; let path = Path::new (&*path_s); let full_path = root.join (path); debug! ("full_path = {:?}", full_path); if let Some (hidden_path) = hidden_path { if full_path == hidden_path { return Ok (Forbidden); } } if let Ok (dir) = read_dir (&full_path).await { serve_dir ( &path_s, path, dir, full_path, &uri, OutputFormat::Html ) } else if let Ok (file) = File::open (&full_path).await { serve_file ( file, &uri, send_body, headers ).await } else { Ok (NotFound) } }