From 8cc11c52a88ec78d175b3ec8de854916f631caab Mon Sep 17 00:00:00 2001 From: Ruben Beltran del Rio Date: Thu, 30 Apr 2026 15:17:30 +0200 Subject: Initial build --- src/cleanup.rs | 72 +++++++++++++++++++++++++++ src/main.rs | 35 +++++++++++++ src/render.rs | 102 ++++++++++++++++++++++++++++++++++++++ src/serve.rs | 152 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 361 insertions(+) create mode 100644 src/cleanup.rs create mode 100644 src/main.rs create mode 100644 src/render.rs create mode 100644 src/serve.rs (limited to 'src') 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 = 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 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("\n\n"); + push_escaped(&mut out, title); + out.push_str("\n\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("
");
+    push_escaped(&mut out, text);
+    out.push_str("
"); + 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("x.rs")); + } + + #[test] + fn page_escapes_filename() { + let html = page(Path::new("/srv/