diff --git a/Cargo.lock b/Cargo.lock index b2e9baf2..26a8e5fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -518,12 +518,22 @@ checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" [[package]] name = "file-explorer" version = "0.0.0" +dependencies = [ + "anyhow", + "chrono", + "mime_guess", + "tokio", +] + +[[package]] +name = "file-explorer-plugin" +version = "0.0.0" dependencies = [ "anyhow", "async-trait", "bytes", "chrono", - "file-explorer-core", + "file-explorer", "file-explorer-proto", "file-explorer-ui", "futures", @@ -544,16 +554,6 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "file-explorer-core" -version = "0.0.0" -dependencies = [ - "anyhow", - "chrono", - "mime_guess", - "tokio", -] - [[package]] name = "file-explorer-proto" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index 78343e01..9b693233 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [workspace] members = [ "crates/file-explorer", - "crates/file-explorer-core", + "crates/file-explorer-plugin", "crates/file-explorer-proto", "crates/file-explorer-ui", "crates/http-server", @@ -51,7 +51,7 @@ tracing-subscriber = "0.3.18" web-sys = "0.3.72" # Workspace Crates -file-explorer-core = { path = "crates/file-explorer-core" } +file-explorer = { path = "crates/file-explorer" } file-explorer-proto = { path = "crates/file-explorer-proto" } file-explorer-ui = { path = "crates/file-explorer-ui" } http-server-plugin = { path = "crates/http-server-plugin" } diff --git a/crates/file-explorer-core/Cargo.toml b/crates/file-explorer-core/Cargo.toml deleted file mode 100644 index 97f158bc..00000000 --- a/crates/file-explorer-core/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "file-explorer-core" -version = "0.0.0" -authors = ["Esteban Borai "] -edition = "2021" -publish = false - -[dependencies] -anyhow = { workspace = true } -chrono = { workspace = true, features = ["serde"] } -mime_guess = { workspace = true } -tokio = { workspace = true, features = ["fs", "rt-multi-thread", "signal", "macros"] } diff --git a/crates/file-explorer-core/src/lib.rs b/crates/file-explorer-core/src/lib.rs deleted file mode 100644 index e007c8b9..00000000 --- a/crates/file-explorer-core/src/lib.rs +++ /dev/null @@ -1,113 +0,0 @@ -mod fs; - -use std::path::{Component, PathBuf}; - -use tokio::fs::OpenOptions; - -pub use self::fs::{Directory, File}; - -use anyhow::Result; - -/// Any OS filesystem entry recognized by [`FileExplorer`] is treated as a -/// `Entry` both `File` and `Directory` are possible values. -#[derive(Debug)] -pub enum Entry { - File(Box), - Directory(Directory), -} - -pub struct FileExplorer { - root: PathBuf, -} - -impl FileExplorer { - pub fn new(root: PathBuf) -> Self { - Self { root } - } - - /// Peeks on the provided `path` as a "subpath" for this [`FileExplorer`] instance. - pub async fn peek(&self, path: PathBuf) -> Result { - let relative_path = self.build_relative_path(path); - self.open(relative_path).await - } - - /// Joins the provided `path` with the `root` path of this [`FileExplorer`] instance. - fn build_relative_path(&self, path: PathBuf) -> PathBuf { - let mut root = self.root.clone(); - root.extend(&self.normalize_path(&path)); - root - } - - /// Normalizes a `Path` to be directory-traversal safe. - /// - /// ```ignore - /// docs/collegue/cs50/lectures/../code/voting_excecise - /// ``` - /// - /// Will be normalized to be: - /// - /// ```ignore - /// docs/collegue/cs50/code/voting_excecise - /// ``` - /// - /// # Reference - /// - /// - https://owasp.org/www-community/attacks/Path_Traversal - fn normalize_path(&self, path: &PathBuf) -> PathBuf { - path.components() - .fold(PathBuf::new(), |mut result, p| match p { - Component::ParentDir => { - result.pop(); - result - } - Component::Normal(os_string) => { - result.push(os_string); - result - } - _ => result, - }) - } - - #[cfg(not(target_os = "windows"))] - async fn open(&self, path: PathBuf) -> Result { - let mut open_options = OpenOptions::new(); - let entry_path: PathBuf = path.clone(); - let file = open_options.read(true).open(path).await?; - let metadata = file.metadata().await?; - - if metadata.is_dir() { - return Ok(Entry::Directory(Directory { path: entry_path })); - } - - Ok(Entry::File(Box::new(File::new(entry_path, file, metadata)))) - } - - #[cfg(target_os = "windows")] - async fn open(&self, path: PathBuf) -> Result { - /// The file is being opened or created for a backup or restore operation. - /// The system ensures that the calling process overrides file security - /// checks when the process has SE_BACKUP_NAME and SE_RESTORE_NAME privileges. - /// - /// For more information, see Changing Privileges in a Token. - /// You must set this flag to obtain a handle to a directory. - /// A directory handle can be passed to some functions instead of a file handle. - /// - /// Refer: https://docs.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilea - const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x02000000; - - let mut open_options = OpenOptions::new(); - let entry_path: PathBuf = path.clone(); - let file = open_options - .read(true) - .custom_flags(FILE_FLAG_BACKUP_SEMANTICS) - .open(path) - .await?; - let metadata = file.metadata().await?; - - if metadata.is_dir() { - return Ok(Entry::Directory(Directory { path: entry_path })); - } - - Ok(Entry::File(Box::new(File::new(entry_path, file, metadata)))) - } -} diff --git a/crates/file-explorer-plugin/Cargo.toml b/crates/file-explorer-plugin/Cargo.toml new file mode 100644 index 00000000..26c6f06b --- /dev/null +++ b/crates/file-explorer-plugin/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "file-explorer-plugin" +version = "0.0.0" +authors = ["Esteban Borai "] +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +anyhow = { workspace = true } +async-trait = { workspace = true } +bytes = { workspace = true } +chrono = { workspace = true, features = ["serde"] } +futures = { workspace = true } +http = { workspace = true } +http-body-util = { workspace = true } +humansize = { workspace = true } +hyper = { workspace = true } +mime_guess = { workspace = true } +multer = { workspace = true } +rust-embed = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +percent-encoding = { workspace = true } +tokio = { workspace = true, features = ["fs", "rt-multi-thread", "signal", "macros"] } +tokio-util = { workspace = true, features = ["io"] } +tracing = { workspace = true } +tracing-subscriber = { workspace = true, features = ["env-filter"] } + +http-server-plugin = { workspace = true } +file-explorer = { workspace = true } +file-explorer-proto = { workspace = true } +file-explorer-ui = { workspace = true } diff --git a/crates/file-explorer-plugin/src/lib.rs b/crates/file-explorer-plugin/src/lib.rs new file mode 100644 index 00000000..4ae3b73f --- /dev/null +++ b/crates/file-explorer-plugin/src/lib.rs @@ -0,0 +1,387 @@ +mod utils; + +use std::fs::read_dir; +use std::mem::MaybeUninit; +use std::path::{Component, Path, PathBuf}; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use async_trait::async_trait; +use http::request::Parts; +use http::HeaderValue; +use http_body_util::Full; +use hyper::body::Bytes; +use hyper::header::CONTENT_TYPE; +use hyper::{Method, Response, StatusCode, Uri}; +use multer::Multipart; +use percent_encoding::{percent_decode_str, utf8_percent_encode}; +use serde::Deserialize; +use tokio::io::AsyncWriteExt; +use tokio::runtime::Handle; + +use file_explorer::{Entry, FileExplorer}; +use file_explorer_proto::{BreadcrumbItem, DirectoryEntry, DirectoryIndex, EntryType, Sort}; +use file_explorer_ui::Assets; +use http_server_plugin::config::read_from_path; +use http_server_plugin::{export_plugin, Function, InvocationError, PluginRegistrar}; + +use self::utils::{decode_uri, encode_uri, PERCENT_ENCODE_SET}; + +const FILE_BUFFER_SIZE: usize = 8 * 1024; + +pub type FileBuffer = Box<[MaybeUninit; FILE_BUFFER_SIZE]>; + +export_plugin!(register); + +const PLUGIN_NAME: &str = "file-explorer"; + +#[allow(improper_ctypes_definitions)] +extern "C" fn register(config_path: PathBuf, rt: Arc, registrar: &mut dyn PluginRegistrar) { + tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .init(); + + let config: FileExplorerConfig = read_from_path(config_path, PLUGIN_NAME).unwrap(); + + registrar.register_function( + PLUGIN_NAME, + Arc::new(FileExplorerPlugin::new(rt, config.path)), + ); +} + +#[derive(Debug, Deserialize)] +struct FileExplorerConfig { + pub path: PathBuf, +} + +struct FileExplorerPlugin { + file_explorer: FileExplorer, + path: PathBuf, + rt: Arc, +} + +#[async_trait] +impl Function for FileExplorerPlugin { + async fn call( + &self, + parts: Parts, + body: Bytes, + ) -> Result>, InvocationError> { + self.rt + .block_on(async move { self.handle(parts, body).await }) + } +} + +impl FileExplorerPlugin { + fn new(rt: Arc, path: PathBuf) -> Self { + let file_explorer = FileExplorer::new(path.clone()); + + Self { + file_explorer, + path, + rt, + } + } + + async fn handle( + &self, + parts: Parts, + body: Bytes, + ) -> Result>, InvocationError> { + tracing::info!("Handling request: {:?}", parts); + + if parts.uri.path().starts_with("/api/v1") { + self.handle_api(parts, body).await + } else { + let path = parts.uri.path(); + let path = path.strip_prefix('/').unwrap_or(path); + + if let Some(file) = Assets::get(path) { + let content_type = mime_guess::from_path(path).first_or_octet_stream(); + let content_type = HeaderValue::from_str(content_type.as_ref()).unwrap(); + let body = Full::new(Bytes::from(file.data.to_vec())); + let mut response = Response::new(body); + let mut headers = response.headers().clone(); + + headers.append(CONTENT_TYPE, content_type); + *response.headers_mut() = headers; + + return Ok(response); + } + + let index = Assets::get("index.html").unwrap(); + let body = Full::new(Bytes::from(index.data.to_vec())); + let mut response = Response::new(body); + let mut headers = response.headers().clone(); + + headers.append(CONTENT_TYPE, "text/html".try_into().unwrap()); + *response.headers_mut() = headers; + + Ok(response) + } + } + + async fn handle_api( + &self, + parts: Parts, + body: Bytes, + ) -> Result>, InvocationError> { + let path = Self::parse_req_uri(parts.uri.clone()).unwrap(); + + match parts.method { + Method::GET => match self.file_explorer.peek(path).await { + Ok(entry) => match entry { + Entry::Directory(dir) => { + let directory_index = + self.marshall_directory_index(dir.path()).await.unwrap(); + let json = serde_json::to_string(&directory_index).unwrap(); + let body = Full::new(Bytes::from(json)); + let mut response = Response::new(body); + let mut headers = response.headers().clone(); + + headers.append(CONTENT_TYPE, "application/json".try_into().unwrap()); + *response.headers_mut() = headers; + + Ok(response) + } + Entry::File(mut file) => { + let body = Full::new(Bytes::from(file.bytes().await.unwrap())); + let mut response = Response::new(body); + let mut headers = response.headers().clone(); + + headers.append(CONTENT_TYPE, file.mime().to_string().try_into().unwrap()); + *response.headers_mut() = headers; + + Ok(response) + } + }, + Err(err) => { + let message = format!("Failed to resolve path: {}", err); + Ok(Response::new(Full::new(Bytes::from(message)))) + } + }, + Method::POST => { + self.handle_file_upload(parts, body).await?; + Ok(Response::new(Full::new(Bytes::from( + "POST method is not supported", + )))) + } + _ => Ok(Response::new(Full::new(Bytes::from("Unsupported method")))), + } + } + + async fn handle_file_upload( + &self, + parts: Parts, + body: Bytes, + ) -> Result>, InvocationError> { + // Extract the `multipart/form-data` boundary from the headers. + let boundary = parts + .headers + .get(CONTENT_TYPE) + .and_then(|ct| ct.to_str().ok()) + .and_then(|ct| multer::parse_boundary(ct).ok()); + + // Send `BAD_REQUEST` status if the content-type is not multipart/form-data. + if boundary.is_none() { + return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .body(Full::from("BAD REQUEST")) + .unwrap()); + } + + // Process the multipart e.g. you can store them in files. + if let Err(err) = self.process_multipart(body, boundary.unwrap()).await { + return Ok(Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(Full::from(format!("INTERNAL SERVER ERROR: {}", err))) + .unwrap()); + } + + Ok(Response::new(Full::from("Success"))) + } + + async fn process_multipart(&self, bytes: Bytes, boundary: String) -> multer::Result<()> { + let cursor = std::io::Cursor::new(bytes); + let bytes_stream = tokio_util::io::ReaderStream::new(cursor); + let mut multipart = Multipart::new(bytes_stream, boundary); + + // Iterate over the fields, `next_field` method will return the next field if + // available. + while let Some(mut field) = multipart.next_field().await? { + // Get the field name. + let name = field.name(); + + // Get the field's filename if provided in "Content-Disposition" header. + let file_name = field.file_name().to_owned().unwrap_or("default.png"); + + // Get the "Content-Type" header as `mime::Mime` type. + let content_type = field.content_type(); + + let mut file = tokio::fs::File::create(file_name).await.unwrap(); + + println!( + "\n\nName: {:?}, FileName: {:?}, Content-Type: {:?}\n\n", + name, file_name, content_type + ); + + // Process the field data chunks e.g. store them in a file. + let mut field_bytes_len = 0; + while let Some(field_chunk) = field.chunk().await? { + // Do something with field chunk. + field_bytes_len += field_chunk.len(); + file.write_all(&field_chunk).await.unwrap(); + } + + println!("Field Bytes Length: {:?}", field_bytes_len); + } + + Ok(()) + } + + fn parse_req_uri(uri: Uri) -> Result { + let parts: Vec<&str> = uri.path().split('/').collect(); + let path = &parts[3..].join("/"); + + Ok(decode_uri(path)) + } + + /// Encodes a `PathBuf` component using `PercentEncode` with UTF-8 charset. + /// + /// # Panics + /// + /// If the component's `OsStr` representation doesn't belong to valid UTF-8 + /// this function panics. + fn encode_component(comp: Component) -> String { + let component = comp + .as_os_str() + .to_str() + .expect("The provided OsStr doesn't belong to the UTF-8 charset."); + + utf8_percent_encode(component, PERCENT_ENCODE_SET).to_string() + } + + fn breadcrumbs_from_path(root_dir: &Path, path: &Path) -> Result> { + let root_dir_name = root_dir + .components() + .last() + .unwrap() + .as_os_str() + .to_str() + .expect("The first path component is not UTF-8 charset compliant."); + let stripped = path + .strip_prefix(root_dir)? + .components() + .map(Self::encode_component) + .collect::>(); + + let mut breadcrumbs = stripped + .iter() + .enumerate() + .map(|(idx, entry_name)| BreadcrumbItem { + depth: (idx + 1) as u8, + entry_name: percent_decode_str(entry_name) + .decode_utf8() + .expect("The path name is not UTF-8 compliant") + .to_string(), + entry_link: format!("/{}", stripped[0..=idx].join("/")), + }) + .collect::>(); + + breadcrumbs.insert( + 0, + BreadcrumbItem { + depth: 0, + entry_name: String::from(root_dir_name), + entry_link: String::from("/"), + }, + ); + + Ok(breadcrumbs) + } + + /// Creates entry's relative path. Used by Handlebars template engine to + /// provide navigation through `FileExplorer` + /// + /// If the root_dir is: `https-server/src` + /// The entry path is: `https-server/src/server/service/file_explorer.rs` + /// + /// Then the resulting path from this function is the absolute path to + /// the "entry path" in relation to the "root_dir" path. + /// + /// This happens because links should behave relative to the `/` path + /// which in this case is `http-server/src` instead of system's root path. + fn make_dir_entry_link(root_dir: &Path, entry_path: &Path) -> String { + let path = entry_path.strip_prefix(root_dir).unwrap(); + + encode_uri(path) + } + + /// Creates a `DirectoryIndex` with the provided `root_dir` and `path` + /// (HTTP Request URI) + fn index_directory(root_dir: PathBuf, path: PathBuf) -> Result { + let breadcrumbs = Self::breadcrumbs_from_path(&root_dir, &path)?; + let entries = read_dir(path).context("Unable to read directory")?; + let mut directory_entries: Vec = Vec::new(); + + for entry in entries { + let entry = entry.context("Unable to read entry")?; + let metadata = entry.metadata()?; + + let display_name = entry + .file_name() + .to_str() + .context("Unable to gather file name into a String")? + .to_string(); + + let date_created = if let Ok(time) = metadata.created() { + Some(time.into()) + } else { + None + }; + + let date_modified = if let Ok(time) = metadata.modified() { + Some(time.into()) + } else { + None + }; + + let entry_type = if metadata.file_type().is_dir() { + EntryType::Directory + } else if let Some(ext) = display_name.split(".").last() { + match ext.to_ascii_lowercase().as_str() { + "gitignore" | "gitkeep" => EntryType::Git, + "justfile" => EntryType::Justfile, + "md" => EntryType::Markdown, + "rs" => EntryType::Rust, + "toml" => EntryType::Toml, + _ => EntryType::File, + } + } else { + EntryType::File + }; + + directory_entries.push(DirectoryEntry { + is_dir: metadata.is_dir(), + size_bytes: metadata.len(), + entry_path: Self::make_dir_entry_link(&root_dir, &entry.path()), + display_name, + entry_type, + date_created, + date_modified, + }); + } + + directory_entries.sort(); + + Ok(DirectoryIndex { + entries: directory_entries, + breadcrumbs, + sort: Sort::Directory, + }) + } + + async fn marshall_directory_index(&self, path: PathBuf) -> Result { + Self::index_directory(self.path.clone(), path) + } +} diff --git a/crates/file-explorer/src/utils.rs b/crates/file-explorer-plugin/src/utils.rs similarity index 100% rename from crates/file-explorer/src/utils.rs rename to crates/file-explorer-plugin/src/utils.rs diff --git a/crates/file-explorer/Cargo.toml b/crates/file-explorer/Cargo.toml index 729e400f..178e59c9 100644 --- a/crates/file-explorer/Cargo.toml +++ b/crates/file-explorer/Cargo.toml @@ -5,31 +5,8 @@ authors = ["Esteban Borai "] edition = "2021" publish = false -[lib] -crate-type = ["cdylib"] - [dependencies] anyhow = { workspace = true } -async-trait = { workspace = true } -bytes = { workspace = true } chrono = { workspace = true, features = ["serde"] } -futures = { workspace = true } -http = { workspace = true } -http-body-util = { workspace = true } -humansize = { workspace = true } -hyper = { workspace = true } mime_guess = { workspace = true } -multer = { workspace = true } -rust-embed = { workspace = true } -serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } -percent-encoding = { workspace = true } tokio = { workspace = true, features = ["fs", "rt-multi-thread", "signal", "macros"] } -tokio-util = { workspace = true, features = ["io"] } -tracing = { workspace = true } -tracing-subscriber = { workspace = true, features = ["env-filter"] } - -http-server-plugin = { workspace = true } -file-explorer-core = { workspace = true } -file-explorer-proto = { workspace = true } -file-explorer-ui = { workspace = true } diff --git a/crates/file-explorer-core/src/fs/directory.rs b/crates/file-explorer/src/fs/directory.rs similarity index 100% rename from crates/file-explorer-core/src/fs/directory.rs rename to crates/file-explorer/src/fs/directory.rs diff --git a/crates/file-explorer-core/src/fs/file.rs b/crates/file-explorer/src/fs/file.rs similarity index 100% rename from crates/file-explorer-core/src/fs/file.rs rename to crates/file-explorer/src/fs/file.rs diff --git a/crates/file-explorer-core/src/fs/mod.rs b/crates/file-explorer/src/fs/mod.rs similarity index 100% rename from crates/file-explorer-core/src/fs/mod.rs rename to crates/file-explorer/src/fs/mod.rs diff --git a/crates/file-explorer/src/lib.rs b/crates/file-explorer/src/lib.rs index a8f21422..e007c8b9 100644 --- a/crates/file-explorer/src/lib.rs +++ b/crates/file-explorer/src/lib.rs @@ -1,387 +1,113 @@ -mod utils; +mod fs; -use std::fs::read_dir; -use std::mem::MaybeUninit; -use std::path::{Component, Path, PathBuf}; -use std::sync::Arc; +use std::path::{Component, PathBuf}; -use anyhow::{Context, Result}; -use async_trait::async_trait; -use http::request::Parts; -use http::HeaderValue; -use http_body_util::Full; -use hyper::body::Bytes; -use hyper::header::CONTENT_TYPE; -use hyper::{Method, Response, StatusCode, Uri}; -use multer::Multipart; -use percent_encoding::{percent_decode_str, utf8_percent_encode}; -use serde::Deserialize; -use tokio::io::AsyncWriteExt; -use tokio::runtime::Handle; +use tokio::fs::OpenOptions; -use file_explorer_core::{Entry, FileExplorer}; -use file_explorer_proto::{BreadcrumbItem, DirectoryEntry, DirectoryIndex, EntryType, Sort}; -use file_explorer_ui::Assets; -use http_server_plugin::config::read_from_path; -use http_server_plugin::{export_plugin, Function, InvocationError, PluginRegistrar}; +pub use self::fs::{Directory, File}; -use self::utils::{decode_uri, encode_uri, PERCENT_ENCODE_SET}; +use anyhow::Result; -const FILE_BUFFER_SIZE: usize = 8 * 1024; - -pub type FileBuffer = Box<[MaybeUninit; FILE_BUFFER_SIZE]>; - -export_plugin!(register); - -const PLUGIN_NAME: &str = "file-explorer"; - -#[allow(improper_ctypes_definitions)] -extern "C" fn register(config_path: PathBuf, rt: Arc, registrar: &mut dyn PluginRegistrar) { - tracing_subscriber::fmt() - .with_max_level(tracing::Level::INFO) - .init(); - - let config: FileExplorerConfig = read_from_path(config_path, PLUGIN_NAME).unwrap(); - - registrar.register_function( - PLUGIN_NAME, - Arc::new(FileExplorerPlugin::new(rt, config.path)), - ); -} - -#[derive(Debug, Deserialize)] -struct FileExplorerConfig { - pub path: PathBuf, -} - -struct FileExplorerPlugin { - file_explorer: FileExplorer, - path: PathBuf, - rt: Arc, +/// Any OS filesystem entry recognized by [`FileExplorer`] is treated as a +/// `Entry` both `File` and `Directory` are possible values. +#[derive(Debug)] +pub enum Entry { + File(Box), + Directory(Directory), } -#[async_trait] -impl Function for FileExplorerPlugin { - async fn call( - &self, - parts: Parts, - body: Bytes, - ) -> Result>, InvocationError> { - self.rt - .block_on(async move { self.handle(parts, body).await }) - } +pub struct FileExplorer { + root: PathBuf, } -impl FileExplorerPlugin { - fn new(rt: Arc, path: PathBuf) -> Self { - let file_explorer = FileExplorer::new(path.clone()); - - Self { - file_explorer, - path, - rt, - } - } - - async fn handle( - &self, - parts: Parts, - body: Bytes, - ) -> Result>, InvocationError> { - tracing::info!("Handling request: {:?}", parts); - - if parts.uri.path().starts_with("/api/v1") { - self.handle_api(parts, body).await - } else { - let path = parts.uri.path(); - let path = path.strip_prefix('/').unwrap_or(path); - - if let Some(file) = Assets::get(path) { - let content_type = mime_guess::from_path(path).first_or_octet_stream(); - let content_type = HeaderValue::from_str(content_type.as_ref()).unwrap(); - let body = Full::new(Bytes::from(file.data.to_vec())); - let mut response = Response::new(body); - let mut headers = response.headers().clone(); - - headers.append(CONTENT_TYPE, content_type); - *response.headers_mut() = headers; - - return Ok(response); - } - - let index = Assets::get("index.html").unwrap(); - let body = Full::new(Bytes::from(index.data.to_vec())); - let mut response = Response::new(body); - let mut headers = response.headers().clone(); - - headers.append(CONTENT_TYPE, "text/html".try_into().unwrap()); - *response.headers_mut() = headers; - - Ok(response) - } +impl FileExplorer { + pub fn new(root: PathBuf) -> Self { + Self { root } } - async fn handle_api( - &self, - parts: Parts, - body: Bytes, - ) -> Result>, InvocationError> { - let path = Self::parse_req_uri(parts.uri.clone()).unwrap(); - - match parts.method { - Method::GET => match self.file_explorer.peek(path).await { - Ok(entry) => match entry { - Entry::Directory(dir) => { - let directory_index = - self.marshall_directory_index(dir.path()).await.unwrap(); - let json = serde_json::to_string(&directory_index).unwrap(); - let body = Full::new(Bytes::from(json)); - let mut response = Response::new(body); - let mut headers = response.headers().clone(); - - headers.append(CONTENT_TYPE, "application/json".try_into().unwrap()); - *response.headers_mut() = headers; - - Ok(response) - } - Entry::File(mut file) => { - let body = Full::new(Bytes::from(file.bytes().await.unwrap())); - let mut response = Response::new(body); - let mut headers = response.headers().clone(); - - headers.append(CONTENT_TYPE, file.mime().to_string().try_into().unwrap()); - *response.headers_mut() = headers; - - Ok(response) - } - }, - Err(err) => { - let message = format!("Failed to resolve path: {}", err); - Ok(Response::new(Full::new(Bytes::from(message)))) - } - }, - Method::POST => { - self.handle_file_upload(parts, body).await?; - Ok(Response::new(Full::new(Bytes::from( - "POST method is not supported", - )))) - } - _ => Ok(Response::new(Full::new(Bytes::from("Unsupported method")))), - } + /// Peeks on the provided `path` as a "subpath" for this [`FileExplorer`] instance. + pub async fn peek(&self, path: PathBuf) -> Result { + let relative_path = self.build_relative_path(path); + self.open(relative_path).await } - async fn handle_file_upload( - &self, - parts: Parts, - body: Bytes, - ) -> Result>, InvocationError> { - // Extract the `multipart/form-data` boundary from the headers. - let boundary = parts - .headers - .get(CONTENT_TYPE) - .and_then(|ct| ct.to_str().ok()) - .and_then(|ct| multer::parse_boundary(ct).ok()); - - // Send `BAD_REQUEST` status if the content-type is not multipart/form-data. - if boundary.is_none() { - return Ok(Response::builder() - .status(StatusCode::BAD_REQUEST) - .body(Full::from("BAD REQUEST")) - .unwrap()); - } - - // Process the multipart e.g. you can store them in files. - if let Err(err) = self.process_multipart(body, boundary.unwrap()).await { - return Ok(Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .body(Full::from(format!("INTERNAL SERVER ERROR: {}", err))) - .unwrap()); - } - - Ok(Response::new(Full::from("Success"))) + /// Joins the provided `path` with the `root` path of this [`FileExplorer`] instance. + fn build_relative_path(&self, path: PathBuf) -> PathBuf { + let mut root = self.root.clone(); + root.extend(&self.normalize_path(&path)); + root } - async fn process_multipart(&self, bytes: Bytes, boundary: String) -> multer::Result<()> { - let cursor = std::io::Cursor::new(bytes); - let bytes_stream = tokio_util::io::ReaderStream::new(cursor); - let mut multipart = Multipart::new(bytes_stream, boundary); - - // Iterate over the fields, `next_field` method will return the next field if - // available. - while let Some(mut field) = multipart.next_field().await? { - // Get the field name. - let name = field.name(); - - // Get the field's filename if provided in "Content-Disposition" header. - let file_name = field.file_name().to_owned().unwrap_or("default.png"); - - // Get the "Content-Type" header as `mime::Mime` type. - let content_type = field.content_type(); - - let mut file = tokio::fs::File::create(file_name).await.unwrap(); - - println!( - "\n\nName: {:?}, FileName: {:?}, Content-Type: {:?}\n\n", - name, file_name, content_type - ); - - // Process the field data chunks e.g. store them in a file. - let mut field_bytes_len = 0; - while let Some(field_chunk) = field.chunk().await? { - // Do something with field chunk. - field_bytes_len += field_chunk.len(); - file.write_all(&field_chunk).await.unwrap(); - } - - println!("Field Bytes Length: {:?}", field_bytes_len); - } - - Ok(()) - } - - fn parse_req_uri(uri: Uri) -> Result { - let parts: Vec<&str> = uri.path().split('/').collect(); - let path = &parts[3..].join("/"); - - Ok(decode_uri(path)) - } - - /// Encodes a `PathBuf` component using `PercentEncode` with UTF-8 charset. + /// Normalizes a `Path` to be directory-traversal safe. /// - /// # Panics + /// ```ignore + /// docs/collegue/cs50/lectures/../code/voting_excecise + /// ``` /// - /// If the component's `OsStr` representation doesn't belong to valid UTF-8 - /// this function panics. - fn encode_component(comp: Component) -> String { - let component = comp - .as_os_str() - .to_str() - .expect("The provided OsStr doesn't belong to the UTF-8 charset."); - - utf8_percent_encode(component, PERCENT_ENCODE_SET).to_string() - } - - fn breadcrumbs_from_path(root_dir: &Path, path: &Path) -> Result> { - let root_dir_name = root_dir - .components() - .last() - .unwrap() - .as_os_str() - .to_str() - .expect("The first path component is not UTF-8 charset compliant."); - let stripped = path - .strip_prefix(root_dir)? - .components() - .map(Self::encode_component) - .collect::>(); - - let mut breadcrumbs = stripped - .iter() - .enumerate() - .map(|(idx, entry_name)| BreadcrumbItem { - depth: (idx + 1) as u8, - entry_name: percent_decode_str(entry_name) - .decode_utf8() - .expect("The path name is not UTF-8 compliant") - .to_string(), - entry_link: format!("/{}", stripped[0..=idx].join("/")), - }) - .collect::>(); - - breadcrumbs.insert( - 0, - BreadcrumbItem { - depth: 0, - entry_name: String::from(root_dir_name), - entry_link: String::from("/"), - }, - ); - - Ok(breadcrumbs) - } - - /// Creates entry's relative path. Used by Handlebars template engine to - /// provide navigation through `FileExplorer` + /// Will be normalized to be: /// - /// If the root_dir is: `https-server/src` - /// The entry path is: `https-server/src/server/service/file_explorer.rs` + /// ```ignore + /// docs/collegue/cs50/code/voting_excecise + /// ``` /// - /// Then the resulting path from this function is the absolute path to - /// the "entry path" in relation to the "root_dir" path. + /// # Reference /// - /// This happens because links should behave relative to the `/` path - /// which in this case is `http-server/src` instead of system's root path. - fn make_dir_entry_link(root_dir: &Path, entry_path: &Path) -> String { - let path = entry_path.strip_prefix(root_dir).unwrap(); - - encode_uri(path) + /// - https://owasp.org/www-community/attacks/Path_Traversal + fn normalize_path(&self, path: &PathBuf) -> PathBuf { + path.components() + .fold(PathBuf::new(), |mut result, p| match p { + Component::ParentDir => { + result.pop(); + result + } + Component::Normal(os_string) => { + result.push(os_string); + result + } + _ => result, + }) } - /// Creates a `DirectoryIndex` with the provided `root_dir` and `path` - /// (HTTP Request URI) - fn index_directory(root_dir: PathBuf, path: PathBuf) -> Result { - let breadcrumbs = Self::breadcrumbs_from_path(&root_dir, &path)?; - let entries = read_dir(path).context("Unable to read directory")?; - let mut directory_entries: Vec = Vec::new(); - - for entry in entries { - let entry = entry.context("Unable to read entry")?; - let metadata = entry.metadata()?; + #[cfg(not(target_os = "windows"))] + async fn open(&self, path: PathBuf) -> Result { + let mut open_options = OpenOptions::new(); + let entry_path: PathBuf = path.clone(); + let file = open_options.read(true).open(path).await?; + let metadata = file.metadata().await?; - let display_name = entry - .file_name() - .to_str() - .context("Unable to gather file name into a String")? - .to_string(); - - let date_created = if let Ok(time) = metadata.created() { - Some(time.into()) - } else { - None - }; - - let date_modified = if let Ok(time) = metadata.modified() { - Some(time.into()) - } else { - None - }; - - let entry_type = if metadata.file_type().is_dir() { - EntryType::Directory - } else if let Some(ext) = display_name.split(".").last() { - match ext.to_ascii_lowercase().as_str() { - "gitignore" | "gitkeep" => EntryType::Git, - "justfile" => EntryType::Justfile, - "md" => EntryType::Markdown, - "rs" => EntryType::Rust, - "toml" => EntryType::Toml, - _ => EntryType::File, - } - } else { - EntryType::File - }; - - directory_entries.push(DirectoryEntry { - is_dir: metadata.is_dir(), - size_bytes: metadata.len(), - entry_path: Self::make_dir_entry_link(&root_dir, &entry.path()), - display_name, - entry_type, - date_created, - date_modified, - }); + if metadata.is_dir() { + return Ok(Entry::Directory(Directory { path: entry_path })); } - directory_entries.sort(); - - Ok(DirectoryIndex { - entries: directory_entries, - breadcrumbs, - sort: Sort::Directory, - }) + Ok(Entry::File(Box::new(File::new(entry_path, file, metadata)))) } - async fn marshall_directory_index(&self, path: PathBuf) -> Result { - Self::index_directory(self.path.clone(), path) + #[cfg(target_os = "windows")] + async fn open(&self, path: PathBuf) -> Result { + /// The file is being opened or created for a backup or restore operation. + /// The system ensures that the calling process overrides file security + /// checks when the process has SE_BACKUP_NAME and SE_RESTORE_NAME privileges. + /// + /// For more information, see Changing Privileges in a Token. + /// You must set this flag to obtain a handle to a directory. + /// A directory handle can be passed to some functions instead of a file handle. + /// + /// Refer: https://docs.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilea + const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x02000000; + + let mut open_options = OpenOptions::new(); + let entry_path: PathBuf = path.clone(); + let file = open_options + .read(true) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS) + .open(path) + .await?; + let metadata = file.metadata().await?; + + if metadata.is_dir() { + return Ok(Entry::Directory(Directory { path: entry_path })); + } + + Ok(Entry::File(Box::new(File::new(entry_path, file, metadata)))) } } diff --git a/crates/http-server/src/server/mod.rs b/crates/http-server/src/server/mod.rs index 9fd88b8a..deb36c23 100644 --- a/crates/http-server/src/server/mod.rs +++ b/crates/http-server/src/server/mod.rs @@ -38,7 +38,7 @@ impl Server { let addr = SocketAddr::from((self.config.host, self.config.port)); let listener = TcpListener::bind(addr).await?; let functions = Arc::new(ExternalFunctions::new()); - let plugin_library = PathBuf::from_str("./target/debug/libfile_explorer.dylib")?; + let plugin_library = PathBuf::from_str("./target/debug/libfile_explorer_plugin.dylib")?; let config = PathBuf::from_str("./config.toml")?; let handle = Arc::new(rt.handle().to_owned());