diff --git a/crates/ptth_server/src/file_server/internal.rs b/crates/ptth_server/src/file_server/internal.rs new file mode 100644 index 0000000..495fe84 --- /dev/null +++ b/crates/ptth_server/src/file_server/internal.rs @@ -0,0 +1,236 @@ +// 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 struct ServeDirParams { + pub path: PathBuf, + pub dir: AlwaysEqual , +} + +#[derive (Debug, PartialEq)] +pub struct ServeFileParams { + pub send_body: bool, + pub range: range::ValidParsed, + pub file: AlwaysEqual , +} + +#[derive (Debug, PartialEq)] +pub enum InternalResponse { + Favicon, + Forbidden, + InvalidQuery, + MethodNotAllowed, + NotFound, + RangeNotSatisfiable (u64), + Redirect (String), + Root, + ServeDir (ServeDirParams), + ServeFile (ServeFileParams), + + MarkdownErr (markdown::Error), + MarkdownPreview (String), +} + +fn internal_serve_dir ( + path_s: &str, + path: &Path, + dir: tokio::fs::ReadDir, + full_path: PathBuf, + uri: &http::Uri +) +-> 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 (InternalResponse::Redirect (format! ("{}/", file_name))); + } + + if uri.query ().is_some () { + return Ok (InternalResponse::InvalidQuery); + } + + let dir = dir.into (); + + Ok (InternalResponse::ServeDir (ServeDirParams { + dir, + path: full_path, + })) +} + +async fn internal_serve_file ( + mut file: tokio::fs::File, + uri: &http::Uri, + send_body: bool, + headers: &HashMap > +) +-> Result +{ + use std::os::unix::fs::PermissionsExt; + + let file_md = file.metadata ().await.map_err (FileServerError::CantGetFileMetadata)?; + if file_md.permissions ().mode () == load_toml::CONFIG_PERMISSIONS_MODE + { + return Ok (InternalResponse::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) => InternalResponse::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 (InternalResponse::InvalidQuery); + } + + if file_len > MAX_BUF_SIZE.into () { + InternalResponse::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) => InternalResponse::MarkdownPreview (x), + Err (x) => InternalResponse::MarkdownErr (x), + } + } + } + else { + let file = file.into (); + + InternalResponse::ServeFile (ServeFileParams { + file, + send_body, + range, + }) + } + }, + }) +} + +pub async fn internal_serve_all ( + root: &Path, + method: Method, + uri: &str, + headers: &HashMap >, + hidden_path: Option <&Path> +) +-> Result +{ + use std::str::FromStr; + use InternalResponse::*; + + 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); + } + }; + + if uri.path () == "/favicon.ico" { + return Ok (Favicon); + } + + let path = match prefix_match ("/files", uri.path ()) { + Some (x) => x, + None => return Ok (Root), + }; + + if path == "" { + return Ok (Redirect ("files/".to_string ())); + } + + // TODO: There is totally a dir traversal attack in here somewhere + + let encoded_path = &path [1..]; + + 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 { + internal_serve_dir ( + &path_s, + path, + dir, + full_path, + &uri + ) + } + else if let Ok (file) = File::open (&full_path).await { + internal_serve_file ( + file, + &uri, + send_body, + headers + ).await + } + else { + Ok (NotFound) + } +} diff --git a/crates/ptth_server/src/file_server/mod.rs b/crates/ptth_server/src/file_server/mod.rs index a34193f..7d4ae13 100644 --- a/crates/ptth_server/src/file_server/mod.rs +++ b/crates/ptth_server/src/file_server/mod.rs @@ -7,22 +7,18 @@ use std::{ borrow::Cow, cmp::min, collections::HashMap, - convert::{Infallible, TryFrom, TryInto}, + convert::{Infallible, TryFrom}, fmt::Debug, io::SeekFrom, - path::{Path, PathBuf}, + path::Path, }; use handlebars::Handlebars; -use percent_encoding::{ - percent_decode, -}; use serde::Serialize; use tokio::{ fs::{ DirEntry, File, - read_dir, ReadDir, }, io::AsyncReadExt, @@ -32,12 +28,6 @@ use tokio::{ }; use tracing::instrument; -#[cfg (test)] -use always_equal::test::AlwaysEqual; - -#[cfg (not (test))] -use always_equal::prod::AlwaysEqual; - use ptth_core::{ http_serde::{ Method, @@ -45,15 +35,15 @@ use ptth_core::{ StatusCode, }, prelude::*, - prefix_match, }; pub mod errors; +mod internal; mod markdown; mod range; use errors::FileServerError; -use markdown::render_styled; +use internal::*; mod emoji { pub const VIDEO: &str = "\u{1f39e}\u{fe0f}"; @@ -315,204 +305,6 @@ async fn serve_file ( Ok (response) } -// Sort of an internal API endpoint to make testing work better. -// 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 - -#[derive (Debug, PartialEq)] -struct ServeDirParams { - path: PathBuf, - dir: AlwaysEqual , -} - -#[derive (Debug, PartialEq)] -struct ServeFileParams { - send_body: bool, - range: range::ValidParsed, - file: AlwaysEqual , -} - -#[derive (Debug, PartialEq)] -enum InternalResponse { - Favicon, - Forbidden, - InvalidQuery, - MethodNotAllowed, - NotFound, - RangeNotSatisfiable (u64), - Redirect (String), - Root, - ServeDir (ServeDirParams), - ServeFile (ServeFileParams), - - MarkdownErr (markdown::Error), - MarkdownPreview (String), -} - -fn internal_serve_dir ( - path_s: &str, - path: &Path, - dir: tokio::fs::ReadDir, - full_path: PathBuf, - uri: &http::Uri -) --> 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 (InternalResponse::Redirect (format! ("{}/", file_name))); - } - - if uri.query ().is_some () { - return Ok (InternalResponse::InvalidQuery); - } - - let dir = dir.into (); - - Ok (InternalResponse::ServeDir (ServeDirParams { - dir, - path: full_path, - })) -} - -async fn internal_serve_file ( - mut file: tokio::fs::File, - uri: &http::Uri, - send_body: bool, - headers: &HashMap > -) --> Result -{ - use std::os::unix::fs::PermissionsExt; - - let file_md = file.metadata ().await.map_err (FileServerError::CantGetFileMetadata)?; - if file_md.permissions ().mode () == super::load_toml::CONFIG_PERMISSIONS_MODE - { - return Ok (InternalResponse::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) => InternalResponse::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 (InternalResponse::InvalidQuery); - } - - if file_len > MAX_BUF_SIZE.into () { - InternalResponse::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) => InternalResponse::MarkdownPreview (x), - Err (x) => InternalResponse::MarkdownErr (x), - } - } - } - else { - let file = file.into (); - - InternalResponse::ServeFile (ServeFileParams { - file, - send_body, - range, - }) - } - }, - }) -} - -async fn internal_serve_all ( - root: &Path, - method: Method, - uri: &str, - headers: &HashMap >, - hidden_path: Option <&Path> -) --> Result -{ - use std::str::FromStr; - use InternalResponse::*; - - 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); - } - }; - - if uri.path () == "/favicon.ico" { - return Ok (Favicon); - } - - let path = match prefix_match ("/files", uri.path ()) { - Some (x) => x, - None => return Ok (Root), - }; - - if path == "" { - return Ok (Redirect ("files/".to_string ())); - } - - // TODO: There is totally a dir traversal attack in here somewhere - - let encoded_path = &path [1..]; - - 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 { - internal_serve_dir ( - &path_s, - path, - dir, - full_path, - &uri - ) - } - else if let Ok (file) = File::open (&full_path).await { - internal_serve_file ( - file, - &uri, - send_body, - headers - ).await - } - else { - Ok (NotFound) - } -} - #[instrument (level = "debug", skip (handlebars, headers))] pub async fn serve_all ( handlebars: &Handlebars <'static>, diff --git a/crates/ptth_server/src/file_server/tests.rs b/crates/ptth_server/src/file_server/tests.rs index a78e6f1..c11bdc7 100644 --- a/crates/ptth_server/src/file_server/tests.rs +++ b/crates/ptth_server/src/file_server/tests.rs @@ -76,6 +76,11 @@ fn i_hate_paths () { #[test] fn file_server () { + use std::path::PathBuf; + + #[cfg (test)] + use always_equal::test::AlwaysEqual; + use ptth_core::{ http_serde::Method, };