diff options
| author | Ruben Beltran del Rio <jj@r.bdr.sh> | 2026-04-30 15:17:30 +0200 |
|---|---|---|
| committer | Ruben Beltran del Rio <jj@r.bdr.sh> | 2026-04-30 15:37:23 +0200 |
| commit | 8cc11c52a88ec78d175b3ec8de854916f631caab (patch) | |
| tree | 4ad0bc1664a97418d0475b0b730c54d874953348 /src | |
Initial build
Diffstat (limited to 'src')
| -rw-r--r-- | src/cleanup.rs | 72 | ||||
| -rw-r--r-- | src/main.rs | 35 | ||||
| -rw-r--r-- | src/render.rs | 102 | ||||
| -rw-r--r-- | src/serve.rs | 152 |
4 files changed, 361 insertions, 0 deletions
diff --git a/src/cleanup.rs b/src/cleanup.rs new file mode 100644 index 0000000..4856400 --- /dev/null +++ b/src/cleanup.rs @@ -0,0 +1,72 @@ +use std::fs; +use std::path::Path; +use std::process::ExitCode; +use std::time::{Duration, SystemTime}; + +const ONE_WEEK: Duration = Duration::from_secs(7 * 24 * 60 * 60); + +pub fn run(dir: &str) -> ExitCode { + let entries = match fs::read_dir(Path::new(dir)) { + Ok(e) => e, + Err(e) => { + eprintln!("estampa: cannot read directory '{dir}': {e}"); + return ExitCode::from(1); + } + }; + + let now = SystemTime::now(); + let mut had_error = false; + + for entry in entries { + let entry = match entry { + Ok(e) => e, + Err(e) => { + eprintln!("estampa: cannot read directory entry: {e}"); + had_error = true; + continue; + } + }; + + let path = entry.path(); + + // symlink_metadata so we never follow links out of the dir + let metadata = match entry.path().symlink_metadata() { + Ok(m) => m, + Err(e) => { + eprintln!("estampa: cannot stat '{}': {e}", path.display()); + had_error = true; + continue; + } + }; + + if !metadata.is_file() { + continue; + } + + let modified = match metadata.modified() { + Ok(m) => m, + Err(e) => { + eprintln!("estampa: cannot read mtime for '{}': {e}", path.display()); + had_error = true; + continue; + } + }; + + let Ok(age) = now.duration_since(modified) else { + continue; + }; + + if age > ONE_WEEK + && let Err(e) = fs::remove_file(&path) + { + eprintln!("estampa: cannot remove '{}': {e}", path.display()); + had_error = true; + } + } + + if had_error { + ExitCode::from(1) + } else { + ExitCode::SUCCESS + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..223fd24 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,35 @@ +use std::env; +use std::process::ExitCode; + +mod cleanup; +mod render; +mod serve; + +fn main() -> ExitCode { + let args: Vec<String> = env::args().collect(); + let command = args.get(1).map(String::as_str); + + match command { + None => serve::run(), + Some("--run") => { + if let Some(dir) = args.get(2) { + cleanup::run(dir) + } else { + eprintln!("estampa: --run requires a directory argument"); + print_usage(); + ExitCode::from(2) + } + } + Some(other) => { + eprintln!("estampa: unknown argument '{other}'"); + print_usage(); + ExitCode::from(2) + } + } +} + +fn print_usage() { + eprintln!("usage:"); + eprintln!(" estampa serve a paste via CGI (DOCUMENT_ROOT + DOCUMENT_URI)"); + eprintln!(" estampa --run <directory> delete files older than one week"); +} diff --git a/src/render.rs b/src/render.rs new file mode 100644 index 0000000..8e366dd --- /dev/null +++ b/src/render.rs @@ -0,0 +1,102 @@ +use std::path::Path; + +use syntect::highlighting::ThemeSet; +use syntect::html::highlighted_html_for_string; +use syntect::parsing::SyntaxSet; + +const THEME: &str = "base16-ocean.dark"; +const STYLE: &str = "html,body{margin:0;background:#2b303b}\ +pre{margin:0;padding:1rem;font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;overflow:auto}"; + +pub fn page(path: &Path, text: &str) -> String { + let title = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("paste"); + + let body = highlight(path, text); + + let mut out = String::with_capacity(body.len() + 256); + out.push_str("<!doctype html>\n<meta charset=\"utf-8\">\n<title>"); + push_escaped(&mut out, title); + out.push_str("</title>\n<style>"); + out.push_str(STYLE); + out.push_str("</style>\n"); + out.push_str(&body); + out.push('\n'); + out +} + +fn highlight(path: &Path, text: &str) -> String { + let syntaxes = SyntaxSet::load_defaults_newlines(); + let themes = ThemeSet::load_defaults(); + + let syntax = path + .extension() + .and_then(|e| e.to_str()) + .and_then(|e| syntaxes.find_syntax_by_extension(e)) + .or_else(|| syntaxes.find_syntax_by_first_line(text)) + .unwrap_or_else(|| syntaxes.find_syntax_plain_text()); + + let Some(theme) = themes.themes.get(THEME) else { + return fallback_pre(text); + }; + + match highlighted_html_for_string(text, &syntaxes, syntax, theme) { + Ok(html) => html, + Err(_) => fallback_pre(text), + } +} + +fn fallback_pre(text: &str) -> String { + let mut out = String::with_capacity(text.len() + 16); + out.push_str("<pre>"); + push_escaped(&mut out, text); + out.push_str("</pre>"); + out +} + +fn push_escaped(out: &mut String, s: &str) { + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + other => out.push(other), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn page_contains_filename_in_title() { + let html = page(Path::new("/srv/x.rs"), "fn main() {}\n"); + assert!(html.contains("<title>x.rs</title>")); + } + + #[test] + fn page_escapes_filename() { + let html = page(Path::new("/srv/<script>.txt"), "hi"); + assert!(html.contains("<script>.txt")); + assert!(!html.contains("<title><script>")); + } + + #[test] + fn page_emits_pre_block() { + let html = page(Path::new("/srv/x.txt"), "hello"); + assert!(html.contains("<pre")); + assert!(html.contains("hello")); + } + + #[test] + fn page_highlights_known_extension() { + // Rust syntax should produce styled spans, not just a plain <pre>hello</pre>. + let html = page(Path::new("/srv/x.rs"), "fn main() {}\n"); + assert!(html.contains("<span")); + } +} diff --git a/src/serve.rs b/src/serve.rs new file mode 100644 index 0000000..c5d2efd --- /dev/null +++ b/src/serve.rs @@ -0,0 +1,152 @@ +use std::env; +use std::fs; +use std::io::{self, Write}; +use std::path::{Component, Path, PathBuf}; +use std::process::ExitCode; + +use crate::render; + +pub fn run() -> ExitCode { + let Ok(document_root) = env::var("DOCUMENT_ROOT") else { + return respond_error(500, "DOCUMENT_ROOT is not set"); + }; + + let Some(raw_request) = request_path() else { + return respond_error(500, "no request path available"); + }; + + let request = strip_query(&raw_request); + + let Some(target) = safe_join(Path::new(&document_root), request) else { + return respond_error(404, "not found"); + }; + + let Ok(metadata) = fs::metadata(&target) else { + return respond_error(404, "not found"); + }; + + if !metadata.is_file() { + return respond_error(404, "not found"); + } + + let Ok(bytes) = fs::read(&target) else { + return respond_error(500, "read error"); + }; + + let Ok(text) = String::from_utf8(bytes) else { + return respond_error(404, "not found"); + }; + + let html = render::page(&target, &text); + let body = html.as_bytes(); + + let header = format!( + "Status: 200 OK\nContent-Type: text/html; charset=utf-8\nContent-Length: {}\n\n", + body.len() + ); + + let stdout = io::stdout(); + let mut handle = stdout.lock(); + if handle.write_all(header.as_bytes()).is_err() { + return ExitCode::from(1); + } + if handle.write_all(body).is_err() { + return ExitCode::from(1); + } + + ExitCode::SUCCESS +} + +fn request_path() -> Option<String> { + env::var("DOCUMENT_URI") + .or_else(|_| env::var("PATH_INFO")) + .ok() +} + +fn strip_query(s: &str) -> &str { + let s = s.split('?').next().unwrap_or(s); + s.split('#').next().unwrap_or(s) +} + +// Only top-level, non-dotfile names are servable: rejects paths with +// `..`, with subdirectories, and with names starting with `.`. +fn safe_join(root: &Path, request: &str) -> Option<PathBuf> { + let mut name = None; + for component in Path::new(request).components() { + match component { + Component::Normal(c) => { + if name.is_some() { + return None; + } + if c.as_encoded_bytes().first() == Some(&b'.') { + return None; + } + name = Some(c); + } + Component::RootDir | Component::CurDir => {} + Component::ParentDir | Component::Prefix(_) => return None, + } + } + name.map(|n| root.join(n)) +} + +fn respond_error(code: u16, message: &str) -> ExitCode { + let status = match code { + 400 => "400 Bad Request", + 403 => "403 Forbidden", + 404 => "404 Not Found", + _ => "500 Internal Server Error", + }; + + println!("Status: {status}"); + println!("Content-Type: text/plain; charset=utf-8"); + println!(); + println!("{message}"); + + ExitCode::SUCCESS +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn safe_join_normal() { + let r = safe_join(Path::new("/srv/p"), "/x.txt").unwrap(); + assert_eq!(r, PathBuf::from("/srv/p/x.txt")); + } + + #[test] + fn safe_join_rejects_subdirectories() { + assert!(safe_join(Path::new("/srv/p"), "/sub/x.txt").is_none()); + assert!(safe_join(Path::new("/srv/p"), "/a/b/c").is_none()); + } + + #[test] + fn safe_join_rejects_dotfiles() { + assert!(safe_join(Path::new("/srv/p"), "/.ssh").is_none()); + assert!(safe_join(Path::new("/srv/p"), "/.bashrc").is_none()); + assert!(safe_join(Path::new("/srv/p"), ".env").is_none()); + } + + #[test] + fn safe_join_rejects_parent() { + assert!(safe_join(Path::new("/srv/p"), "/../etc/passwd").is_none()); + assert!(safe_join(Path::new("/srv/p"), "../etc/passwd").is_none()); + assert!(safe_join(Path::new("/srv/p"), "/sub/../../etc").is_none()); + } + + #[test] + fn safe_join_rejects_empty() { + assert!(safe_join(Path::new("/srv/p"), "").is_none()); + assert!(safe_join(Path::new("/srv/p"), "/").is_none()); + } + + #[test] + fn strip_query_removes_query_and_fragment() { + assert_eq!(strip_query("/x.txt?foo=bar"), "/x.txt"); + assert_eq!(strip_query("/x.txt#frag"), "/x.txt"); + assert_eq!(strip_query("/x.txt?a=b#frag"), "/x.txt"); + assert_eq!(strip_query("/x.txt"), "/x.txt"); + } +} |