aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorRuben Beltran del Rio <jj@r.bdr.sh>2026-04-30 15:17:30 +0200
committerRuben Beltran del Rio <jj@r.bdr.sh>2026-04-30 15:37:23 +0200
commit8cc11c52a88ec78d175b3ec8de854916f631caab (patch)
tree4ad0bc1664a97418d0475b0b730c54d874953348 /src
Initial build
Diffstat (limited to 'src')
-rw-r--r--src/cleanup.rs72
-rw-r--r--src/main.rs35
-rw-r--r--src/render.rs102
-rw-r--r--src/serve.rs152
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("&amp;"),
+ '<' => out.push_str("&lt;"),
+ '>' => out.push_str("&gt;"),
+ '"' => out.push_str("&quot;"),
+ '\'' => out.push_str("&#39;"),
+ 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("&lt;script&gt;.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");
+ }
+}