// 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, path::{Path, PathBuf}, }; use percent_encoding::percent_decode; use tokio::{ fs::{ File, read_dir, ReadDir, }, }; #[cfg (test)] use always_equal::test::AlwaysEqual; #[cfg (not (test))] use always_equal::prod::AlwaysEqual; use ptth_core::{ http_serde::Method, prelude::*, }; use crate::{ load_toml, }; use super::{ errors::FileServerError, range, }; #[cfg (feature = "markdown")] use super::markdown::{ self, render_styled, }; #[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 (MarkdownErrWrapper), MarkdownPreview (String), } #[cfg (feature = "markdown")] #[derive (Debug, PartialEq)] pub struct MarkdownErrWrapper { pub inner: markdown::Error, } #[cfg (feature = "markdown")] impl MarkdownErrWrapper { fn new (inner: markdown::Error) -> Self { Self { inner } } } #[cfg (not (feature = "markdown"))] #[derive (Debug, PartialEq)] pub struct MarkdownErrWrapper {} 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 ( file: tokio::fs::File, uri: &http::Uri, send_body: bool, headers: &HashMap > ) -> Result { use range::Parsed::*; 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 ()); let range = match range::check (range_header, file_len) { NotSatisfiable (file_len) => return Ok (Response::RangeNotSatisfiable (file_len)), Valid (range) => range, }; #[cfg (feature = "markdown")] { 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 () { return Ok (Response::MarkdownErr (MarkdownErrWrapper::new (markdown::Error::TooBig))); } else { use std::convert::TryInto; use tokio::io::AsyncReadExt; let mut buffer = vec! [0_u8; MAX_BUF_SIZE.try_into ().expect ("Couldn't fit u32 into usize")]; let mut file = file; let bytes_read = file.read (&mut buffer).await?; buffer.truncate (bytes_read); return Ok (match render_styled (&buffer) { Ok (x) => Response::MarkdownPreview (x), Err (x) => Response::MarkdownErr (MarkdownErrWrapper::new (x)), }); } } } let file = file.into (); Ok (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::*; // API versioning will be major-only, so I'll keep adding stuff to v1 // until I need to deprecate or break something. if let Some (path) = path.strip_prefix ("/v1/dir/") { 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) } // Handle the requests internally without knowing anything about PTTH or // HTML / handlebars pub async fn serve_all ( root: &Path, method: Method, uri: &str, headers: &HashMap >, hidden_path: Option <&Path> ) -> Result { use std::str::FromStr; use Response::*; trace! ("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) = path.strip_prefix ("/api") { return serve_api (root, &uri, hidden_path, path).await; } let path = match path.strip_prefix ("/files/") { 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); trace! ("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) } }