first commit

This commit is contained in:
2025-11-09 01:06:25 +02:00
commit dbaa2feb48
28 changed files with 1551 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
html/
web/
target/
result
Cargo.lock
flake.lock

8
Cargo.toml Normal file
View File

@@ -0,0 +1,8 @@
[workspace]
members = [
"stdsrv",
"cracked_md",
"fstools",
]
resolver = "3"

1
cracked_md/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

7
cracked_md/Cargo.toml Normal file
View File

@@ -0,0 +1,7 @@
[package]
name = "cracked_md"
version = "0.1.0"
edition = "2024"
[dependencies]
fstools = { path = "../fstools" }

194
cracked_md/src/ast.rs Normal file
View File

@@ -0,0 +1,194 @@
use crate::to_html::ToHtml;
#[derive(Debug, Clone, PartialEq)]
pub struct Document {
pub blocks: Vec<Block>,
}
impl ToHtml for Document {
fn to_html(self) -> String {
format!(
"<!doctype html><html lang=en><head></head><body>{}</body></html>",
self.blocks.to_html()
)
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Block {
Paragraph(Vec<Inline>),
Heading {
level: u8,
content: Vec<Inline>,
},
Code {
language: Option<String>,
content: String,
},
List(Vec<ListItem>),
Quote(Vec<Block>),
}
impl ToHtml for Block {
fn to_html(self) -> String {
match self {
Self::Paragraph(content) => format!("<p>{}</p>", content.to_html()),
Self::Heading { level, content } => {
format!("<h{}>{}</h{}>", level, content.to_html(), level)
}
Self::Code {
language: _,
content,
} => {
format!("<pre><code>{}</code></pre>", content)
}
_ => todo!(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ListItem {
pub blocks: Vec<Block>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Inline {
Text(String),
Bold(Vec<Inline>),
Italic(Vec<Inline>),
Code(String),
Link { text: Vec<Inline>, href: String },
}
impl ToHtml for Inline {
fn to_html(self) -> String {
match self {
Self::Text(s) => s,
Self::Bold(content) => format!("<b>{}</b>", content.to_html()),
Self::Italic(content) => format!("<i>{}</i>", content.to_html()),
Self::Code(s) => format!("<code>{}</code>", s),
Self::Link { text, href } => format!("<a href=\"{}\">{}</a>", href, text.to_html()),
}
}
}
impl<T> ToHtml for Vec<T>
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,
"<!doctype html><html lang=en><head></head><body><h1>Heading 1</h1></body></html>"
);
}
#[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,
"<!doctype html><html lang=en><head></head><body><h1><b>Bold</b> heading 1</h1></body></html>"
);
}
#[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,
"<!doctype html><html lang=en><head></head><body><h1><b>Bold</b> heading 1</h1><h2>Heading 2</h2><p>run <code>sudo rm -rf /</code> on your computer</p></body></html>"
);
}
}
#[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,
"<!doctype html><html lang=en><head></head><body><h1>Header 1</h1></body></html>"
);
}
#[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,
"<!doctype html><html lang=en><head></head><body><h1><b>Bold</b> header 1</h1><h2>Header 2</h2><p>run <code>sudo rm -rf /</code> on your computer</p></body></html>"
);
}
}

76
cracked_md/src/lib.rs Normal file
View File

@@ -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<T> = std::result::Result<T, crate::Error>;
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(())
}

147
cracked_md/src/parser.rs Normal file
View File

@@ -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()
}]
}
);
}
}

View File

@@ -0,0 +1,45 @@
use crate::ast::Block;
use super::inline::parse_inlines;
pub fn parse_blocks(input: &str) -> Vec<Block> {
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
}

View File

@@ -0,0 +1,61 @@
use crate::ast::Inline;
pub fn parse_inlines(input: &str) -> Vec<Inline> {
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<I: Iterator<Item = char>>(
chars: &mut std::iter::Peekable<I>,
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
}

View File

@@ -0,0 +1,4 @@
pub trait ToHtml {
fn to_html(self) -> String;
}

31
flake.nix Normal file
View File

@@ -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
];
};
}
);
}

1
fstools/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

6
fstools/Cargo.toml Normal file
View File

@@ -0,0 +1,6 @@
[package]
name = "fstools"
version = "0.1.0"
edition = "2024"
[dependencies]

45
fstools/src/lib.rs Normal file
View File

@@ -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<PathBuf> {
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<PathBuf> {
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<PathBuf> = subdirs
.into_iter()
.flat_map(|s| crawl_fs_rec(root, &s))
.collect();
files.extend(others);
files
}

20
package.nix Normal file
View File

@@ -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;
};
}

9
rust-toolchain.toml Normal file
View File

@@ -0,0 +1,9 @@
[toolchain]
channel = "nightly"
targets = [
"x86_64-unknown-linux-gnu"
]
components = [
"clippy",
"rustfmt"
]

1
stdsrv/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
target

9
stdsrv/Cargo.toml Normal file
View File

@@ -0,0 +1,9 @@
[package]
name = "stdsrv"
version = "0.1.0"
edition = "2024"
# local dependencies
[dependencies]
cracked_md = { path = "../cracked_md" }
fstools = { path = "../fstools" }

58
stdsrv/src/args.rs Normal file
View File

@@ -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<std::env::Args> for ProgramArgs {
type Error = crate::error::Error;
fn try_from(mut value: std::env::Args) -> Result<Self, Self::Error> {
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)
}
}

53
stdsrv/src/error.rs Normal file
View File

@@ -0,0 +1,53 @@
//! Custom Error type and Result enum and their most standard trait implementations.
use std::fmt::{Debug, Display};
pub type Result<T> = std::result::Result<T, Error>;
#[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<std::io::Error> for Error {
fn from(value: std::io::Error) -> Self {
Self {
kind: ErrorKind::Io,
msg: value.to_string(),
}
}
}

74
stdsrv/src/fileserver.rs Normal file
View File

@@ -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<FileServer> {
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<Vec<u8>> {
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")
}
}
}

37
stdsrv/src/http_header.rs Normal file
View File

@@ -0,0 +1,37 @@
use std::{collections::HashMap, fmt::Display};
#[derive(Debug)]
pub struct HttpHeaders {
_inner: HashMap<String, String>,
}
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(())
}
}

43
stdsrv/src/http_stream.rs Normal file
View File

@@ -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<Self::Item> {
// 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,
))
}
}

39
stdsrv/src/logger.rs Normal file
View File

@@ -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)*)
);
}};
}

88
stdsrv/src/main.rs Normal file
View File

@@ -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<dyn std::error::Error>> {
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(())
}

243
stdsrv/src/request.rs Normal file
View File

@@ -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<Self> {
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<Self, Self::Error> {
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<Self> {
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<HttpRequest> = 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<HttpRequest> = s.try_into();
assert!(req.is_err());
}
#[test]
fn http_method_display() {
let method = HttpMethod::GET;
assert_eq!(method.to_string(), "GET")
}
}

55
stdsrv/src/responder.rs Normal file
View File

@@ -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<T> Size for Vec<T>
where
T: Size,
{
fn size(&self) -> usize {
if let Some(elem) = self.first() {
elem.size() * self.len()
} else {
0
}
}
}
impl<T> Size for Option<T>
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()
}
}
*/

189
stdsrv/src/response.rs Normal file
View File

@@ -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<Vec<u8>>,
}
/*
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<u8>) -> 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<u8> = 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("<binary data>".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"
);
}
}