about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/error.rs28
-rw-r--r--src/fs.rs52
-rw-r--r--src/main.rs150
-rw-r--r--src/settings.rs6
-rw-r--r--src/templated.rs125
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>")
+	}
+}