From dbaa2feb488305a308520d1fbdf670c86dfaa39f Mon Sep 17 00:00:00 2001 From: Kerdonov Date: Sun, 9 Nov 2025 01:06:25 +0200 Subject: [PATCH] first commit --- .gitignore | 7 + Cargo.toml | 8 ++ cracked_md/.gitignore | 1 + cracked_md/Cargo.toml | 7 + cracked_md/src/ast.rs | 194 +++++++++++++++++++++++++ cracked_md/src/lib.rs | 76 ++++++++++ cracked_md/src/parser.rs | 147 +++++++++++++++++++ cracked_md/src/parser/block.rs | 45 ++++++ cracked_md/src/parser/inline.rs | 61 ++++++++ cracked_md/src/to_html.rs | 4 + flake.nix | 31 ++++ fstools/.gitignore | 1 + fstools/Cargo.toml | 6 + fstools/src/lib.rs | 45 ++++++ package.nix | 20 +++ rust-toolchain.toml | 9 ++ stdsrv/.gitignore | 1 + stdsrv/Cargo.toml | 9 ++ stdsrv/src/args.rs | 58 ++++++++ stdsrv/src/error.rs | 53 +++++++ stdsrv/src/fileserver.rs | 74 ++++++++++ stdsrv/src/http_header.rs | 37 +++++ stdsrv/src/http_stream.rs | 43 ++++++ stdsrv/src/logger.rs | 39 +++++ stdsrv/src/main.rs | 88 ++++++++++++ stdsrv/src/request.rs | 243 ++++++++++++++++++++++++++++++++ stdsrv/src/responder.rs | 55 ++++++++ stdsrv/src/response.rs | 189 +++++++++++++++++++++++++ 28 files changed, 1551 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 cracked_md/.gitignore create mode 100644 cracked_md/Cargo.toml create mode 100644 cracked_md/src/ast.rs create mode 100644 cracked_md/src/lib.rs create mode 100644 cracked_md/src/parser.rs create mode 100644 cracked_md/src/parser/block.rs create mode 100644 cracked_md/src/parser/inline.rs create mode 100644 cracked_md/src/to_html.rs create mode 100644 flake.nix create mode 100644 fstools/.gitignore create mode 100644 fstools/Cargo.toml create mode 100644 fstools/src/lib.rs create mode 100644 package.nix create mode 100644 rust-toolchain.toml create mode 100644 stdsrv/.gitignore create mode 100644 stdsrv/Cargo.toml create mode 100644 stdsrv/src/args.rs create mode 100644 stdsrv/src/error.rs create mode 100644 stdsrv/src/fileserver.rs create mode 100644 stdsrv/src/http_header.rs create mode 100644 stdsrv/src/http_stream.rs create mode 100644 stdsrv/src/logger.rs create mode 100644 stdsrv/src/main.rs create mode 100644 stdsrv/src/request.rs create mode 100644 stdsrv/src/responder.rs create mode 100644 stdsrv/src/response.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..46a786a --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +html/ +web/ +target/ + +result +Cargo.lock +flake.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..6ec0d5e --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,8 @@ +[workspace] +members = [ + "stdsrv", + "cracked_md", + "fstools", +] + +resolver = "3" diff --git a/cracked_md/.gitignore b/cracked_md/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/cracked_md/.gitignore @@ -0,0 +1 @@ +/target diff --git a/cracked_md/Cargo.toml b/cracked_md/Cargo.toml new file mode 100644 index 0000000..3e63602 --- /dev/null +++ b/cracked_md/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "cracked_md" +version = "0.1.0" +edition = "2024" + +[dependencies] +fstools = { path = "../fstools" } diff --git a/cracked_md/src/ast.rs b/cracked_md/src/ast.rs new file mode 100644 index 0000000..b8d34a3 --- /dev/null +++ b/cracked_md/src/ast.rs @@ -0,0 +1,194 @@ +use crate::to_html::ToHtml; + +#[derive(Debug, Clone, PartialEq)] +pub struct Document { + pub blocks: Vec, +} + +impl ToHtml for Document { + fn to_html(self) -> String { + format!( + "{}", + self.blocks.to_html() + ) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Block { + Paragraph(Vec), + Heading { + level: u8, + content: Vec, + }, + Code { + language: Option, + content: String, + }, + List(Vec), + Quote(Vec), +} + +impl ToHtml for Block { + fn to_html(self) -> String { + match self { + Self::Paragraph(content) => format!("

{}

", content.to_html()), + Self::Heading { level, content } => { + format!("{}", level, content.to_html(), level) + } + Self::Code { + language: _, + content, + } => { + format!("
{}
", content) + } + _ => todo!(), + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ListItem { + pub blocks: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Inline { + Text(String), + Bold(Vec), + Italic(Vec), + Code(String), + Link { text: Vec, href: String }, +} + +impl ToHtml for Inline { + fn to_html(self) -> String { + match self { + Self::Text(s) => s, + Self::Bold(content) => format!("{}", content.to_html()), + Self::Italic(content) => format!("{}", content.to_html()), + Self::Code(s) => format!("{}", s), + Self::Link { text, href } => format!("{}", href, text.to_html()), + } + } +} + +impl ToHtml for Vec +where + T: ToHtml, +{ + fn to_html(self) -> String { + let mut rendered = String::new(); + for i in self { + rendered.push_str(&i.to_html()); + } + rendered + } +} + +// -------------------- +// TESTS +// -------------------- + +#[cfg(test)] +mod unit_test { + use super::*; + + #[test] + fn single_header() { + let ast = Document { + blocks: vec![Block::Heading { + level: 1, + content: vec![Inline::Text("Heading 1".to_string())], + }], + }; + + let html = ast.to_html(); + + assert_eq!( + html, + "

