Skip to main content

unifier/daemon/
www.rs

1//! Temp static file store + minimal localhost HTTP server.
2//!
3//! Jan (and other tools) pipe HTML into `unifier serve`; the daemon serves it
4//! from `.daemon/www/` on `127.0.0.1` so reports open in a browser without a
5//! separate static-file stack.
6//!
7//! Layout:
8//! ```text
9//! .daemon/www/<name>           โ€” raw body bytes
10//! .daemon/www/<name>.meta.json โ€” { content_type, created_at, expires_at? }
11//! .daemon/http.port            โ€” bound TCP port
12//! ```
13
14use std::fs;
15use std::io::{Read, Write};
16use std::net::{SocketAddr, TcpListener, TcpStream};
17use std::path::{Path, PathBuf};
18use std::sync::atomic::{AtomicBool, Ordering};
19use std::sync::Arc;
20use std::thread;
21use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
22
23use serde::{Deserialize, Serialize};
24
25use crate::daemon::paths::{http_port_path, www_dir};
26use crate::error::{Error, Result};
27use crate::home::UnifierHome;
28use crate::scope::resolve_under_root;
29
30const DEFAULT_CONTENT_TYPE: &str = "text/html; charset=utf-8";
31const DEFAULT_PORT_ENV: &str = "UNIFIER_HTTP_PORT";
32/// Prefer a stable localhost port when free; fall back to ephemeral.
33const PREFERRED_PORT: u16 = 17355;
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct WwwMeta {
37    pub name: String,
38    pub content_type: String,
39    pub created_at: String,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub expires_at: Option<String>,
42    pub bytes: u64,
43}
44
45/// Validate a publish name: no slashes, no `..`, printable path segment.
46pub fn validate_name(name: &str) -> Result<()> {
47    if name.is_empty() {
48        return Err(Error::msg("web name must not be empty"));
49    }
50    if name.contains('/') || name.contains('\\') || name.contains('\0') {
51        return Err(Error::msg("web name must not contain path separators"));
52    }
53    if name == "." || name == ".." || name.ends_with(".meta.json") {
54        return Err(Error::msg(format!("invalid web name: {name}")));
55    }
56    if !name
57        .chars()
58        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
59    {
60        return Err(Error::msg(
61            "web name may only contain [A-Za-z0-9._-] characters",
62        ));
63    }
64    Ok(())
65}
66
67fn meta_path(www: &Path, name: &str) -> PathBuf {
68    www.join(format!("{name}.meta.json"))
69}
70
71fn body_path(www: &Path, name: &str) -> PathBuf {
72    www.join(name)
73}
74
75fn now_rfc3339() -> String {
76    chrono::Utc::now().to_rfc3339()
77}
78
79fn expires_rfc3339(ttl_secs: u64) -> String {
80    let when = SystemTime::now() + Duration::from_secs(ttl_secs);
81    let secs = when
82        .duration_since(UNIX_EPOCH)
83        .unwrap_or_default()
84        .as_secs() as i64;
85    chrono::DateTime::from_timestamp(secs, 0)
86        .unwrap_or_else(chrono::Utc::now)
87        .to_rfc3339()
88}
89
90fn is_expired(meta: &WwwMeta) -> bool {
91    let Some(exp) = &meta.expires_at else {
92        return false;
93    };
94    match chrono::DateTime::parse_from_rfc3339(exp) {
95        Ok(dt) => dt.with_timezone(&chrono::Utc) < chrono::Utc::now(),
96        Err(_) => false,
97    }
98}
99
100/// Write body + meta under `.daemon/www/<name>`.
101pub fn publish(
102    home: &UnifierHome,
103    name: &str,
104    body: &[u8],
105    content_type: Option<&str>,
106    ttl_secs: Option<u64>,
107) -> Result<WwwMeta> {
108    validate_name(name)?;
109    let www = www_dir(home);
110    fs::create_dir_all(&www)?;
111    // Confine writes under www/
112    let dest = resolve_under_root(&www, name)?;
113    let tmp = www.join(format!(".{name}.tmp"));
114    fs::write(&tmp, body)?;
115    fs::rename(&tmp, &dest)?;
116
117    let meta = WwwMeta {
118        name: name.to_string(),
119        content_type: content_type
120            .unwrap_or(DEFAULT_CONTENT_TYPE)
121            .to_string(),
122        created_at: now_rfc3339(),
123        expires_at: ttl_secs.map(expires_rfc3339),
124        bytes: body.len() as u64,
125    };
126    let meta_json = serde_json::to_string_pretty(&meta)?;
127    let meta_tmp = www.join(format!(".{name}.meta.tmp"));
128    fs::write(&meta_tmp, &meta_json)?;
129    fs::rename(meta_tmp, meta_path(&www, name))?;
130    Ok(meta)
131}
132
133pub fn remove(home: &UnifierHome, name: &str) -> Result<bool> {
134    validate_name(name)?;
135    let www = www_dir(home);
136    let body = body_path(&www, name);
137    let meta = meta_path(&www, name);
138    let had = body.exists() || meta.exists();
139    let _ = fs::remove_file(&body);
140    let _ = fs::remove_file(&meta);
141    Ok(had)
142}
143
144pub fn load_meta(home: &UnifierHome, name: &str) -> Result<Option<WwwMeta>> {
145    validate_name(name)?;
146    let path = meta_path(&www_dir(home), name);
147    if !path.is_file() {
148        return Ok(None);
149    }
150    let text = fs::read_to_string(&path)?;
151    let meta: WwwMeta = serde_json::from_str(&text)?;
152    if is_expired(&meta) {
153        let _ = remove(home, name);
154        return Ok(None);
155    }
156    Ok(Some(meta))
157}
158
159pub fn list(home: &UnifierHome) -> Result<Vec<WwwMeta>> {
160    let www = www_dir(home);
161    if !www.is_dir() {
162        return Ok(vec![]);
163    }
164    let mut out = Vec::new();
165    for entry in fs::read_dir(&www)? {
166        let entry = entry?;
167        let fname = entry.file_name().to_string_lossy().into_owned();
168        let Some(name) = fname.strip_suffix(".meta.json") else {
169            continue;
170        };
171        if let Ok(Some(meta)) = load_meta(home, name) {
172            out.push(meta);
173        }
174    }
175    out.sort_by(|a, b| a.name.cmp(&b.name));
176    Ok(out)
177}
178
179pub fn read_port(home: &UnifierHome) -> Option<u16> {
180    let text = fs::read_to_string(http_port_path(home)).ok()?;
181    text.trim().parse().ok()
182}
183
184pub fn base_url(home: &UnifierHome) -> Option<String> {
185    read_port(home).map(|p| format!("http://127.0.0.1:{p}"))
186}
187
188pub fn entry_url(home: &UnifierHome, name: &str) -> Result<String> {
189    validate_name(name)?;
190    let base = base_url(home).ok_or_else(|| Error::msg("web server port not available"))?;
191    Ok(format!("{base}/{name}"))
192}
193
194/// Wrap a report body in Unifier chrome for interactive Jan HTML export.
195pub fn wrap_html(title: &str, body: &str) -> String {
196    format!(
197        r#"<!DOCTYPE html>
198<html lang="en">
199<head>
200<meta charset="utf-8"/>
201<meta name="viewport" content="width=device-width, initial-scale=1"/>
202<title>{title}</title>
203<style>
204  :root {{ color-scheme: light; --ink:#1a1f1c; --muted:#5c6b63; --bg:#f3f6f2; --accent:#2f6f4e; --card:#fff; }}
205  * {{ box-sizing: border-box; }}
206  body {{ margin:0; font:16px/1.55 "IBM Plex Sans","Source Sans 3",system-ui,sans-serif;
207         background:
208           radial-gradient(900px 480px at 0% 0%, #d7e8de 0%, transparent 60%),
209           radial-gradient(700px 420px at 100% 10%, #e8efe4 0%, transparent 55%),
210           var(--bg);
211         color:var(--ink); min-height:100vh; }}
212  header {{ padding:1.25rem 1.5rem; border-bottom:1px solid #d5ddd7;
213            backdrop-filter: blur(6px); background:rgba(243,246,242,0.85);
214            display:flex; align-items:baseline; gap:0.75rem; }}
215  header .brand {{ font-family:"IBM Plex Serif","Source Serif 4",Georgia,serif;
216                   font-size:1.35rem; font-weight:600; letter-spacing:-0.02em; }}
217  header .title {{ color:var(--muted); font-size:0.95rem; }}
218  header a {{ color:var(--accent); text-decoration:none; margin-left:auto; font-size:0.9rem; }}
219  main {{ max-width:56rem; margin:1.5rem auto 3rem; padding:1.25rem 1.5rem;
220          background:var(--card); border:1px solid #d5ddd7; border-radius:10px;
221          box-shadow:0 10px 30px rgba(26,31,28,0.04); }}
222</style>
223</head>
224<body>
225<header>
226  <div class="brand">Unifier</div>
227  <div class="title">{title}</div>
228  <a href="/">all reports</a>
229</header>
230<main>
231{body}
232</main>
233</body>
234</html>
235"#,
236        title = html_escape(title),
237        body = body
238    )
239}
240
241fn preferred_port() -> u16 {
242    std::env::var(DEFAULT_PORT_ENV)
243        .ok()
244        .and_then(|s| s.parse().ok())
245        .unwrap_or(PREFERRED_PORT)
246}
247
248/// Spawn the localhost HTTP listener. Returns the bound port.
249/// `http_activity` is set true on each accepted request so the daemon idle
250/// timer stays fresh while browsers are hitting temp reports.
251pub fn spawn(
252    home: UnifierHome,
253    shutdown: Arc<AtomicBool>,
254    http_activity: Arc<AtomicBool>,
255) -> Result<u16> {
256    let preferred = preferred_port();
257    let listener = match TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], preferred))) {
258        Ok(l) => l,
259        Err(_) => TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0)))?,
260    };
261    listener.set_nonblocking(true)?;
262    let port = listener.local_addr()?.port();
263    fs::create_dir_all(crate::daemon::paths::daemon_dir(&home))?;
264    fs::write(http_port_path(&home), format!("{port}\n"))?;
265    fs::create_dir_all(www_dir(&home))?;
266
267    thread::spawn(move || {
268        let mut last_gc = Instant::now();
269        while !shutdown.load(Ordering::Relaxed) {
270            match listener.accept() {
271                Ok((stream, _)) => {
272                    http_activity.store(true, Ordering::Relaxed);
273                    if let Err(e) = handle_http(stream, &home) {
274                        eprintln!("www http error: {e}");
275                    }
276                }
277                Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
278                    if last_gc.elapsed() > Duration::from_secs(30) {
279                        let _ = gc_expired(&home);
280                        last_gc = Instant::now();
281                    }
282                    thread::sleep(Duration::from_millis(25));
283                }
284                Err(e) => {
285                    eprintln!("www accept error: {e}");
286                    thread::sleep(Duration::from_millis(100));
287                }
288            }
289        }
290        let _ = fs::remove_file(http_port_path(&home));
291    });
292
293    Ok(port)
294}
295
296fn gc_expired(home: &UnifierHome) -> Result<()> {
297    for meta in list(home)? {
298        // list() already drops expired via load_meta
299        let _ = meta;
300    }
301    Ok(())
302}
303
304fn handle_http(mut stream: TcpStream, home: &UnifierHome) -> Result<()> {
305    stream.set_read_timeout(Some(Duration::from_secs(5)))?;
306    stream.set_write_timeout(Some(Duration::from_secs(5)))?;
307
308    let mut buf = [0u8; 4096];
309    let n = stream.read(&mut buf)?;
310    if n == 0 {
311        return Ok(());
312    }
313    let req = String::from_utf8_lossy(&buf[..n]);
314    let mut lines = req.lines();
315    let request_line = lines.next().unwrap_or("");
316    let mut parts = request_line.split_whitespace();
317    let method = parts.next().unwrap_or("");
318    let path = parts.next().unwrap_or("/");
319
320    if method != "GET" && method != "HEAD" {
321        write_response(&mut stream, 405, "text/plain; charset=utf-8", b"method not allowed")?;
322        return Ok(());
323    }
324
325    if path == "/" || path == "/index.html" {
326        let body = index_html(home)?;
327        write_response(
328            &mut stream,
329            200,
330            "text/html; charset=utf-8",
331            if method == "HEAD" { b"" } else { body.as_bytes() },
332        )?;
333        return Ok(());
334    }
335
336    let name = path.trim_start_matches('/');
337    if name.contains('/') || validate_name(name).is_err() {
338        write_response(&mut stream, 404, "text/plain; charset=utf-8", b"not found")?;
339        return Ok(());
340    }
341
342    let Some(meta) = load_meta(home, name)? else {
343        write_response(&mut stream, 404, "text/plain; charset=utf-8", b"not found")?;
344        return Ok(());
345    };
346
347    let body_path = body_path(&www_dir(home), name);
348    let body = fs::read(&body_path).unwrap_or_default();
349    write_response(
350        &mut stream,
351        200,
352        &meta.content_type,
353        if method == "HEAD" { b"" } else { &body },
354    )?;
355    Ok(())
356}
357
358fn index_html(home: &UnifierHome) -> Result<String> {
359    let entries = list(home)?;
360    let mut items = String::new();
361    for e in &entries {
362        items.push_str(&format!(
363            "<li><a href=\"/{}\">{}</a> <span class=\"meta\">{} ยท {} bytes</span></li>\n",
364            html_escape(&e.name),
365            html_escape(&e.name),
366            html_escape(&e.content_type),
367            e.bytes
368        ));
369    }
370    if items.is_empty() {
371        items.push_str("<li class=\"empty\">No files yet. Pipe HTML with <code>unifier serve</code>.</li>\n");
372    }
373    Ok(format!(
374        r#"<!DOCTYPE html>
375<html lang="en">
376<head>
377<meta charset="utf-8"/>
378<meta name="viewport" content="width=device-width, initial-scale=1"/>
379<title>Unifier</title>
380<style>
381  :root {{ color-scheme: light; --ink:#1a1f1c; --muted:#5c6b63; --bg:#f3f6f2; --accent:#2f6f4e; }}
382  body {{ margin:0; font:16px/1.5 "IBM Plex Sans", "Source Sans 3", system-ui, sans-serif;
383         background: radial-gradient(1200px 600px at 10% -10%, #dfece4, var(--bg)); color:var(--ink); }}
384  main {{ max-width:42rem; margin:3rem auto; padding:0 1.25rem; }}
385  h1 {{ font-family:"IBM Plex Serif","Source Serif 4",Georgia,serif; font-weight:600; letter-spacing:-0.02em; }}
386  a {{ color:var(--accent); }}
387  ul {{ list-style:none; padding:0; }}
388  li {{ padding:0.55rem 0; border-bottom:1px solid #d5ddd7; }}
389  .meta {{ color:var(--muted); font-size:0.85rem; margin-left:0.5rem; }}
390  .empty {{ color:var(--muted); border:0; }}
391  code {{ font-family:ui-monospace,monospace; font-size:0.9em; }}
392</style>
393</head>
394<body>
395<main>
396  <h1>Unifier</h1>
397  <p>Temp files served from this daemon.</p>
398  <ul>
399{items}  </ul>
400</main>
401</body>
402</html>
403"#
404    ))
405}
406
407fn html_escape(s: &str) -> String {
408    s.replace('&', "&amp;")
409        .replace('<', "&lt;")
410        .replace('>', "&gt;")
411        .replace('"', "&quot;")
412}
413
414fn write_response(stream: &mut TcpStream, status: u16, content_type: &str, body: &[u8]) -> Result<()> {
415    let reason = match status {
416        200 => "OK",
417        404 => "Not Found",
418        405 => "Method Not Allowed",
419        _ => "Error",
420    };
421    let header = format!(
422        "HTTP/1.1 {status} {reason}\r\n\
423         Content-Type: {content_type}\r\n\
424         Content-Length: {}\r\n\
425         Connection: close\r\n\
426         Cache-Control: no-store\r\n\
427         Access-Control-Allow-Origin: *\r\n\
428         \r\n",
429        body.len()
430    );
431    stream.write_all(header.as_bytes())?;
432    if !body.is_empty() {
433        stream.write_all(body)?;
434    }
435    stream.flush()?;
436    Ok(())
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442    use tempfile::tempdir;
443
444    fn home(dir: &Path) -> UnifierHome {
445        UnifierHome::resolve(Some(dir.to_path_buf()), None).unwrap()
446    }
447
448    #[test]
449    fn publish_list_remove() {
450        let tmp = tempdir().unwrap();
451        let h = home(tmp.path());
452        publish(&h, "report", b"<h1>hi</h1>", None, None).unwrap();
453        let entries = list(&h).unwrap();
454        assert_eq!(entries.len(), 1);
455        assert_eq!(entries[0].name, "report");
456        assert!(remove(&h, "report").unwrap());
457        assert!(list(&h).unwrap().is_empty());
458    }
459
460    #[test]
461    fn rejects_bad_names() {
462        assert!(validate_name("../x").is_err());
463        assert!(validate_name("a/b").is_err());
464        assert!(validate_name("").is_err());
465    }
466
467    #[test]
468    fn ttl_expires() {
469        let tmp = tempdir().unwrap();
470        let h = home(tmp.path());
471        let mut meta = publish(&h, "old", b"x", Some("text/plain"), Some(1)).unwrap();
472        // Force expiry in the past
473        meta.expires_at = Some("2000-01-01T00:00:00Z".into());
474        let www = www_dir(&h);
475        fs::write(meta_path(&www, "old"), serde_json::to_string(&meta).unwrap()).unwrap();
476        assert!(load_meta(&h, "old").unwrap().is_none());
477    }
478}