diff --git a/Cargo.lock b/Cargo.lock index 1589605..c7926aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -96,7 +96,7 @@ dependencies = [ [[package]] name = "ferrox" -version = "0.4.2" +version = "0.5.0" dependencies = [ "html-escape", "mime_guess", @@ -106,6 +106,7 @@ dependencies = [ "time", "tokio", "tokio-rustls", + "urlencoding", ] [[package]] @@ -556,6 +557,12 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8-width" version = "0.1.8" diff --git a/Cargo.toml b/Cargo.toml index de0ac1a..1b55614 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ferrox" -version = "0.4.2" +version = "0.5.0" edition = "2024" [dependencies] @@ -11,4 +11,5 @@ tokio = { version = "1", features = ["full"] } serde = { version = "1.0", features = ["derive"] } serde_yaml = "0.9" tokio-rustls = "0.26" -rustls-pemfile = "2.1" \ No newline at end of file +rustls-pemfile = "2.1" +urlencoding = "2" \ No newline at end of file diff --git a/ferrox.yml b/ferrox.yml index 09aa7db..57c9d3b 100644 --- a/ferrox.yml +++ b/ferrox.yml @@ -4,6 +4,7 @@ server: http_port: 80 https_port: 443 addr: 0.0.0.0 + router: static # static/spa depending on what do you wanna host # Make sure to create these directories before starting Ferrox paths: diff --git a/src/config.rs b/src/config.rs index fe59ae8..d52f0b6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,4 @@ -use serde::Deserialize; +use serde::{Deserialize}; use std::{collections::HashMap, fs}; /// Stores the network address settings used when binding the HTTP server. @@ -7,6 +7,14 @@ pub struct ServerConfig { pub http_port: u16, pub https_port: u16, pub addr: String, + pub router: RouterPreset +} + +#[derive(Deserialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum RouterPreset { + Static, + Spa } /// Defines the filesystem paths used for static files and log output. diff --git a/src/handlers/static_files.rs b/src/handlers/static_files.rs index 90aad8b..f1a768a 100644 --- a/src/handlers/static_files.rs +++ b/src/handlers/static_files.rs @@ -1,9 +1,9 @@ +use crate::config::RouterPreset; use crate::http::response::{Body, Response}; -use crate::utils::templates::{render_indexing}; +use crate::utils::templates::render_indexing; use html_escape::encode_safe; use mime_guess::{self}; -use std::path::Path; -use std::{path::PathBuf}; +use std::path::PathBuf; use tokio::fs::File; /// Resolves a requested path inside the configured serving directory and returns a response. @@ -12,31 +12,48 @@ use tokio::fs::File; /// /// * `file_path` - The request path extracted from the HTTP request line. /// * `serving_dir` - The root directory from which static files are served. -pub async fn serve_file(file_path: &String, serving_dir: &String) -> Result { - let base = Path::new(&serving_dir).canonicalize()?; +pub async fn serve_file( + file_path: &str, + serving_dir: &str, + logic: &RouterPreset, +) -> Result { + let base = tokio::fs::canonicalize(&serving_dir).await?; let requested_path = base.join(file_path.trim_start_matches('/')); - let canonical = match requested_path.canonicalize() { + let canonical = match tokio::fs::canonicalize(&requested_path).await { Ok(p) if p.starts_with(&base) => p, Ok(_) => return Ok(Response::error("403", "Forbidden")), - Err(_) => return Ok(Response::error("404", "Not Found")), + Err(_) => { + if logic == &RouterPreset::Spa { + return Ok(spa_fallback(&base).await?); + } else { + return Ok(Response::error("404", "Not Found")); + } + } }; if canonical.is_dir() { - if !file_path.ends_with('/') { - return Ok(Response::redirect("301 Moved Permanently", &format!("{}/", file_path))); - } - - let index_html = canonical.join("index.html"); - - if index_html.exists() { - return serve_actual_file(index_html).await; + if logic == &RouterPreset::Static { + if !file_path.ends_with('/') { + return Ok(Response::redirect( + "301 Moved Permanently", + &format!("{file_path}/"), + )); + } + + let index_html = canonical.join("index.html"); + + if tokio::fs::try_exists(&index_html).await.unwrap_or(false) { + return serve_actual_file(index_html).await; + } + + return match index_files(canonical, file_path).await { + Ok(body) => Ok(Response::new_html("200 OK", body)), + Err(_) => Ok(Response::error("403", "Forbidden")), + }; + } else { + return Ok(spa_fallback(&base).await?); } - - return match index_files(canonical, file_path) { - Ok(body) => Ok(Response::new_html("200 OK", body)), - Err(_) => Ok(Response::error("403", "Forbidden")), - }; } serve_actual_file(canonical).await @@ -61,32 +78,56 @@ async fn serve_actual_file(path: PathBuf) -> Result { }) } +/// Serves the `index.html` file from the base directory as a fallback for SPA routing. +/// +/// This function is called when a requested path does not directly map to a file or directory +/// and the router is configured for Single Page Application (SPA) mode. +/// +/// # Arguments +/// * `base` - The base directory from which static files are served. +async fn spa_fallback(base: &PathBuf) -> Result { + let fallback = base.join("index.html"); + + if tokio::fs::try_exists(&fallback).await.unwrap_or(false) { + return serve_actual_file(fallback).await; + } else { + return Ok(Response::error("404", "Not Found")); + } +} + /// Builds an HTML directory listing for a filesystem path. /// /// # Arguments /// /// * `path` - The canonical directory path whose contents should be listed. /// * `display_path` - The request path displayed as the page title. -fn index_files(path: PathBuf, display_path: &String) -> Result, std::io::Error> { - let dir_entries = std::fs::read_dir(&path)?; +async fn index_files(path: PathBuf, display_path: &str) -> Result, std::io::Error> { + let mut dir_entries = tokio::fs::read_dir(&path).await?; let mut html_list = String::new(); if display_path != "/" { html_list.push_str("
  • ..
  • "); } - for entry in dir_entries.flatten() { + while let Some(entry) = dir_entries.next_entry().await? { let name = entry.file_name().to_string_lossy().to_string(); - if name.starts_with('.') { continue; } + if name.starts_with('.') { + continue; + } - let href = if entry.file_type()?.is_dir() { + let file_type = entry.file_type().await?; + + let href = if file_type.is_dir() { format!("{}/", name) } else { name }; - html_list.push_str(&format!("
  • {save_href}
  • ", save_href = encode_safe(&href))); + html_list.push_str(&format!( + "
  • {save_href}
  • ", + save_href = encode_safe(&href) + )); } Ok(render_indexing(display_path, &html_list)) @@ -103,6 +144,8 @@ mod tests { static TEST_DIR_COUNTER: AtomicU64 = AtomicU64::new(0); + // Static files test + fn unique_test_dir() -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -131,28 +174,45 @@ mod tests { #[tokio::test] async fn redirects_directory_without_trailing_slash() { + // ARRANGE let (root, serve_dir) = create_serving_layout(); fs::create_dir_all(Path::new(&serve_dir).join("docs")).expect("docs dir should be created"); - let response = serve_file(&"/docs".to_string(), &serve_dir) - .await - .expect("directory request should succeed"); + // ACT + let response = serve_file( + &"/docs", + &serve_dir, + &crate::config::RouterPreset::Static, + ) + .await + .expect("directory request should succeed"); + // ASSERT assert_eq!(response.status, "301 Moved Permanently"); - assert_eq!(response.headers, vec![("Location".to_string(), "/docs/".to_string())]); + assert_eq!( + response.headers, + vec![("Location".to_string(), "/docs/".to_string())] + ); cleanup(&root); } #[tokio::test] async fn blocks_path_traversal_outside_serving_root() { + // ARRANGE let (root, serve_dir) = create_serving_layout(); fs::write(root.join("secret.txt"), "secret").expect("outside file should be created"); - let response = serve_file(&"/../secret.txt".to_string(), &serve_dir) - .await - .expect("traversal attempt should return a response"); + // ACT + let response = serve_file( + &"/../secret.txt", + &serve_dir, + &crate::config::RouterPreset::Static, + ) + .await + .expect("traversal attempt should return a response"); + // ASSERT assert_eq!(response.status, "403 Forbidden"); cleanup(&root); @@ -160,6 +220,7 @@ mod tests { #[tokio::test] async fn serves_index_html_from_directory() { + // ARRANGE let (root, serve_dir) = create_serving_layout(); fs::create_dir_all(Path::new(&serve_dir).join("docs")).expect("docs dir should be created"); fs::write( @@ -168,41 +229,177 @@ mod tests { ) .expect("index file should be written"); - let response = serve_file(&"/docs/".to_string(), &serve_dir) - .await - .expect("directory request should succeed"); + // ACT + let response = serve_file( + &"/docs/", + &serve_dir, + &crate::config::RouterPreset::Static, + ) + .await + .expect("directory request should succeed"); - assert_eq!(response.status, "200 OK"); - assert_eq!(response.content_type.essence_str(), "text/html"); match response.body { Body::File(_) => {} Body::Bytes(_) => panic!("expected file-backed response"), } + // ASSERT + assert_eq!(response.status, "200 OK"); + assert_eq!(response.content_type.essence_str(), "text/html"); + cleanup(&root); } #[tokio::test] async fn directory_listing_hides_dotfiles_and_marks_subdirectories() { + // ARRANGE let (root, serve_dir) = create_serving_layout(); let docs = Path::new(&serve_dir).join("docs"); fs::create_dir_all(docs.join("nested")).expect("nested dir should be created"); fs::write(docs.join("file.txt"), "visible").expect("visible file should be written"); fs::write(docs.join(".hidden"), "hidden").expect("hidden file should be written"); - let response = serve_file(&"/docs/".to_string(), &serve_dir) - .await - .expect("directory request should succeed"); + // ACT + let response = serve_file( + &"/docs/", + &serve_dir, + &crate::config::RouterPreset::Static, + ) + .await + .expect("directory request should succeed"); let body = match response.body { Body::Bytes(bytes) => String::from_utf8(bytes).expect("listing should be utf-8"), Body::File(_) => panic!("expected in-memory directory listing"), }; + // ASSERT assert!(body.contains("file.txt"), "body was: {body}"); assert!(body.contains("nested/"), "body was: {body}"); assert!(!body.contains(".hidden"), "body was: {body}"); cleanup(&root); } + + // SPA test + + #[tokio::test] + async fn serves_index_when_spa() { + // ARRANGE + let (root, serve_dir) = create_serving_layout(); + fs::write( + Path::new(&serve_dir).join("index.html"), + "

    Hey there!

    ", + ) + .expect("index file should be written"); + + // ACT + let response = serve_file( + &"/docs/getting-started", + &serve_dir, + &crate::config::RouterPreset::Spa, + ) + .await + .expect("directory request should succeed"); + + match response.body { + Body::File(_) => {} + Body::Bytes(_) => panic!("expected file-backed response"), + } + + // ASSERT + assert_eq!(response.status, "200 OK"); + assert_eq!(response.content_type.essence_str(), "text/html"); + + cleanup(&root); + } + + #[tokio::test] + async fn not_found_when_no_index_but_spa() { + // ARRANGE + let (root, serve_dir) = create_serving_layout(); + + // ACT + let response = serve_file( + &"/docs/getting-started", + &serve_dir, + &crate::config::RouterPreset::Spa, + ) + .await + .expect("directory request should succeed"); + + match response.body { + Body::Bytes(_) => {} + Body::File(_) => panic!("expected byte-backed response"), + } + + // ASSERT + assert_eq!(response.status, "404 Not Found"); + + cleanup(&root); + } + + #[tokio::test] + async fn serves_index_when_dir_exists() { + // ARRANGE + let (root, serve_dir) = create_serving_layout(); + fs::write( + Path::new(&serve_dir).join("index.html"), + "

    Hey there!

    ", + ) + .expect("index file should be written"); + fs::create_dir_all(Path::new(&serve_dir).join("docs")).expect("docs dir should be created"); + + // ACT + let response = serve_file( + &"/docs", + &serve_dir, + &crate::config::RouterPreset::Spa, + ) + .await + .expect("directory request should succeed"); + + match response.body { + Body::File(_) => {} + Body::Bytes(_) => panic!("expected file-backed response"), + } + + // ASSERT + assert_eq!(response.status, "200 OK"); + assert_eq!(response.content_type.essence_str(), "text/html"); + + cleanup(&root); + } + + #[tokio::test] + async fn serves_actual_file_when_spa() { + // ARRANGE + let (root, serve_dir) = create_serving_layout(); + fs::create_dir_all(Path::new(&serve_dir).join("docs")).expect("docs dir should be created"); + fs::write( + Path::new(&serve_dir).join("docs").join("doc.js"), + "alert('Ferrox is faster than Leclerc!')", + ) + .expect("text file should be written"); + + // ACT + let response = serve_file( + &"/docs/doc.js", + &serve_dir, + &crate::config::RouterPreset::Spa, + ) + .await + .expect("directory request should succeed"); + + match response.body { + Body::File(_) => {} + Body::Bytes(_) => panic!("expected file-backed response"), + } + + // ASSERT + assert_eq!(response.status, "200 OK"); + assert_eq!(response.content_type.essence_str(), "text/javascript"); + + cleanup(&root); + } } diff --git a/src/server.rs b/src/server.rs index 811aab5..4fc82ff 100644 --- a/src/server.rs +++ b/src/server.rs @@ -5,6 +5,7 @@ use std::time::Duration; use std::vec; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; +use urlencoding::decode; use crate::config::Config; use crate::handlers::static_files::serve_file; @@ -282,7 +283,23 @@ where } }; - let mut response: Response = match serve_file(&request.path, &config.paths.serve_dir).await { + let decoded_path = match decode(&request.path) { + Ok(p) => p.into_owned(), + Err(_) => { + let res = Response::error("400", "Bad Request"); + + res.write_headers(&mut stream, &config).await?; + return Ok(()); + } + }; + + let mut response: Response = match serve_file( + &decoded_path, + &config.paths.serve_dir, + &config.server.router, + ) + .await + { Ok(r) => r, Err(e) => { logger::error_log( @@ -373,7 +390,10 @@ where full_data.extend_from_slice(&temp_buffer[..bytes_read]); - if full_data[check_start..].windows(4).any(|window| window == b"\r\n\r\n") { + if full_data[check_start..] + .windows(4) + .any(|window| window == b"\r\n\r\n") + { break; } @@ -388,4 +408,4 @@ where } Ok(full_data) -} \ No newline at end of file +}