diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/error.rs | 28 | ||||
-rw-r--r-- | src/fs.rs | 52 | ||||
-rw-r--r-- | src/main.rs | 150 | ||||
-rw-r--r-- | src/settings.rs | 6 | ||||
-rw-r--r-- | src/templated.rs | 125 |
5 files changed, 336 insertions, 25 deletions
diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..56539d0 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,28 @@ +use std::io; + +use camino::Utf8PathBuf; + +#[derive(Debug, snafu::Snafu)] +pub enum RuntimeError { + #[snafu(display("the path was not found: {path}"))] + NotFound { + source: io::Error, + path: Utf8PathBuf, + }, + #[snafu(display("io error: {path}: {source}"))] + UnknownIo { + source: io::Error, + path: Utf8PathBuf, + }, + #[snafu(display("path tried to go below webroot: {path}"))] + PathTooLow { path: String }, +} + +impl RuntimeError { + pub fn from_io(source: io::Error, path: Utf8PathBuf) -> Self { + match source.kind() { + io::ErrorKind::NotFound => RuntimeError::NotFound { source, path }, + _ => RuntimeError::UnknownIo { source, path }, + } + } +} diff --git a/src/fs.rs b/src/fs.rs index e13f405..d9b8810 100644 --- a/src/fs.rs +++ b/src/fs.rs @@ -1,6 +1,9 @@ use camino::{Utf8Path, Utf8PathBuf}; +use core::fmt; use std::{io, ops::Deref, str::FromStr}; +use crate::RuntimeError; + /// Webpath is the path we get from HTTP requests. It's garunteed to not fall /// below the webroot and will never start or end with a slash. #[derive(Clone, Debug, PartialEq)] @@ -60,9 +63,16 @@ impl PartialEq<str> for Webpath { } } +impl fmt::Display for Webpath { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "/{}", self.webcanon) + } +} + const ROOT_INDEX: &str = "home.html"; const DIRFILE_EXT: &str = "html"; +#[derive(Clone, Debug)] pub struct Filesystem { webroot: Utf8PathBuf, } @@ -74,7 +84,8 @@ impl Filesystem { } } - fn resolve(&self, webpath: &Webpath) -> Result<Utf8PathBuf, RuntimeError> { + pub fn resolve(&self, webpath: &Webpath) -> Result<Utf8PathBuf, RuntimeError> { + println!("resolve = {webpath}"); if webpath.is_index() || webpath == ROOT_INDEX { return Ok(self.webroot.join(ROOT_INDEX)); } @@ -102,35 +113,28 @@ impl Filesystem { } } - fn metadata<P: AsRef<Utf8Path>>(path: P) -> Result<std::fs::Metadata, RuntimeError> { + pub fn metadata<P: AsRef<Utf8Path>>(path: P) -> Result<std::fs::Metadata, RuntimeError> { path.as_ref() .metadata() .map_err(|ioe| RuntimeError::from_io(ioe, path.as_ref().to_owned())) } -} -#[derive(Debug, snafu::Snafu)] -pub enum RuntimeError { - #[snafu(display("the path was not found: {path}"))] - NotFound { - source: io::Error, - path: Utf8PathBuf, - }, - #[snafu(display("io error: {path}: {source}"))] - UnknownIo { - source: io::Error, - path: Utf8PathBuf, - }, - #[snafu(display("path tried to go below webroot: {path}"))] - PathTooLow { path: String }, -} + pub async fn open<P: AsRef<Utf8Path>>(path: P) -> Result<tokio::fs::File, RuntimeError> { + tokio::fs::File::open(path.as_ref()) + .await + .map_err(|ioe| RuntimeError::from_io(ioe, path.as_ref().to_owned())) + } -impl RuntimeError { - pub fn from_io(source: io::Error, path: Utf8PathBuf) -> Self { - match source.kind() { - io::ErrorKind::NotFound => RuntimeError::NotFound { source, path }, - _ => RuntimeError::UnknownIo { source, path }, - } + pub async fn read<P: AsRef<Utf8Path>>(path: P) -> Result<Vec<u8>, RuntimeError> { + tokio::fs::read(path.as_ref()) + .await + .map_err(|ioe| RuntimeError::from_io(ioe, path.as_ref().to_owned())) + } + + pub async fn read_to_string<P: AsRef<Utf8Path>>(path: P) -> Result<String, RuntimeError> { + tokio::fs::read_to_string(path.as_ref()) + .await + .map_err(|ioe| RuntimeError::from_io(ioe, path.as_ref().to_owned())) } } diff --git a/src/main.rs b/src/main.rs index 1ba94f7..c429e8a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,151 @@ +mod error; mod fs; +mod settings; +mod templated; -fn main() {} +use std::{os::unix::fs::MetadataExt, str::FromStr}; + +use axum::{ + body::Body, extract::Path, http::header, response::Response, routing::get, Extension, Router, +}; +use bempline::{Document, Options}; +use camino::Utf8PathBuf; +pub use error::RuntimeError; +use fs::Filesystem; +use settings::Settings; +use tokio_util::io::ReaderStream; + +use crate::templated::Templated; + +#[tokio::main] +async fn main() { + let fs = Filesystem::new("../inf/served"); + + let settings = Settings { + template_dir: Utf8PathBuf::from("../inf/templates"), + }; + + let app = Router::new() + .route("/", get(index_handler)) + .route("/*path", get(handler)) + .layer(Extension(fs)) + .layer(Extension(settings)); + + let listener = tokio::net::TcpListener::bind("0.0.0.0:2560").await.unwrap(); + axum::serve(listener, app).await.unwrap() +} + +async fn index_handler(fse: Extension<Filesystem>, se: Extension<Settings>) -> Response { + handler(fse, se, Path(String::from("/"))).await +} + +async fn handler( + Extension(fs): Extension<Filesystem>, + Extension(settings): Extension<Settings>, + Path(path): Path<String>, +) -> Response { + match falible_handler(fs, settings, path).await { + Ok(resp) => resp, + Err(re) => Response::builder() + .body(Body::from(re.to_string())) + .unwrap(), + } +} + +async fn falible_handler( + fs: Filesystem, + settings: Settings, + path: String, +) -> Result<Response, RuntimeError> { + println!("raw = {path}"); + + let path = path.parse()?; + + println!("path = {path}"); + + let filepath = fs.resolve(&path)?; + + let ext = filepath.extension().unwrap_or_default(); + + if ext != "html" { + send_file(filepath).await + } else { + let content = Filesystem::read_to_string(&filepath).await?; + match Templated::from_str(&content) { + Ok(templated) => send_template(templated, filepath, settings).await, + Err(_) => Ok(Response::builder() + .header(header::CONTENT_TYPE, "text/html") + .body(Body::from(content)) + .unwrap()), + } + } +} + +// 20 megabytes +const STREAM_AFTER: u64 = 20 * 1024 * 1024; + +async fn send_file(filepath: Utf8PathBuf) -> Result<Response, RuntimeError> { + let ext = filepath.extension().unwrap_or_default(); + + let mime = match ext { + // Text + "css" => "text/css", + "html" => "text/html", + "js" => "text/javascript", + "txt" => "txt/plain", + + // Multimedia + "gif" => "image/gif", + "jpg" | "jpeg" => "image/jpeg", + "mp4" => "video/mp4", + "png" => "image/png", + _ => "", + }; + + let mut response = Response::builder(); + if !mime.is_empty() { + response = response.header(header::CONTENT_TYPE, mime); + } + + let metadata = Filesystem::metadata(&filepath)?; + if metadata.size() > STREAM_AFTER { + let file = Filesystem::open(filepath).await?; + let stream = ReaderStream::new(file); + Ok(response.body(Body::from_stream(stream)).unwrap()) + } else { + let content = Filesystem::read(filepath).await?; + Ok(response.body(Body::from(content)).unwrap()) + } +} + +async fn send_template( + templated: Templated, + path: Utf8PathBuf, + settings: Settings, +) -> Result<Response, RuntimeError> { + let template_stem = templated.frontmatter.get("template").expect("no template"); + let template_name = Utf8PathBuf::from(format!("{template_stem}.html")); + let template_path = settings.template_dir.join(template_name); + + let filename = path.file_name().expect("template has no filename"); + + let mut template = Document::from_file( + template_path, + Options::default().include_path(bempline::options::IncludeMethod::Path( + settings.template_dir.as_std_path().to_owned(), + )), + ) + .unwrap(); + + template.set( + "title", + templated.frontmatter.get("title").unwrap_or(filename), + ); + + template.set("main", templated.content); + + Ok(Response::builder() + .header(header::CONTENT_TYPE, "text/html") + .body(Body::from(template.compile())) + .unwrap()) +} diff --git a/src/settings.rs b/src/settings.rs new file mode 100644 index 0000000..9a31d56 --- /dev/null +++ b/src/settings.rs @@ -0,0 +1,6 @@ +use camino::Utf8PathBuf; + +#[derive(Clone, Debug)] +pub struct Settings { + pub template_dir: Utf8PathBuf, +} diff --git a/src/templated.rs b/src/templated.rs new file mode 100644 index 0000000..c6daac8 --- /dev/null +++ b/src/templated.rs @@ -0,0 +1,125 @@ +use std::str::FromStr; + +/// The content part of a file that fills in a template. +pub struct Templated { + pub frontmatter: Frontmatter, + pub content: String, +} + +impl FromStr for Templated { + type Err = TemplateError; + + fn from_str(raw: &str) -> Result<Self, Self::Err> { + let (front, content) = match raw.strip_prefix("---\n") { + None => return Err(TemplateError::MissingFrontmatter), + Some(no_start) => match no_start.split_once("\n---\n") { + None => return Err(TemplateError::MissingFrontmatter), + Some((front, content)) => (front, content), + }, + }; + + Ok(Self { + frontmatter: front.parse()?, + content: content.to_owned(), + }) + } +} + +pub struct Frontmatter { + entries: Vec<(String, String)>, +} + +impl FromStr for Frontmatter { + type Err = TemplateError; + + fn from_str(raw: &str) -> Result<Self, Self::Err> { + let mut entries = vec![]; + + for line in raw.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + // Skip over blank lines and comments + continue; + } + + let (key, value) = match trimmed.split_once('=') { + None => { + return Err(TemplateError::MalformedFrontmatterEntry { + line: trimmed.to_owned(), + }); + } + Some(tup) => tup, + }; + + entries.push((key.trim().to_owned(), value.trim().to_owned())) + } + + Ok(Self { entries }) + } +} + +impl Frontmatter { + pub fn get(&self, key: &str) -> Option<&str> { + self.entries + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v.as_str()) + } + + pub fn get_many(&self, key: &str) -> Vec<&str> { + // this could probably be a filter_map() but the explicit filter and + // then map seems cleaner + self.entries + .iter() + .filter(|(k, _)| k == key) + .map(|(_, v)| v.as_str()) + .collect() + } +} + +#[derive(Debug, snafu::Snafu)] +pub enum TemplateError { + #[snafu(display("file is missing the frontmatter"))] + MissingFrontmatter, + #[snafu(display("the frontmatter entry is malformed: {line}"))] + MalformedFrontmatterEntry { line: String }, +} + +#[cfg(test)] +mod test { + use std::str::FromStr; + + use crate::templated::{Frontmatter, Templated}; + + #[test] + fn frontmatter_parse() { + let frntmtr = "key=value\nkey2=value2"; + assert!(Frontmatter::from_str(frntmtr).is_ok()) + } + + #[test] + fn frontmatter_fails_on_invalid_line() { + let front = "key1=value1\nincorrect line"; + assert!(Frontmatter::from_str(front).is_err()) + } + + #[test] + fn templated_parse() { + let tempalted = "---\ntitle=Title!\n---\nContent line!"; + assert!(Templated::from_str(tempalted).is_ok()) + } + + #[test] + fn templated_doesnt_eat_frontmatter_line() { + let templated = "---\ntitle=Title---\nContent"; + assert!(Templated::from_str(templated).is_err()); + } + + #[test] + fn templated_parses_correct() { + let raw = "---\ntitle=Title!\n---\n<p>Paragraph!</p>"; + let tmpl = Templated::from_str(raw).unwrap(); + assert_eq!(tmpl.frontmatter.get("title").unwrap(), "Title!"); + assert_eq!(tmpl.content, "<p>Paragraph!</p>") + } +} |