diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..7f36bcc9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changes + +## v0.0.10 + +### Added + +* [Etag Support](https://github.com/EstebanBorai/http-server/commit/32dc9a2e6b32bb18906ef06d45c155731d91519e) + +## v0.0.9 + +### Added + +* [CLI](https://github.com/EstebanBorai/http-server/commit/f7ea5b278e8b46c322d82c324c7f263ecae3914b) +* [Config Shape](https://github.com/EstebanBorai/http-server/commit/49e4cd73ddc4401bf684ad70875b075bb35caf5a) +* [HTTP server with File Explorer](https://github.com/EstebanBorai/http-server/commit/7aa59495dab51c52a0a79b75c185b3b74dda5be1) diff --git a/Cargo.lock b/Cargo.lock index 4dc98492..364a78b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -89,7 +89,7 @@ dependencies = [ [[package]] name = "http-server" -version = "0.0.9" +version = "0.0.10" dependencies = [ "ascii", "clap", diff --git a/Cargo.toml b/Cargo.toml index 514de23e..3dcf3c72 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "http-server" -version = "0.0.9" +version = "0.0.10" authors = ["Esteban Borai "] edition = "2018" license = "MIT" diff --git a/README.md b/README.md index 6b47f6da..4e0463de 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,6 @@
[![Crates.io](https://img.shields.io/crates/v/http-server.svg)](https://crates.io/crates/http-server) - [![Documentation](https://docs.rs/http-server/badge.svg)](https://docs.rs/http-server) ![Build](https://github.com/EstebanBorai/http-server/workflows/build/badge.svg) ![Lint](https://github.com/EstebanBorai/http-server/workflows/clippy/fmt/badge.svg) ![Tests](https://github.com/EstebanBorai/http-server/workflows/tests/badge.svg) diff --git a/src/config.rs b/src/config.rs index 63811990..d2e26630 100644 --- a/src/config.rs +++ b/src/config.rs @@ -28,8 +28,6 @@ impl From> for Config { }; let silent = matches.is_present(SILENT.1); - - // at this point the values provided to the config are validated by the CLI Self { address, port, diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 00000000..b5354a65 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,14 @@ +#[derive(Debug)] +pub struct HttpError { + pub status_code: u16, + pub message: String, +} + +impl HttpError { + pub fn new(status_code: u16, message: &str) -> Self { + Self { + status_code, + message: message.to_string(), + } + } +} diff --git a/src/handler/file_explorer/handler.rs b/src/handler/file_explorer/handler.rs new file mode 100644 index 00000000..cee464c2 --- /dev/null +++ b/src/handler/file_explorer/handler.rs @@ -0,0 +1,69 @@ +use crate::file_explorer::FileExplorer; +use crate::handler::build_html; +use crate::server::{ETag, HttpHeader}; +use ascii::AsciiString; +use std::fs::{read_dir, File}; +use tiny_http::{Header, Request, Response, ResponseBox}; + +/// Creates a path by merging the URL params and the `root_dir`. +/// Then reads the file system entries in the resulting path. +/// +/// If the resulting path is a directory, enumerates every file system entry +/// and responds with an HTML with the entry listing. +/// +/// Otherwise retrieves the provided file. +pub fn file_explorer(request: Request, file_explorer: &FileExplorer) -> (Request, ResponseBox) { + match file_explorer.read(request.url().as_ref()) { + Ok(entry) => { + if entry.is_file { + let mime_type = mime_guess::from_path(&entry.path) + .first_or_octet_stream() + .to_string(); + let mime_type = AsciiString::from_ascii(mime_type.as_bytes()).unwrap(); + let file = File::open(entry.path).unwrap(); + let entity_tag = ETag::from_metadata(&file.metadata().unwrap()).unwrap(); + let entity_tag: HttpHeader = entity_tag.into(); + let entity_tag: Header = entity_tag.into(); + + ( + request, + Response::from_file(file) + .with_header(tiny_http::Header { + field: "Content-Type".parse().unwrap(), + value: mime_type, + }) + .with_header(entity_tag) + .boxed(), + ) + } else { + let dirpath = entry.path.clone(); + let dirpath = dirpath.to_str().unwrap(); + let dirname = &dirpath[file_explorer.root_dir_string.len()..]; + + let entries = read_dir(entry.path).unwrap(); + + let html = build_html( + dirname, + &file_explorer.root_dir_string, + &file_explorer, + entries, + ); + let mime_type_value: AsciiString = AsciiString::from_ascii("text/html").unwrap(); + let response = Response::from_string(html) + .with_status_code(200) + .with_header(tiny_http::Header { + field: "Content-Type".parse().unwrap(), + value: mime_type_value, + }); + + (request, response.boxed()) + } + } + Err(_) => ( + request, + Response::from_string("Not Found") + .with_status_code(404) + .boxed(), + ), + } +} diff --git a/src/handler/static_fs/html/mod.rs b/src/handler/file_explorer/html/mod.rs similarity index 100% rename from src/handler/static_fs/html/mod.rs rename to src/handler/file_explorer/html/mod.rs diff --git a/src/handler/static_fs/html/static/index.html b/src/handler/file_explorer/html/static/index.html similarity index 100% rename from src/handler/static_fs/html/static/index.html rename to src/handler/file_explorer/html/static/index.html diff --git a/src/handler/static_fs/html/static/style.css b/src/handler/file_explorer/html/static/style.css similarity index 100% rename from src/handler/static_fs/html/static/style.css rename to src/handler/file_explorer/html/static/style.css diff --git a/src/handler/static_fs/mod.rs b/src/handler/file_explorer/mod.rs similarity index 100% rename from src/handler/static_fs/mod.rs rename to src/handler/file_explorer/mod.rs diff --git a/src/handler/main_handler.rs b/src/handler/main_handler.rs new file mode 100644 index 00000000..31f84058 --- /dev/null +++ b/src/handler/main_handler.rs @@ -0,0 +1,17 @@ +use crate::file_explorer::FileExplorer; +use crate::handler::file_explorer; +use tiny_http::{Request, Response, ResponseBox}; + +/// The main handler acts like a router for every supported method. +/// If a method is not supported then responds with `Method Not Allowed 405` +pub fn main_handler(req: Request, fexplorer: &FileExplorer) -> (Request, ResponseBox) { + match req.method().to_string().to_lowercase().as_str() { + "get" => file_explorer(req, fexplorer), + _ => ( + req, + Response::from_string("Method Not Allowed") + .with_status_code(405) + .boxed(), + ), + } +} diff --git a/src/handler/mod.rs b/src/handler/mod.rs index 29323f27..0132536c 100644 --- a/src/handler/mod.rs +++ b/src/handler/mod.rs @@ -1,3 +1,5 @@ -mod static_fs; +mod file_explorer; +mod main_handler; -pub use static_fs::*; +pub use file_explorer::*; +pub use main_handler::*; diff --git a/src/handler/static_fs/handler.rs b/src/handler/static_fs/handler.rs deleted file mode 100644 index bff30411..00000000 --- a/src/handler/static_fs/handler.rs +++ /dev/null @@ -1,60 +0,0 @@ -use crate::file_explorer::FileExplorer; -use crate::handler::build_html; -use ascii::AsciiString; -use std::fs::{read_dir, File}; -use tiny_http::{Request, Response}; - -/// Creates a path by merging the URL params and the `root_dir`. -/// Then reads the file system entries in the resulting path. -/// -/// If the resulting path is a directory, enumerates every file system entry -/// and responds with an HTML with the entry listing. -/// -/// Otherwise retrieves the provided file. -pub fn static_fs(request: Request, file_explorer: &FileExplorer) { - match file_explorer.read(request.url().as_ref()) { - Ok(entry) => { - if entry.is_file { - let mime_type = mime_guess::from_path(&entry.path) - .first_or_octet_stream() - .to_string(); - let mime_type = AsciiString::from_ascii(mime_type.as_bytes()).unwrap(); - let file = File::open(entry.path).unwrap(); - let response = Response::from_file(file).with_header(tiny_http::Header { - field: "Content-Type".parse().unwrap(), - value: mime_type, - }); - - request.respond(response).unwrap(); - } else { - let dirpath = entry.path.clone(); - let dirpath = dirpath.to_str().unwrap(); - let dirname = &dirpath[file_explorer.root_dir_string.len()..]; - - let entries = read_dir(entry.path).unwrap(); - - let html = build_html( - dirname, - &file_explorer.root_dir_string, - &file_explorer, - entries, - ); - let mime_type_value: AsciiString = AsciiString::from_ascii("text/html").unwrap(); - - request - .respond( - Response::from_string(html) - .with_status_code(200) - .with_header(tiny_http::Header { - field: "Content-Type".parse().unwrap(), - value: mime_type_value, - }), - ) - .unwrap() - } - } - Err(_) => request - .respond(Response::from_string("Not Found").with_status_code(404)) - .unwrap(), - } -} diff --git a/src/main.rs b/src/main.rs index c0c5cc35..894856f3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,92 +1,6 @@ -//!
-//!
-//! -//!
-//!

http-server

-//!

Command-line HTTP Server

-//!
-//! -//!
-//! -//! [![Crates.io](https://img.shields.io/crates/v/http-server.svg)](https://crates.io/crates/http-server) -//! [![Documentation](https://docs.rs/http-server/badge.svg)](https://docs.rs/http-server) -//! ![Build](https://github.com/EstebanBorai/http-server/workflows/build/badge.svg) -//! ![Lint](https://github.com/EstebanBorai/http-server/workflows/clippy/fmt/badge.svg) -//! ![Tests](https://github.com/EstebanBorai/http-server/workflows/tests/badge.svg) -//! -//!
-//! -//! ## Index -//! -//! - [Installation](#installation) -//! - [Usage](#usage) -//! - [Flags](#flags) -//! - [Options](#options) -//! - [Contributing](#contributing) -//! - [License](#license) -//! - [Contribution](#contribution) -//! -//! ## Installation -//! -//! ```bash -//! cargo install http-server -//! ``` -//! -//! Check for the installation to be successful. -//! -//! ```bash -//! http-server --help -//! ``` -//! -//! ## Usage -//! -//! ``` -//! http-server [FLAGS] [OPTIONS] [root_dir] -//! ``` -//! -//! ### Flags -//! -//! Flags are provided without any values. For example: -//! -//! ``` -//! http-server --help -//! ``` -//! -//! Name | Short | Long | Description -//! --- | --- | --- | --- -//! Help | `h` | `help` | Prints help information -//! Version | `V` | `version` | Prints version information -//! -//! ### Options -//! -//! Options are provided with a value and also have default values. For example: -//! -//! ``` -//! http-server --address 127.0.0.1 -//! ``` -//! -//! Name | Short | Long | Description | Default Value -//! --- | --- | --- | --- | --- -//! Address | `a` | `address` | Address to bind the server | `0.0.0.0` -//! Port | `p` | `port` | Port to bind the server | `7878` -//! -//! ## Contributing -//! -//! Every contribution to this project is welcome. Feel free to open a pull request, -//! an issue or just by starting this project. -//! -//! ## License -//! -//! Licensed under the MIT License -//! -//! ### Contribution -//! -//! Unless you explicitly state otherwise, any contribution intentionally submitted for -//! inclusion in http-server by you, shall be dual licensed as above, without any additional -//! terms or conditions. -//! mod cli; mod config; +mod error; mod file_explorer; mod handler; mod server; diff --git a/src/server/header/etag.rs b/src/server/header/etag.rs new file mode 100644 index 00000000..09c326e5 --- /dev/null +++ b/src/server/header/etag.rs @@ -0,0 +1,102 @@ +use crate::server::header::HttpHeader; +use std::fs::Metadata; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Exclamation point (!) 7-bit character representation +const EXCLAMATION_CHARACTER: u8 = b'\x21'; + +/// Hastag (#) 7-bit character representation +const HASHTAG_SYMBOL: u8 = b'\x23'; + +/// Closing Curly Brace (}) 7-bit character representation +const CLOSING_CURLY_BRACE: u8 = b'\x7e'; + +/// Padding character 7-bit representation +const PADDING_CHARACTER: u8 = b'\x80'; + +/// The ETag response-header field provides the current value of the entity tag +/// for the requested variant. The headers used with entity tags are described in +/// sections 14.24, 14.26 and 14.44. The entity tag MAY be used for comparison +/// with other entities from the same resource (see section 13.3.3). +/// +/// # Anatomy +/// +/// The value of an ETag must follow the ABNF form: +/// +/// ``` +/// entity-tag = [ weak ] opaque-tag +/// +/// weak = "W/" +/// opaque-tag = quoted-string +/// ``` +/// +/// Reference: [US-ASCII Coded Character Set](https://www.w3.org/Protocols/rfc2616/rfc2616-sec2.html#sec2.2) +/// +/// The anatomy of an ETag may vary based on its type. +/// An ETag could be either _weak_ or _strong_. +/// +/// A _weak_ ETag is denoted by the prefix `W/`: +/// +/// ``` +/// ETag: W/"557b5a3d3eca3aa493d35e47e94c94a2" +/// ``` +/// +/// In the other hand a _strong_ Etag have no prefix. +/// +/// # References +/// +/// * [W3 RFC2616 - Sec 2](https://www.w3.org/Protocols/rfc2616/rfc2616-sec2.html#sec2.2) +/// * [W3 RFC2616 - Sec 14](https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.19) +pub struct ETag { + value: String, +} + +impl ETag { + /// Creates an **strong** entity tag from the + /// provided metadata + pub fn from_metadata(meta: &Metadata) -> Result { + let time_created = meta + .created() + .unwrap_or(SystemTime::now()) + .duration_since(UNIX_EPOCH) + .unwrap(); + + let time_modified = meta + .modified() + .unwrap_or(SystemTime::now()) + .duration_since(UNIX_EPOCH) + .unwrap(); + + let tag = format!( + "{:x}{:x}{:x}", + time_created.subsec_millis(), + time_modified.subsec_millis(), + meta.len() + ); + + if ETag::is_valid_tag(&tag) { + return Ok(Self { value: tag }); + } + + Err(()) + } + + /// Ensures that every character of the tag is a valid character. + /// + /// Every character (in its 7-bit representation) from the Unicode + /// character U+0023 to the U+007E is valid for an ETag hash. + /// Also the exclamation character which is represented by the Unicode + /// code U+0021 and the padding character U+0080. + fn is_valid_tag(slice: &str) -> bool { + slice.bytes().all(|c| { + c == EXCLAMATION_CHARACTER + || (c >= HASHTAG_SYMBOL && c <= CLOSING_CURLY_BRACE) | (c >= PADDING_CHARACTER) + }) + } +} + +impl Into for ETag { + fn into(self) -> HttpHeader { + HttpHeader(String::from("ETag"), self.value) + } +} diff --git a/src/server/header/http_header.rs b/src/server/header/http_header.rs new file mode 100644 index 00000000..516f66bf --- /dev/null +++ b/src/server/header/http_header.rs @@ -0,0 +1,19 @@ +use ascii::AsciiString; +use tiny_http::Header; + +pub struct HttpHeader(pub String, pub String); + +impl From
for HttpHeader { + fn from(header: Header) -> Self { + Self(header.field.to_string(), header.value.to_string()) + } +} + +impl Into
for HttpHeader { + fn into(self) -> Header { + Header { + field: self.0.parse().unwrap(), + value: AsciiString::from_ascii(self.1).unwrap(), + } + } +} diff --git a/src/server/header/mod.rs b/src/server/header/mod.rs new file mode 100644 index 00000000..d4bf72d4 --- /dev/null +++ b/src/server/header/mod.rs @@ -0,0 +1,5 @@ +mod etag; +mod http_header; + +pub use etag::*; +pub use http_header::*; diff --git a/src/server.rs b/src/server/http.rs similarity index 72% rename from src/server.rs rename to src/server/http.rs index 3c9bac6a..ce0b0df9 100644 --- a/src/server.rs +++ b/src/server/http.rs @@ -1,9 +1,10 @@ use crate::config::Config; use crate::file_explorer::FileExplorer; -use crate::handler::static_fs; +use crate::handler::main_handler; use std::net::SocketAddr; -use tiny_http::{Response, Server, ServerConfig}; +use tiny_http::{Server, ServerConfig}; +/// HTTP Server instance pub struct HttpServer { pub server: tiny_http::Server, pub address: SocketAddr, @@ -31,6 +32,7 @@ impl From for HttpServer { } impl HttpServer { + /// Binds the server to the specified address and listen for incomming requests pub fn serve(&self) { if self.must_log { println!( @@ -40,12 +42,8 @@ impl HttpServer { } for request in self.server.incoming_requests() { - match request.method().as_str().to_lowercase().as_str() { - "get" => static_fs(request, &self.file_explorer), - _ => request - .respond(Response::from_string("Method Not Allowed").with_status_code(405)) - .unwrap(), - } + let (req, res) = main_handler(request, &self.file_explorer); + req.respond(res).unwrap(); } } } diff --git a/src/server/mod.rs b/src/server/mod.rs new file mode 100644 index 00000000..8290334d --- /dev/null +++ b/src/server/mod.rs @@ -0,0 +1,5 @@ +mod header; +mod http; + +pub use header::*; +pub use http::*;