diff options
-rw-r--r-- | Cargo.lock | 96 | ||||
-rw-r--r-- | Cargo.toml | 3 | ||||
-rw-r--r-- | readme.md | 56 | ||||
-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 |
8 files changed, 490 insertions, 26 deletions
diff --git a/Cargo.lock b/Cargo.lock index ea690e2..0976342 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -39,8 +39,11 @@ name = "awake" version = "0.1.0" dependencies = [ "axum", + "bempline", "camino", "snafu", + "tokio", + "tokio-util", ] [[package]] @@ -114,6 +117,17 @@ dependencies = [ ] [[package]] +name = "bempline" +version = "0.9.0" +source = "git+https://github.com/gennyble/bempline#8de6d7f96e98e948acb6e823003775571417a8a2" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] name = "bytes" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -235,6 +249,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" [[package]] +name = "hermit-abi" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7" + +[[package]] name = "http" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -338,6 +358,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" [[package]] +name = "lock_api" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1cc9717a20b1bb222f333e6a92fd32f7d8a18ddc5a3191a11af45dcbf4dcd16" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] name = "log" version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -382,6 +412,16 @@ dependencies = [ ] [[package]] +name = "num_cpus" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] name = "object" version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -397,6 +437,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" [[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93f00c865fe7cabf650081affecd3871070f26767e7b2070a3ffae14c654b447" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets", +] + +[[package]] name = "percent-encoding" version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -453,6 +516,15 @@ dependencies = [ ] [[package]] +name = "redox_syscall" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" +dependencies = [ + "bitflags", +] + +[[package]] name = "rustc-demangle" version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -471,6 +543,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" [[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] name = "serde" version = "1.0.197" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -524,6 +602,15 @@ dependencies = [ ] [[package]] +name = "signal-hook-registry" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" +dependencies = [ + "libc", +] + +[[package]] name = "slab" version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -533,6 +620,12 @@ dependencies = [ ] [[package]] +name = "smallvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb4feee49fdd9f707ef802e22365a35de4b7b299de4763d44bfea899442ff9" + +[[package]] name = "snafu" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -590,7 +683,10 @@ dependencies = [ "bytes", "libc", "mio", + "num_cpus", + "parking_lot", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys", diff --git a/Cargo.toml b/Cargo.toml index 628bff6..220d7e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,3 +9,6 @@ edition = "2021" axum = "0.7.4" camino = "1.1.6" snafu = "0.8.0" +bempline = { git = "https://github.com/gennyble/bempline" } +tokio = { version = "1.36.0", features = ["full"] } +tokio-util = { version = "0.7.10", features = ["io"] } diff --git a/readme.md b/readme.md index c7a1258..b7a3651 100644 --- a/readme.md +++ b/readme.md @@ -1,3 +1,11 @@ +# For Later +**Single-tile weather radar** +Using the RainViewer API for the radar and an OpenStreetMap tileserver for the +map tile. Make a gif with a few frames of radar so we can embed it on the site. + +Zoomed out far enough that I'm not worried about opsec and with no marker for +where I am in the tile. + ## Dirfiles These are files that match the name of the directory. @@ -10,4 +18,50 @@ the directory name changes. What do you name the root file, then? You shouldn't have to match the webroot's directory name. Perhaps it should be configurable. I think for now we'll -hard-code in `home.html`, though. \ No newline at end of file +hard-code in `home.html`, though. + +## Page-content uses templates +Because writing the same outer-html for everything poses a few problems. I'll +enumerate them for fun! +1) it makes keeping a consistent page style difficult. +2) making a style change or renaming a core-stylesheet would require going + through and editing a large number of files. +3) helps separate the layout and content of the page. + +We're using the bempline templating engine. Not because it's the best or even +really *that good*, but because I wrote it and I like it :) + +Page-content files will have frontmatter in the form: +``` +--- +key=value +--- +``` + +blank lines and comments are acceptable. comments are lines starting with a `#` + +with these keys being common and defined. + +**title** (default: filename) +the `<title>` of the page + +== TODO: THE BELOW IS DREAMING == + +**og_title** (default: page title) +the opengraph title of the webpage + +**og_type** (default: website) +TODO: not currently implemented. everything is a website. + +**og_description** (default: none) +a one or two sentence description of the page. + +```html +<!-- required tags. us an image requried? i do not have one.--> +<meta property="og:title" content="nyble.dev | gone" /> +<meta property="og:type" content="website" /> +<meta property="og:url" content="https://nyble.dev/" /> + +<!-- not required but helps make better cards, etc. --> +<meta property="og:description" content="gone for now. stepped out for a bit." /> +``` \ No newline at end of file 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>") + } +} |