Heading 1

" + ); + } + + #[test] + fn inline_bold_header() { + let ast = Document { + blocks: vec![Block::Heading { + level: 1, + content: vec![ + Inline::Bold(vec![Inline::Text("Bold".to_string())]), + Inline::Text(" heading 1".to_string()), + ], + }], + }; + + let html = ast.to_html(); + + assert_eq!( + html, + "

Bold heading 1

" + ); + } + + #[test] + fn headings_and_paragraph_nested_code() { + let ast = Document { + blocks: vec![ + Block::Heading { + level: 1, + content: vec![ + Inline::Bold(vec![Inline::Text("Bold".to_string())]), + Inline::Text(" heading 1".to_string()), + ], + }, + Block::Heading { + level: 2, + content: vec![Inline::Text("Heading 2".to_string())], + }, + Block::Paragraph(vec![ + Inline::Text("run ".to_string()), + Inline::Code("sudo rm -rf /".to_string()), + Inline::Text(" on your computer".to_string()), + ]), + ], + }; + + let html = ast.to_html(); + + assert_eq!( + html, + "

Bold heading 1

Heading 2

run sudo rm -rf / on your computer

" + ); + } +} + +#[cfg(test)] +mod convert_md_to_html_test { + use crate::parser::parse; + use crate::to_html::ToHtml; + + #[test] + fn single_header() { + let md = "# Header 1"; + + let html = parse(md).to_html(); + + assert_eq!( + html, + "

Header 1

" + ); + } + + #[test] + fn nested_bold_headers_and_nested_code_paragraph() { + let md = "# *Bold* header 1\n## Header 2\nrun `sudo rm -rf /` on your computer"; + + let html = parse(md).to_html(); + + assert_eq!( + html, + "

Bold header 1

Header 2

run sudo rm -rf / on your computer

