first commit
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
html/
|
||||
web/
|
||||
target/
|
||||
|
||||
result
|
||||
Cargo.lock
|
||||
flake.lock
|
||||
8
Cargo.toml
Normal file
8
Cargo.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"stdsrv",
|
||||
"cracked_md",
|
||||
"fstools",
|
||||
]
|
||||
|
||||
resolver = "3"
|
||||
1
cracked_md/.gitignore
vendored
Normal file
1
cracked_md/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target
|
||||
7
cracked_md/Cargo.toml
Normal file
7
cracked_md/Cargo.toml
Normal 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
194
cracked_md/src/ast.rs
Normal 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
76
cracked_md/src/lib.rs
Normal 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
147
cracked_md/src/parser.rs
Normal 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()
|
||||
}]
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
45
cracked_md/src/parser/block.rs
Normal file
45
cracked_md/src/parser/block.rs
Normal 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
|
||||
}
|
||||
61
cracked_md/src/parser/inline.rs
Normal file
61
cracked_md/src/parser/inline.rs
Normal 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
|
||||
}
|
||||
4
cracked_md/src/to_html.rs
Normal file
4
cracked_md/src/to_html.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub trait ToHtml {
|
||||
fn to_html(self) -> String;
|
||||
}
|
||||
|
||||
31
flake.nix
Normal file
31
flake.nix
Normal 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
1
fstools/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target
|
||||
6
fstools/Cargo.toml
Normal file
6
fstools/Cargo.toml
Normal file
@@ -0,0 +1,6 @@
|
||||
[package]
|
||||
name = "fstools"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
45
fstools/src/lib.rs
Normal file
45
fstools/src/lib.rs
Normal 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
20
package.nix
Normal 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
9
rust-toolchain.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[toolchain]
|
||||
channel = "nightly"
|
||||
targets = [
|
||||
"x86_64-unknown-linux-gnu"
|
||||
]
|
||||
components = [
|
||||
"clippy",
|
||||
"rustfmt"
|
||||
]
|
||||
1
stdsrv/.gitignore
vendored
Normal file
1
stdsrv/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
target
|
||||
9
stdsrv/Cargo.toml
Normal file
9
stdsrv/Cargo.toml
Normal 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
58
stdsrv/src/args.rs
Normal 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
53
stdsrv/src/error.rs
Normal 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
74
stdsrv/src/fileserver.rs
Normal 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
37
stdsrv/src/http_header.rs
Normal 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
43
stdsrv/src/http_stream.rs
Normal 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
39
stdsrv/src/logger.rs
Normal 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
88
stdsrv/src/main.rs
Normal 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
243
stdsrv/src/request.rs
Normal 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
55
stdsrv/src/responder.rs
Normal 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
189
stdsrv/src/response.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user