" + ); + } +} diff --git a/cracked_md/src/lib.rs b/cracked_md/src/lib.rs new file mode 100644 index 0000000..7a5a4c0 --- /dev/null +++ b/cracked_md/src/lib.rs @@ -0,0 +1,76 @@ +#![deny(dead_code, unused_imports)] + +use fstools::crawl_fs; +use parser::parse; +use std::{ + fmt::Display, + fs::{self, File}, + io::Write, + path::PathBuf, +}; +use to_html::ToHtml; + +pub mod ast; +pub mod parser; +pub mod to_html; + +#[derive(Debug)] +pub enum Error { + OutDirIsNotEmpty, + OutDirFileDeleteNotAllowed, + OutDirDirectoryInPlaceOfFile, + FileRead, + DirRead, + FileWrite, + FileCreate, + DirCreate, +} + +impl Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", &self) + } +} + +impl std::error::Error for Error {} + +type Result = std::result::Result; + +pub fn generate(indir: &PathBuf, outdir: &PathBuf, force: bool) -> Result<()> { + let files = crawl_fs(indir); + + for path in files { + let fullpath = indir.as_path().join(&path); + + // read and parse md file + let content = fs::read_to_string(&fullpath).map_err(|_e| Error::FileRead)?; + let html = parse(&content).to_html(); + + // write html data to file + let mut newpath = outdir.to_owned(); + newpath.push(path); + newpath.set_extension("html"); + + // check if path exists + if newpath.exists() { + // remove if is file and if force, otherwise error + if newpath.is_file() && force { + fs::remove_file(&newpath).map_err(|_e| Error::OutDirFileDeleteNotAllowed)?; + } else { + Err(Error::OutDirDirectoryInPlaceOfFile)?; + } + } + + //println!("About to write file '{}'", newpath.display()); + + let parent = newpath.parent().ok_or(Error::DirCreate)?; + fs::create_dir_all(parent).map_err(|_e| Error::DirCreate)?; + let mut newfile = File::create_new(newpath).map_err(|_e| Error::FileCreate)?; + + newfile + .write(html.as_bytes()) + .map_err(|_e| Error::FileWrite)?; + } + + Ok(()) +} diff --git a/cracked_md/src/parser.rs b/cracked_md/src/parser.rs new file mode 100644 index 0000000..75e905f --- /dev/null +++ b/cracked_md/src/parser.rs @@ -0,0 +1,147 @@ +mod block; +mod inline; + +use block::parse_blocks; + +use crate::ast::Document; + +pub fn parse(s: &str) -> Document { + Document { + blocks: parse_blocks(s), + } +} + +#[cfg(test)] +mod test { + use crate::ast::*; + use crate::parser::parse; + + #[test] + fn only_paragraph() { + let md = "testing paragraph"; + + let doc = parse(md); + assert_eq!( + doc, + Document { + blocks: vec![Block::Paragraph(vec![Inline::Text( + "testing paragraph".to_string() + )])] + } + ); + } + + #[test] + fn different_headers() { + let md = "# Header 1\n## Header 2"; + + let doc = parse(md); + + assert_eq!( + doc, + Document { + blocks: vec![ + Block::Heading { + level: 1, + content: vec![Inline::Text("Header 1".to_string())] + }, + Block::Heading { + level: 2, + content: vec![Inline::Text("Header 2".to_string())] + }, + ] + } + ); + } + + #[test] + fn inline_bold_and_italics() { + let md = "some *bold* and _italic_ text"; + + let doc = parse(md); + + assert_eq!( + doc, + Document { + blocks: vec![Block::Paragraph(vec![ + Inline::Text("some ".to_string()), + Inline::Bold(vec![Inline::Text("bold".to_string())]), + Inline::Text(" and ".to_string()), + Inline::Italic(vec![Inline::Text("italic".to_string())]), + Inline::Text(" text".to_string()), + ])] + } + ); + } + + #[test] + fn inline_code() { + let md = "run command `sudo rm -rf /`"; + + let doc = parse(md); + + assert_eq!( + doc, + Document { + blocks: vec![Block::Paragraph(vec![ + Inline::Text("run command ".to_string()), + Inline::Code("sudo rm -rf /".to_string()), + ])] + } + ); + } + + #[test] + fn bold_header() { + let md = "# Header is *bold*"; + + let doc = parse(md); + + assert_eq!( + doc, + Document { + blocks: vec![Block::Heading { + level: 1, + content: vec![ + Inline::Text("Header is ".to_string()), + Inline::Bold(vec![Inline::Text("bold".to_string())]) + ] + }] + } + ); + } + + #[test] + fn anonymous_code_block() { + let md = "```\necho hello\n```"; + + let doc = parse(md); + + assert_eq!( + doc, + Document { + blocks: vec![Block::Code { + language: None, + content: "echo hello\n".to_string() + }] + } + ); + } + + #[test] + fn rust_code_block() { + let md = "```rust\nfn main() {\n\tprintln!(\"Hello world!\");\n}\n```"; + + let doc = parse(md); + + assert_eq!( + doc, + Document { + blocks: vec![Block::Code { + language: Some("rust".to_string()), + content: "fn main() {\n\tprintln!(\"Hello world!\");\n}\n".to_string() + }] + } + ); + } +} diff --git a/cracked_md/src/parser/block.rs b/cracked_md/src/parser/block.rs new file mode 100644 index 0000000..1a37aa8 --- /dev/null +++ b/cracked_md/src/parser/block.rs @@ -0,0 +1,45 @@ +use crate::ast::Block; + +use super::inline::parse_inlines; + +pub fn parse_blocks(input: &str) -> Vec { + let mut blocks = Vec::new(); + + let mut lines = input.lines().peekable(); + + while let Some(line) = lines.next() { + if line.starts_with("#") { + let level = line.chars().take_while(|&c| c == '#').count() as u8; + let text = line[level as usize..].trim(); + blocks.push(Block::Heading { + level, + content: parse_inlines(text), + }); + } else if let Some(quote_body) = line.strip_prefix(">") { + let quote_blocks = parse_blocks(quote_body); + blocks.push(Block::Quote(quote_blocks)); + } else if line.starts_with("```") { + let lang_line = line.strip_prefix("```").unwrap().to_string(); + let lang = if lang_line.is_empty() { + None + } else { + Some(lang_line) + }; + let mut code = String::new(); + while lines.peek().is_some() && !lines.peek().unwrap().starts_with("```") { + code.push_str(&format!("{}\n", lines.next().unwrap())); + } + lines.next(); + blocks.push(Block::Code { + language: lang, + content: code, + }); + } else if line.trim().is_empty() { + continue; + } else { + blocks.push(Block::Paragraph(parse_inlines(line))); + } + } + + blocks +} diff --git a/cracked_md/src/parser/inline.rs b/cracked_md/src/parser/inline.rs new file mode 100644 index 0000000..f2cdd3b --- /dev/null +++ b/cracked_md/src/parser/inline.rs @@ -0,0 +1,61 @@ +use crate::ast::Inline; + +pub fn parse_inlines(input: &str) -> Vec { + let mut inlines = Vec::new(); + let mut chars = input.chars().peekable(); + + while let Some(c) = chars.next() { + match c { + '*' => { + let inner = collect_until(&mut chars, '*'); + inlines.push(Inline::Bold(parse_inlines(&inner))); + } + '_' => { + let inner = collect_until(&mut chars, '_'); + inlines.push(Inline::Italic(parse_inlines(&inner))); + } + '`' => { + let code = collect_until(&mut chars, '`'); + inlines.push(Inline::Code(code)); + } + '[' => { + let text = collect_until(&mut chars, ']'); + if chars.next() == Some('(') { + let href = collect_until(&mut chars, ')'); + inlines.push(Inline::Link { + text: parse_inlines(&text), + href, + }); + } + } + _ => { + let mut text = String::new(); + text.push(c); + while let Some(&nc) = chars.peek() { + if matches!(nc, '*' | '_' | '`' | '[') { + break; + } + text.push(chars.next().unwrap()); + } + inlines.push(Inline::Text(text)); + } + } + } + + inlines +} + +fn collect_until>( + chars: &mut std::iter::Peekable, + end: char, +) -> String { + let mut s = String::new(); + while let Some(&c) = chars.peek() { + if c == end { + chars.next(); + break; + } + s.push(chars.next().unwrap()); + } + s +} diff --git a/cracked_md/src/to_html.rs b/cracked_md/src/to_html.rs new file mode 100644 index 0000000..b9f3aae --- /dev/null +++ b/cracked_md/src/to_html.rs @@ -0,0 +1,4 @@ +pub trait ToHtml { + fn to_html(self) -> String; +} + diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..63ace70 --- /dev/null +++ b/flake.nix @@ -0,0 +1,31 @@ +{ + description = "stdsrv Flake file"; + + inputs = { + nixpkgs.url = "nixpkgs/nixos-unstable"; + flake-utlis.url = "github:numtide/flake-utils"; + }; + + outputs = { + self, + nixpkgs, + flake-utlis, + }: + flake-utlis.lib.eachDefaultSystem ( + system: let + pkgs = import nixpkgs {inherit system;}; + stdsrv = import ./package.nix {inherit pkgs;}; + in { + packages.default = stdsrv; + devShells.default = pkgs.mkShell { + packages = [ + pkgs.rustup + pkgs.bacon + pkgs.cargo-nextest + pkgs.cargo-expand + pkgs.cargo-watch + ]; + }; + } + ); +} diff --git a/fstools/.gitignore b/fstools/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/fstools/.gitignore @@ -0,0 +1 @@ +/target diff --git a/fstools/Cargo.toml b/fstools/Cargo.toml new file mode 100644 index 0000000..a795e32 --- /dev/null +++ b/fstools/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "fstools" +version = "0.1.0" +edition = "2024" + +[dependencies] diff --git a/fstools/src/lib.rs b/fstools/src/lib.rs new file mode 100644 index 0000000..8b1023d --- /dev/null +++ b/fstools/src/lib.rs @@ -0,0 +1,45 @@ +use std::collections::HashSet; +use std::fs; +use std::path::PathBuf; + +/// Recursively crawl through the directory given and aggregate all file handles to a HashMap with +/// their respective (relative) paths as keys. +pub fn crawl_fs(root: &PathBuf) -> HashSet { + crawl_fs_rec(root, root) +} + +/// Helper function: Recursively crawl through the directory given and aggregate all file handles to a HashMap with +/// their respective (relative) paths as keys. +fn crawl_fs_rec(root: &PathBuf, path: &PathBuf) -> HashSet { + let mut subdirs = Vec::with_capacity(100); + let mut files = HashSet::with_capacity(1024); + + match fs::read_dir(path) { + Ok(entries) => { + for entry in entries { + match entry { + Ok(entry) => { + if entry.path().is_dir() { + subdirs.push(entry.path()); + } else { + let path = entry.path(); + if let Ok(path_no_prefix) = path.strip_prefix(root) { + files.insert(path_no_prefix.into()); + } + } + } + Err(_e) => {} + } + } + } + Err(_e) => {} + } + + let others: HashSet = subdirs + .into_iter() + .flat_map(|s| crawl_fs_rec(root, &s)) + .collect(); + + files.extend(others); + files +} diff --git a/package.nix b/package.nix new file mode 100644 index 0000000..9d16e24 --- /dev/null +++ b/package.nix @@ -0,0 +1,20 @@ +{pkgs}: +pkgs.rustPlatform.buildRustPackage { + pname = "stdsrv"; + version = "0.1.0"; + + src = ./.; + + cargoLock.lockFile = ./Cargo.lock; + + cargoBuildFlags = ["-p" "stdsrv"]; + + doCheck = true; + + meta = with pkgs.lib; { + description = "A simple file server that converts your markdown files to HTML before serving them."; + license = licenses.gpl3; + maintainers = [maintainers.scrac]; + platforms = platforms.linux; + }; +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..9dd8a10 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,9 @@ +[toolchain] +channel = "nightly" +targets = [ + "x86_64-unknown-linux-gnu" +] +components = [ + "clippy", + "rustfmt" +] diff --git a/stdsrv/.gitignore b/stdsrv/.gitignore new file mode 100644 index 0000000..eb5a316 --- /dev/null +++ b/stdsrv/.gitignore @@ -0,0 +1 @@ +target diff --git a/stdsrv/Cargo.toml b/stdsrv/Cargo.toml new file mode 100644 index 0000000..a9c39f8 --- /dev/null +++ b/stdsrv/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "stdsrv" +version = "0.1.0" +edition = "2024" + +# local dependencies +[dependencies] +cracked_md = { path = "../cracked_md" } +fstools = { path = "../fstools" } diff --git a/stdsrv/src/args.rs b/stdsrv/src/args.rs new file mode 100644 index 0000000..7481c6f --- /dev/null +++ b/stdsrv/src/args.rs @@ -0,0 +1,58 @@ +use std::path::PathBuf; + +use crate::error::Error; + +use crate::error::ErrorKind; + +pub struct ProgramArgs { + pub outdir: PathBuf, + pub indir: PathBuf, + pub generate: bool, + pub force: bool, + pub addr: String, +} + +impl Default for ProgramArgs { + fn default() -> Self { + Self { + indir: PathBuf::from("./web"), + outdir: PathBuf::from("./html"), + generate: false, + force: false, + addr: "0.0.0.0:8080".to_string(), + } + } +} + +impl TryFrom for ProgramArgs { + type Error = crate::error::Error; + fn try_from(mut value: std::env::Args) -> Result { + let mut a = Self::default(); + let _ = value.next(); // ignore executable path + while let Some(v) = value.next() { + match v.as_str() { + "-i" => { + a.indir = value + .next() + .ok_or(Error::new( + ErrorKind::CommandLineArgsParse, + "Expected input directory after option `-i`", + ))? + .into(); + } + "-a" => { + a.addr = value.next().ok_or(Error::new( + ErrorKind::CommandLineArgsParse, + "Expected listener address after option `-a`", + ))?; + } + "-g" => a.generate = true, + "-f" => a.force = true, + _ => { + a.outdir = v.into(); + } + } + } + Ok(a) + } +} diff --git a/stdsrv/src/error.rs b/stdsrv/src/error.rs new file mode 100644 index 0000000..d47e647 --- /dev/null +++ b/stdsrv/src/error.rs @@ -0,0 +1,53 @@ +//! Custom Error type and Result enum and their most standard trait implementations. + +use std::fmt::{Debug, Display}; + +pub type Result = std::result::Result; + +#[derive(Debug)] +#[allow(dead_code)] +pub enum ErrorKind { + StreamReadFailed, + CommandLineArgsParse, + UnsupportedHttpMethod, + UnsupportedHttpVersion, + RequestParse, + FileNotFound, + DirNotFound, + Io, + TcpBind, + NotImplemented, +} + +#[derive(Debug)] +#[allow(dead_code)] +pub struct Error { + kind: ErrorKind, + msg: String, +} + +impl Error { + pub fn new(kind: ErrorKind, msg: &str) -> Self { + Self { + kind, + msg: msg.to_string(), + } + } +} + +impl Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}: {}", self.kind, self.msg) + } +} + +impl std::error::Error for Error {} + +impl From for Error { + fn from(value: std::io::Error) -> Self { + Self { + kind: ErrorKind::Io, + msg: value.to_string(), + } + } +} diff --git a/stdsrv/src/fileserver.rs b/stdsrv/src/fileserver.rs new file mode 100644 index 0000000..e965f94 --- /dev/null +++ b/stdsrv/src/fileserver.rs @@ -0,0 +1,74 @@ +//! FileServer + +use std::fs; +use std::path::PathBuf; + +use crate::{ + error::*, + request::{HttpMethod, HttpRequest}, + responder::Responder, + response::{HttpResponse, HttpStatus}, +}; + +pub struct FileServer { + root: PathBuf, +} + +impl FileServer { + pub fn new(root: &PathBuf) -> Result { + if !root.is_dir() { + return Err(Error::new( + ErrorKind::DirNotFound, + &root.display().to_string(), + )); + } + + Ok(Self { + root: root.to_owned(), + }) + } + + pub fn get_contents(&self, path: PathBuf) -> Option> { + let mut fullpath = self.root.as_path().join(path); + // default to index.html + if fullpath.is_dir() { + fullpath.push("index.html"); + } + fs::read(fullpath).ok() + } +} + +impl Responder for FileServer { + fn respond(&self, req: HttpRequest) -> HttpResponse { + if req.version != "HTTP/1.1" { + return HttpResponse::new_empty(HttpStatus::HTTPVersionNotSupported); + } + + if req.method != HttpMethod::GET { + return HttpResponse::new_empty(HttpStatus::MethodNotAllowed); + } + + let content_type = match req.path.extension() { + Some(s) => match s.as_encoded_bytes() { + b"html" | b"htm" => "text/html", + b"txt" => "text/plain", + b"css" => "text/css", + b"js" => "text/javascript", + b"pdf" => "application/pdf", + b"json" => "application/json", + b"xml" => "application/xml", + b"gif" => "image/gif", + b"jpeg" | b"jpg" => "image/jpg", + b"png" => "image/png", + _ => "text/plain", + }, + None => "text/html", + }; + + if let Some(content) = self.get_contents(req.path) { + HttpResponse::new(HttpStatus::Ok, content).add_header("Content-Type", content_type) + } else { + HttpResponse::new_empty(HttpStatus::NotFound).add_header("Connection", "close") + } + } +} diff --git a/stdsrv/src/http_header.rs b/stdsrv/src/http_header.rs new file mode 100644 index 0000000..d1c5d74 --- /dev/null +++ b/stdsrv/src/http_header.rs @@ -0,0 +1,37 @@ +use std::{collections::HashMap, fmt::Display}; + +#[derive(Debug)] +pub struct HttpHeaders { + _inner: HashMap, +} + +impl HttpHeaders { + pub fn new() -> Self { + HttpHeaders { + _inner: HashMap::new(), + } + } + + pub fn add(&mut self, k: &str, v: &str) { + self._inner.insert(k.to_string(), v.to_string()); + } + + #[cfg(test)] + pub fn get(&self, k: &str) -> Option<&String> { + self._inner.get(k) + } + + #[cfg(test)] + pub fn len(&self) -> usize { + self._inner.len() + } +} + +impl Display for HttpHeaders { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for (k, v) in self._inner.iter() { + write!(f, "{}: {}\r\n", k, v)?; + } + Ok(()) + } +} diff --git a/stdsrv/src/http_stream.rs b/stdsrv/src/http_stream.rs new file mode 100644 index 0000000..1765be8 --- /dev/null +++ b/stdsrv/src/http_stream.rs @@ -0,0 +1,43 @@ +use crate::error::ErrorKind; +use crate::log; +use crate::logger::Level; +use std::io::Read; +use std::net::{TcpListener, TcpStream}; + +use crate::{error::Error, request::HttpRequest}; + +pub struct HttpStream { + tcp_listener: TcpListener, +} + +impl HttpStream { + pub fn new(addr: &str) -> Self { + let tcp_listener = TcpListener::bind(addr) + .unwrap_or_else(|e| panic!("Failed to bind on address `{}`: {}", addr, e)); + log!(Level::Info, "Listening on `{}`", addr); + + Self { tcp_listener } + } +} + +impl Iterator for HttpStream { + type Item = (HttpRequest, TcpStream); + + fn next(&mut self) -> Option { + // safe to unwrap, because Incoming never returns None + let mut stream = self.tcp_listener.incoming().next().unwrap().ok()?; + + let mut buf = [0; 1024]; + let _read_bytes = stream + .read(&mut buf) + .or(Err(Error::new( + ErrorKind::StreamReadFailed, + "Reading from TCP stream failed", + ))) + .ok()?; + Some(( + String::from_utf8_lossy(&buf[..]).trim().try_into().ok()?, + stream, + )) + } +} diff --git a/stdsrv/src/logger.rs b/stdsrv/src/logger.rs new file mode 100644 index 0000000..4ec0b1d --- /dev/null +++ b/stdsrv/src/logger.rs @@ -0,0 +1,39 @@ +use std::fmt::Display; + +#[derive(Debug)] +#[allow(dead_code)] +pub enum Level { + Error, + Warn, + Info, + Debug, +} + +impl Display for Level { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}]\x1b[0m", + match self { + Self::Error => "\x1b[1;31m[ERROR", + Self::Warn => "\x1b[1;33m[WARN", + Self::Info => "\x1b[0;32m[INFO", + Self::Debug => "\x1b[0;36m[DEBUG", + } + ) + } +} + +#[macro_export] +macro_rules! log { + ($level:expr, $($arg:tt)*) => {{ + println!( + "{} {}:{}:{}: {}", + $level, + std::module_path!(), + std::file!(), + std::line!(), + format!($($arg)*) + ); + }}; +} diff --git a/stdsrv/src/main.rs b/stdsrv/src/main.rs new file mode 100644 index 0000000..10dc05a --- /dev/null +++ b/stdsrv/src/main.rs @@ -0,0 +1,88 @@ +//! This is my very monolithic file server with zero dependencies (other than rust +//! stdlib). +//! +//! Documentation is a work in progress, go see my webpage at [jlux.dev](https://jlux.dev). + +use std::{ + io::{BufReader, BufWriter}, + net::TcpListener, + process, +}; + +use args::ProgramArgs; +use cracked_md::generate; +use fileserver::FileServer; +use logger::Level; +use request::HttpRequest; +use responder::Responder; + +mod args; +mod error; +mod fileserver; +mod http_header; +//mod http_stream; +mod logger; +mod request; +mod responder; +mod response; + +/// Entrypoint to the program. +fn main() -> Result<(), Box> { + let args: ProgramArgs = std::env::args().try_into()?; + + if args.generate { + match generate(&args.indir, &args.outdir, args.force) { + Ok(_) => log!( + Level::Info, + "HTML generation from `{}` to `{}` successful", + args.indir.display(), + args.outdir.display() + ), + Err(cracked_md::Error::OutDirIsNotEmpty) => { + log!( + Level::Error, + "HTML generation failed, run `rm -r {}` and retry", + args.outdir.display() + ); + process::exit(1); + } + Err(e) => { + log!(Level::Error, "HTML generation failed with error: {}", e,); + process::exit(1); + } + }; + } + + let listener = TcpListener::bind(&args.addr)?; + log!(Level::Info, "Listening on addr `{}`", &args.addr); + + for stream in listener.incoming() { + match stream { + Ok(stream) => { + let outdir = args.outdir.to_owned(); + std::thread::spawn(move || { + log!(Level::Debug, "TcpStream handler spawned"); + let mut reader = BufReader::new(&stream); + let mut writer = BufWriter::new(&stream); + let server = match FileServer::new(&outdir) { + Ok(s) => s, + Err(_e) => return, + }; + + while let Ok(req) = HttpRequest::try_from(&mut reader) { + let _ = server + .respond(req) + .add_header("Server", "stdsrv") + .add_header("Connection", "keep-alive") + .send(&mut writer) + .map_err(|e| log!(Level::Error, "{}", e)); + } + log!(Level::Debug, "TcpStream handler exited"); + }); + } + Err(e) => log!(Level::Warn, "Connection failed: {}", e), + } + } + + Ok(()) +} diff --git a/stdsrv/src/request.rs b/stdsrv/src/request.rs new file mode 100644 index 0000000..3be4814 --- /dev/null +++ b/stdsrv/src/request.rs @@ -0,0 +1,243 @@ +//! An abstraction layer for parsing HTTP requests. As simple and high level as I managed. + +use crate::error::{Error, ErrorKind, Result}; +use crate::http_header::HttpHeaders; +use crate::log; +use crate::logger::Level; +use std::fmt::Display; +use std::io::{BufRead, BufReader}; +use std::net::TcpStream; +use std::path::PathBuf; + +/// Supports just GET methods for now. +#[derive(Debug, PartialEq)] +#[allow(dead_code, clippy::upper_case_acronyms)] +pub enum HttpMethod { + GET, + //PUT, + //POST, +} + +impl TryFrom<&str> for HttpMethod { + type Error = Error; + fn try_from(value: &str) -> Result { + match value { + "GET" => Ok(Self::GET), + //"PUT" => Ok(Self::PUT), + //"POST" => Ok(Self::POST), + _ => Err(Error::new(ErrorKind::UnsupportedHttpMethod, value)), + } + } +} + +impl Display for HttpMethod { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self) + } +} + +/// Type for HTTP requests. +#[derive(Debug)] +#[allow(dead_code)] +pub struct HttpRequest { + pub method: HttpMethod, + pub path: PathBuf, + pub version: String, + pub headers: HttpHeaders, +} + +impl Display for HttpRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{} {} {}\n{}", + self.method, + self.path.display(), + self.version, + self.headers + ) + } +} + +impl TryFrom<&mut BufReader<&TcpStream>> for HttpRequest { + type Error = Error; + fn try_from(value: &mut BufReader<&TcpStream>) -> std::result::Result { + let mut request_line = String::new(); + + value + .read_line(&mut request_line) + .map_err(|_e| Error::new(ErrorKind::RequestParse, "expected request line"))?; + let mut parts = request_line.split_whitespace(); + let method: HttpMethod = parts + .next() + .ok_or(Error::new( + ErrorKind::RequestParse, + "expected request method", + ))? + .try_into()?; + let path_with_prefix: PathBuf = parts + .next() + .ok_or(Error::new(ErrorKind::RequestParse, "expected request path"))? + .into(); + let path = if path_with_prefix.starts_with("/") { + path_with_prefix.strip_prefix("/").unwrap().into() + } else { + path_with_prefix + }; + + let version = parts + .next() + .ok_or(Error::new( + ErrorKind::RequestParse, + "expected request version", + ))? + .into(); + + let mut req = Self { + method, + path, + version, + headers: HttpHeaders::new(), + }; + + loop { + let mut line = String::new(); + value.read_line(&mut line)?; + if line == "\r\n" || line.is_empty() { + break; + } + if let Some((header, val)) = line.split_once(": ") { + req.headers.add(header, val); + } + } + + Ok(req) + } +} + +impl TryFrom<&str> for HttpRequest { + type Error = Error; + fn try_from(s: &str) -> Result { + let mut lines = s.split("\r\n"); + + let request_line = lines + .next() + .ok_or(Error::new(ErrorKind::RequestParse, "expected request line"))?; + let mut parts = request_line.split_whitespace(); + + let method = parts + .next() + .ok_or(Error::new( + ErrorKind::RequestParse, + "expected request method", + ))? + .try_into()?; + let path_with_prefix: PathBuf = parts + .next() + .ok_or(Error::new(ErrorKind::RequestParse, "expected request path"))? + .into(); + let path = if path_with_prefix.starts_with("/") { + path_with_prefix.strip_prefix("/").unwrap().into() + } else { + path_with_prefix + }; + + let version = parts + .next() + .ok_or(Error::new( + ErrorKind::RequestParse, + "expected request version", + ))? + .into(); + + let mut headers = HttpHeaders::new(); + + for line in lines { + if let Some(v) = line.split_once(": ") { + headers.add(v.0, v.1) + } + } + + let req = Self { + method, + path, + version, + headers, + }; + + log!(Level::Info, "\n{}", req); + + Ok(req) + } +} + +// ------------------------------ +// TESTS +// ------------------------------ + +#[cfg(test)] +mod request_test { + use super::*; + + #[test] + fn http_parse_method_get() { + let s = "GET / HTTP/1.1\r\nHost: localhost:8080\r\nUser-Agent: curl/8.14.1\r\nAccept: */*\r\n\r\n"; + let req: HttpRequest = s.try_into().unwrap(); + + assert_eq!(req.method, HttpMethod::GET); + } + + #[test] + fn http_parse_path_indexhtml() { + let s = "GET /index.html HTTP/1.1\r\nHost: localhost:8080\r\nUser-Agent: curl/8.14.1\r\nAccept: */*\r\n\r\n"; + let req: HttpRequest = s.try_into().unwrap(); + + assert_eq!(req.path, PathBuf::from("index.html")); + } + + #[test] + fn http_parse_version() { + let s = "GET / HTTP/1.1\r\nHost: localhost:8080\r\nUser-Agent: curl/8.14.1\r\nAccept: */*\r\n\r\n"; + let req: HttpRequest = s.try_into().unwrap(); + + assert_eq!(req.version, "HTTP/1.1"); + } + + #[test] + fn http_parse_headers_len() { + let s = "GET / HTTP/1.1\r\nHost: localhost:8080\r\nUser-Agent: curl/8.14.1\r\nAccept: */*\r\n\r\n"; + let req: HttpRequest = s.try_into().unwrap(); + + assert_eq!(req.headers.len(), 3); + } + + #[test] + fn http_parse_useragent_header_curl() { + let s = "GET / HTTP/1.1\r\nHost: localhost:8080\r\nUser-Agent: curl/8.14.1\r\nAccept: */*\r\n\r\n"; + let req: HttpRequest = s.try_into().unwrap(); + + assert_eq!(req.headers.get("User-Agent").unwrap(), "curl/8.14.1"); + } + + #[test] + fn http_parse_empty_should_fail() { + let s = ""; + let req: Result = s.try_into(); + + assert!(req.is_err()); + } + + #[test] + fn http_parse_unsupported_method_delete() { + let s = "DELETE / HTTP/1.1\r\nHost: localhost:8080\r\nUser-Agent: curl/8.14.1\r\nAccept: */*\r\n\r\n"; + let req: Result = s.try_into(); + + assert!(req.is_err()); + } + + #[test] + fn http_method_display() { + let method = HttpMethod::GET; + assert_eq!(method.to_string(), "GET") + } +} diff --git a/stdsrv/src/responder.rs b/stdsrv/src/responder.rs new file mode 100644 index 0000000..b5850ed --- /dev/null +++ b/stdsrv/src/responder.rs @@ -0,0 +1,55 @@ +//! Traits helping HTTP connections + +use crate::request::HttpRequest; +use crate::response::HttpResponse; + +/// Responder trait. Just a respond method that turns a HttpRequest to a HttpResponse. +pub trait Responder { + fn respond(&self, req: HttpRequest) -> HttpResponse; +} + +/* +/// Size trait. Number of bytes when encoded. +pub trait Size { + fn size(&self) -> usize; +} + +// Standard implementations for Size trait + +impl Size for u8 { + fn size(&self) -> usize { + 1 + } +} + +impl Size for Vec +where + T: Size, +{ + fn size(&self) -> usize { + if let Some(elem) = self.first() { + elem.size() * self.len() + } else { + 0 + } + } +} + +impl Size for Option +where + T: Size, +{ + fn size(&self) -> usize { + match self { + Some(t) => t.size(), + None => 0, + } + } +} + +impl Size for String { + fn size(&self) -> usize { + self.len() + } +} +*/ diff --git a/stdsrv/src/response.rs b/stdsrv/src/response.rs new file mode 100644 index 0000000..9b4ed24 --- /dev/null +++ b/stdsrv/src/response.rs @@ -0,0 +1,189 @@ +//! An abstraction layer for building and sending HTTP responses. + +use crate::error::Result; +use crate::http_header::HttpHeaders; +use crate::log; +use crate::logger::Level; +use std::{fmt::Display, io::Write}; + +/// Macro for generating Http status codes (AI generated). +macro_rules! http_statuses { + ($($name:ident => ($code:expr, $reason:expr)),+ $(,)?) => { + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + #[allow(unused_attributes, dead_code)] + pub enum HttpStatus { + $($name),+ + } + + impl HttpStatus { + pub fn code(&self) -> u16 { + match self { + $(HttpStatus::$name => $code,)+ + } + } + + pub fn reason(&self) -> &'static str { + match self { + $(HttpStatus::$name => $reason,)+ + } + } + } + }; +} + +http_statuses!( + Ok => (200, "OK"), + + NotFound => (404, "Not Found"), + MethodNotAllowed => (405, "Method Not Allowed"), + + ImATeapot => (418, "I'm a teapot"), + + InternalServerError => (500, "Internal Server Error"), + HTTPVersionNotSupported => (505, "HTTP Version Not Supported"), +); + +impl Display for HttpStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} {}", self.code(), self.reason()) + } +} + +/// HTTP response structure +#[derive(Debug)] +pub struct HttpResponse { + version: String, + status: HttpStatus, + headers: HttpHeaders, + body: Option>, +} + +/* +impl Size for HttpResponse { + fn size(&self) -> usize { + self.as_bytes().len() + } +} +*/ + +impl HttpResponse { + pub fn new_empty(status: HttpStatus) -> Self { + let mut headers = HttpHeaders::new(); + headers.add("Content-Length", "0"); + Self { + version: "HTTP/1.1".into(), + status, + headers, + body: None, + } + } + + pub fn new(status: HttpStatus, body: Vec) -> Self { + let mut headers = HttpHeaders::new(); + headers.add("Content-Length", &body.len().to_string()); + Self { + version: "HTTP/1.1".into(), + status, + headers, + body: Some(body), + } + } + + pub fn add_header(mut self, key: &str, value: &str) -> Self { + self.headers.add(key, value); + self + } + + fn add_header_inner(&mut self, key: &str, value: &str) { + self.headers.add(key, value); + } + + /// sending images "cuts off" + pub fn send(&mut self, stream: &mut impl Write) -> Result<()> { + let body_len = match &self.body { + Some(v) => format!("{}", v.len()), + None => "0".to_string(), + }; + self.add_header_inner("Content-Length", &body_len); + stream.write_all(self.start_bytes())?; + if let Some(b) = &self.body { + stream.write_all(b)?; + } + //stream.write_all(b"\r\n")?; + stream.flush()?; + //sleep(Duration::from_millis(100)); + /* + stream.shutdown(std::net::Shutdown::Write)?; + + // hack? + let _ = std::io::Read::read(stream, &mut [0u8; 1]); + */ + + log!(Level::Info, "\n{}", &self); + Ok(()) + } + + fn start_bytes(&self) -> &[u8] { + let mut bytes: Vec = Vec::new(); + bytes.extend_from_slice(self.version.as_bytes()); + bytes.extend_from_slice(b" "); + bytes.extend_from_slice(self.status.to_string().as_bytes()); + bytes.extend_from_slice(b"\r\n"); + bytes.extend_from_slice(self.headers.to_string().as_bytes()); + bytes.extend_from_slice(b"\r\n"); + bytes.leak() + } +} + +impl Display for HttpResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{} {}\r\n{}\r\n", + self.version, self.status, self.headers, + )?; + if let Some(s) = &self.body { + write!( + f, + "{}\r\n", + String::from_utf8(s.to_vec()).unwrap_or("".to_string()) + )?; + } + Ok(()) + } +} + +// -------------------- +// TESTS +// -------------------- +#[cfg(test)] +mod response_test { + use super::*; + + #[test] + fn http_status_macro_display() { + let stat = HttpStatus::ImATeapot; + + assert_eq!(stat.to_string(), "418 I'm a teapot"); + } + + #[test] + fn http_response_new_empty() { + let resp = HttpResponse::new_empty(HttpStatus::ImATeapot); + + assert_eq!( + resp.to_string(), + "HTTP/1.1 418 I'm a teapot\r\nContent-Length: 0\r\n\r\n" + ); + } + + #[test] + fn http_response_new_with_body() { + let resp = HttpResponse::new(HttpStatus::ImATeapot, b"teapot".into()); + + assert_eq!( + resp.to_string(), + "HTTP/1.1 418 I'm a teapot\r\nContent-Length: 6\r\n\r\nteapot\r\n" + ); + } +}