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. Persistent keys are also readable at `/keys/…`
6//! so HTML boards can deep-link into results.
7//!
8//! Layout:
9//! ```text
10//! .daemon/www/<name>           — raw body bytes
11//! .daemon/www/<name>.meta.json — { content_type, created_at, expires_at? }
12//! .daemon/http.port            — bound TCP port
13//! GET /keys/<key>              — live key value from HotStore
14//! GET /keys[/prefix]           — list present keys (optional prefix)
15//! ```
16
17use std::fs;
18use std::io::{Read, Write};
19use std::net::{SocketAddr, TcpListener, TcpStream};
20use std::path::{Path, PathBuf};
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::sync::{Arc, Mutex};
23use std::thread;
24use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
25
26use serde::{Deserialize, Serialize};
27
28use crate::daemon::paths::{http_port_path, www_dir};
29use crate::error::{Error, Result};
30use crate::home::UnifierHome;
31use crate::scope::resolve_under_root;
32use crate::store::{validate_key, HotStore};
33
34const DEFAULT_CONTENT_TYPE: &str = "text/html; charset=utf-8";
35const DEFAULT_PORT_ENV: &str = "UNIFIER_HTTP_PORT";
36/// Prefer a stable localhost port when free; fall back to ephemeral.
37const PREFERRED_PORT: u16 = 17355;
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct WwwMeta {
41    pub name: String,
42    pub content_type: String,
43    pub created_at: String,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub expires_at: Option<String>,
46    pub bytes: u64,
47}
48
49/// Validate a publish name: no slashes, no `..`, printable path segment.
50pub fn validate_name(name: &str) -> Result<()> {
51    if name.is_empty() {
52        return Err(Error::msg("web name must not be empty"));
53    }
54    if name.contains('/') || name.contains('\\') || name.contains('\0') {
55        return Err(Error::msg("web name must not contain path separators"));
56    }
57    if name == "." || name == ".." || name.ends_with(".meta.json") {
58        return Err(Error::msg(format!("invalid web name: {name}")));
59    }
60    if !name
61        .chars()
62        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
63    {
64        return Err(Error::msg(
65            "web name may only contain [A-Za-z0-9._-] characters",
66        ));
67    }
68    Ok(())
69}
70
71fn meta_path(www: &Path, name: &str) -> PathBuf {
72    www.join(format!("{name}.meta.json"))
73}
74
75fn body_path(www: &Path, name: &str) -> PathBuf {
76    www.join(name)
77}
78
79fn now_rfc3339() -> String {
80    chrono::Utc::now().to_rfc3339()
81}
82
83fn expires_rfc3339(ttl_secs: u64) -> String {
84    let when = SystemTime::now() + Duration::from_secs(ttl_secs);
85    let secs = when
86        .duration_since(UNIX_EPOCH)
87        .unwrap_or_default()
88        .as_secs() as i64;
89    chrono::DateTime::from_timestamp(secs, 0)
90        .unwrap_or_else(chrono::Utc::now)
91        .to_rfc3339()
92}
93
94fn is_expired(meta: &WwwMeta) -> bool {
95    let Some(exp) = &meta.expires_at else {
96        return false;
97    };
98    match chrono::DateTime::parse_from_rfc3339(exp) {
99        Ok(dt) => dt.with_timezone(&chrono::Utc) < chrono::Utc::now(),
100        Err(_) => false,
101    }
102}
103
104/// Write body + meta under `.daemon/www/<name>`.
105pub fn publish(
106    home: &UnifierHome,
107    name: &str,
108    body: &[u8],
109    content_type: Option<&str>,
110    ttl_secs: Option<u64>,
111) -> Result<WwwMeta> {
112    validate_name(name)?;
113    let www = www_dir(home);
114    fs::create_dir_all(&www)?;
115    // Confine writes under www/
116    let dest = resolve_under_root(&www, name)?;
117    let tmp = www.join(format!(".{name}.tmp"));
118    fs::write(&tmp, body)?;
119    fs::rename(&tmp, &dest)?;
120
121    let meta = WwwMeta {
122        name: name.to_string(),
123        content_type: content_type.unwrap_or(DEFAULT_CONTENT_TYPE).to_string(),
124        created_at: now_rfc3339(),
125        expires_at: ttl_secs.map(expires_rfc3339),
126        bytes: body.len() as u64,
127    };
128    let meta_json = serde_json::to_string_pretty(&meta)?;
129    let meta_tmp = www.join(format!(".{name}.meta.tmp"));
130    fs::write(&meta_tmp, &meta_json)?;
131    fs::rename(meta_tmp, meta_path(&www, name))?;
132    Ok(meta)
133}
134
135pub fn remove(home: &UnifierHome, name: &str) -> Result<bool> {
136    validate_name(name)?;
137    let www = www_dir(home);
138    let body = body_path(&www, name);
139    let meta = meta_path(&www, name);
140    let had = body.exists() || meta.exists();
141    let _ = fs::remove_file(&body);
142    let _ = fs::remove_file(&meta);
143    Ok(had)
144}
145
146pub fn load_meta(home: &UnifierHome, name: &str) -> Result<Option<WwwMeta>> {
147    validate_name(name)?;
148    let path = meta_path(&www_dir(home), name);
149    if !path.is_file() {
150        return Ok(None);
151    }
152    let text = fs::read_to_string(&path)?;
153    let meta: WwwMeta = serde_json::from_str(&text)?;
154    if is_expired(&meta) {
155        let _ = remove(home, name);
156        return Ok(None);
157    }
158    Ok(Some(meta))
159}
160
161pub fn list(home: &UnifierHome) -> Result<Vec<WwwMeta>> {
162    let www = www_dir(home);
163    if !www.is_dir() {
164        return Ok(vec![]);
165    }
166    let mut out = Vec::new();
167    for entry in fs::read_dir(&www)? {
168        let entry = entry?;
169        let fname = entry.file_name().to_string_lossy().into_owned();
170        let Some(name) = fname.strip_suffix(".meta.json") else {
171            continue;
172        };
173        if let Ok(Some(meta)) = load_meta(home, name) {
174            out.push(meta);
175        }
176    }
177    out.sort_by(|a, b| a.name.cmp(&b.name));
178    Ok(out)
179}
180
181pub fn read_port(home: &UnifierHome) -> Option<u16> {
182    let text = fs::read_to_string(http_port_path(home)).ok()?;
183    text.trim().parse().ok()
184}
185
186pub fn base_url(home: &UnifierHome) -> Option<String> {
187    read_port(home).map(|p| format!("http://127.0.0.1:{p}"))
188}
189
190pub fn entry_url(home: &UnifierHome, name: &str) -> Result<String> {
191    validate_name(name)?;
192    let base = base_url(home).ok_or_else(|| Error::msg("web server port not available"))?;
193    Ok(format!("{base}/{name}"))
194}
195
196/// HTTP URL for a persistent key (`/keys/<key>`).
197pub fn key_url(home: &UnifierHome, key: &str) -> Result<String> {
198    validate_key(key)?;
199    let base = base_url(home).ok_or_else(|| Error::msg("web server port not available"))?;
200    Ok(format!("{base}/keys/{key}"))
201}
202
203/// Wrap a report body in Unifier chrome for interactive Jan HTML export.
204pub fn wrap_html(title: &str, body: &str) -> String {
205    format!(
206        r#"<!DOCTYPE html>
207<html lang="en">
208<head>
209<meta charset="utf-8"/>
210<meta name="viewport" content="width=device-width, initial-scale=1"/>
211<title>{title}</title>
212<style>
213  :root {{ color-scheme: light; --ink:#1a1f1c; --muted:#5c6b63; --bg:#f3f6f2; --accent:#2f6f4e; --card:#fff; }}
214  * {{ box-sizing: border-box; }}
215  body {{ margin:0; font:16px/1.55 "IBM Plex Sans","Source Sans 3",system-ui,sans-serif;
216         background:
217           radial-gradient(900px 480px at 0% 0%, #d7e8de 0%, transparent 60%),
218           radial-gradient(700px 420px at 100% 10%, #e8efe4 0%, transparent 55%),
219           var(--bg);
220         color:var(--ink); min-height:100vh; }}
221  header {{ padding:1.25rem 1.5rem; border-bottom:1px solid #d5ddd7;
222            backdrop-filter: blur(6px); background:rgba(243,246,242,0.85);
223            display:flex; align-items:baseline; gap:0.75rem; }}
224  header .brand {{ font-family:"IBM Plex Serif","Source Serif 4",Georgia,serif;
225                   font-size:1.35rem; font-weight:600; letter-spacing:-0.02em; }}
226  header .title {{ color:var(--muted); font-size:0.95rem; }}
227  header a {{ color:var(--accent); text-decoration:none; margin-left:auto; font-size:0.9rem; }}
228  main {{ max-width:56rem; margin:1.5rem auto 3rem; padding:1.25rem 1.5rem;
229          background:var(--card); border:1px solid #d5ddd7; border-radius:10px;
230          box-shadow:0 10px 30px rgba(26,31,28,0.04); }}
231</style>
232</head>
233<body>
234<header>
235  <div class="brand">Unifier</div>
236  <div class="title">{title}</div>
237  <a href="/">all reports</a>
238</header>
239<main>
240{body}
241</main>
242</body>
243</html>
244"#,
245        title = html_escape(title),
246        body = body
247    )
248}
249
250fn preferred_port() -> u16 {
251    std::env::var(DEFAULT_PORT_ENV)
252        .ok()
253        .and_then(|s| s.parse().ok())
254        .unwrap_or(PREFERRED_PORT)
255}
256
257/// Spawn the localhost HTTP listener. Returns the bound port.
258/// `http_activity` is set true on each accepted request so the daemon idle
259/// timer stays fresh while browsers are hitting temp reports.
260pub fn spawn(
261    home: UnifierHome,
262    store: Arc<Mutex<HotStore>>,
263    shutdown: Arc<AtomicBool>,
264    http_activity: Arc<AtomicBool>,
265) -> Result<u16> {
266    let preferred = preferred_port();
267    let listener = match TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], preferred))) {
268        Ok(l) => l,
269        Err(_) => TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0)))?,
270    };
271    listener.set_nonblocking(true)?;
272    let port = listener.local_addr()?.port();
273    fs::create_dir_all(crate::daemon::paths::daemon_dir(&home))?;
274    fs::write(http_port_path(&home), format!("{port}\n"))?;
275    fs::create_dir_all(www_dir(&home))?;
276
277    thread::spawn(move || {
278        let mut last_gc = Instant::now();
279        while !shutdown.load(Ordering::Relaxed) {
280            match listener.accept() {
281                Ok((stream, _)) => {
282                    http_activity.store(true, Ordering::Relaxed);
283                    if let Err(e) = handle_http(stream, &home, &store) {
284                        eprintln!("www http error: {e}");
285                    }
286                }
287                Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
288                    if last_gc.elapsed() > Duration::from_secs(30) {
289                        let _ = gc_expired(&home);
290                        last_gc = Instant::now();
291                    }
292                    thread::sleep(Duration::from_millis(25));
293                }
294                Err(e) => {
295                    eprintln!("www accept error: {e}");
296                    thread::sleep(Duration::from_millis(100));
297                }
298            }
299        }
300        let _ = fs::remove_file(http_port_path(&home));
301    });
302
303    Ok(port)
304}
305
306fn gc_expired(home: &UnifierHome) -> Result<()> {
307    for meta in list(home)? {
308        // list() already drops expired via load_meta
309        let _ = meta;
310    }
311    Ok(())
312}
313
314fn handle_http(
315    mut stream: TcpStream,
316    home: &UnifierHome,
317    store: &Arc<Mutex<HotStore>>,
318) -> Result<()> {
319    stream.set_read_timeout(Some(Duration::from_secs(5)))?;
320    stream.set_write_timeout(Some(Duration::from_secs(5)))?;
321
322    let mut buf = [0u8; 4096];
323    let n = stream.read(&mut buf)?;
324    if n == 0 {
325        return Ok(());
326    }
327    let req = String::from_utf8_lossy(&buf[..n]);
328    let mut lines = req.lines();
329    let request_line = lines.next().unwrap_or("");
330    let mut parts = request_line.split_whitespace();
331    let method = parts.next().unwrap_or("");
332    let raw_path = parts.next().unwrap_or("/");
333    let path = raw_path.split('?').next().unwrap_or("/");
334    let want_html = accept_prefers_html(&req);
335
336    if method != "GET" && method != "HEAD" {
337        write_response(
338            &mut stream,
339            405,
340            "text/plain; charset=utf-8",
341            b"method not allowed",
342        )?;
343        return Ok(());
344    }
345
346    if path == "/" || path == "/index.html" {
347        let body = index_html(home)?;
348        write_response(
349            &mut stream,
350            200,
351            "text/html; charset=utf-8",
352            if method == "HEAD" {
353                b""
354            } else {
355                body.as_bytes()
356            },
357        )?;
358        return Ok(());
359    }
360
361    if path == "/keys" || path == "/keys/" || path.starts_with("/keys/") {
362        return handle_keys_http(&mut stream, method, path, want_html, store);
363    }
364
365    let name = path.trim_start_matches('/');
366    if name.contains('/') || validate_name(name).is_err() {
367        write_response(&mut stream, 404, "text/plain; charset=utf-8", b"not found")?;
368        return Ok(());
369    }
370
371    let Some(meta) = load_meta(home, name)? else {
372        write_response(&mut stream, 404, "text/plain; charset=utf-8", b"not found")?;
373        return Ok(());
374    };
375
376    let body_path = body_path(&www_dir(home), name);
377    let body = fs::read(&body_path).unwrap_or_default();
378    write_response(
379        &mut stream,
380        200,
381        &meta.content_type,
382        if method == "HEAD" { b"" } else { &body },
383    )?;
384    Ok(())
385}
386
387fn accept_prefers_html(req: &str) -> bool {
388    for line in req.lines().skip(1) {
389        if line.is_empty() {
390            break;
391        }
392        let lower = line.to_ascii_lowercase();
393        if let Some(rest) = lower.strip_prefix("accept:") {
394            let v = rest.trim();
395            let html = v.find("text/html");
396            let json = v.find("application/json");
397            return match (html, json) {
398                (Some(h), Some(j)) => h < j,
399                (Some(_), None) => true,
400                _ => false,
401            };
402        }
403    }
404    false
405}
406
407fn percent_decode_path(s: &str) -> String {
408    let bytes = s.as_bytes();
409    let mut out = Vec::with_capacity(bytes.len());
410    let mut i = 0;
411    while i < bytes.len() {
412        if bytes[i] == b'%' && i + 2 < bytes.len() {
413            if let (Ok(hi), Ok(lo)) = (
414                u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 2]).unwrap_or(""), 16),
415                u8::from_str_radix(std::str::from_utf8(&bytes[i + 2..i + 3]).unwrap_or(""), 16),
416            ) {
417                out.push((hi << 4) | lo);
418                i += 3;
419                continue;
420            }
421        }
422        out.push(bytes[i]);
423        i += 1;
424    }
425    String::from_utf8_lossy(&out).into_owned()
426}
427
428fn guess_key_content_type(value: &str) -> &'static str {
429    let t = value.trim_start();
430    if (t.starts_with('{') && t.ends_with('}')) || (t.starts_with('[') && t.ends_with(']')) {
431        "application/json; charset=utf-8"
432    } else {
433        "text/plain; charset=utf-8"
434    }
435}
436
437fn handle_keys_http(
438    stream: &mut TcpStream,
439    method: &str,
440    path: &str,
441    want_html: bool,
442    store: &Arc<Mutex<HotStore>>,
443) -> Result<()> {
444    let rest = path
445        .strip_prefix("/keys/")
446        .or_else(|| path.strip_prefix("/keys").map(|_| ""))
447        .unwrap_or("");
448    let rest = percent_decode_path(rest.trim_matches('/'));
449
450    // Trailing slash or bare /keys → list; otherwise try get, then list-as-prefix.
451    let list_only = path == "/keys" || path == "/keys/" || path.ends_with('/');
452
453    let store = store
454        .lock()
455        .map_err(|e| Error::msg(format!("store lock poisoned: {e}")))?;
456
457    if !list_only && !rest.is_empty() {
458        match store.get_key(&rest) {
459            Ok(Some(value)) => {
460                let ct = guess_key_content_type(&value);
461                let body: Vec<u8> = if want_html {
462                    key_value_html(&rest, &value, ct).into_bytes()
463                } else {
464                    value.into_bytes()
465                };
466                let out_ct = if want_html {
467                    "text/html; charset=utf-8"
468                } else {
469                    ct
470                };
471                write_response(
472                    stream,
473                    200,
474                    out_ct,
475                    if method == "HEAD" { b"" } else { &body },
476                )?;
477                return Ok(());
478            }
479            Ok(None) => {
480                // Fall through: treat as prefix listing if any children exist.
481            }
482            Err(e) => {
483                write_response(
484                    stream,
485                    400,
486                    "text/plain; charset=utf-8",
487                    e.to_string().as_bytes(),
488                )?;
489                return Ok(());
490            }
491        }
492    }
493
494    let prefix = if rest.is_empty() { None } else { Some(rest.as_str()) };
495    let keys = match store.list_keys(prefix) {
496        Ok(k) => k,
497        Err(e) => {
498            write_response(
499                stream,
500                400,
501                "text/plain; charset=utf-8",
502                e.to_string().as_bytes(),
503            )?;
504            return Ok(());
505        }
506    };
507
508    if !list_only && !rest.is_empty() && keys.is_empty() {
509        write_response(stream, 404, "text/plain; charset=utf-8", b"not found")?;
510        return Ok(());
511    }
512
513    if want_html {
514        let body = keys_list_html(prefix, &keys);
515        write_response(
516            stream,
517            200,
518            "text/html; charset=utf-8",
519            if method == "HEAD" {
520                b""
521            } else {
522                body.as_bytes()
523            },
524        )?;
525    } else {
526        let body = keys.join("\n") + if keys.is_empty() { "" } else { "\n" };
527        write_response(
528            stream,
529            200,
530            "text/plain; charset=utf-8",
531            if method == "HEAD" {
532                b""
533            } else {
534                body.as_bytes()
535            },
536        )?;
537    }
538    Ok(())
539}
540
541fn key_value_html(key: &str, value: &str, content_type: &str) -> String {
542    let pretty = if content_type.starts_with("application/json") {
543        serde_json::from_str::<serde_json::Value>(value)
544            .ok()
545            .and_then(|v| serde_json::to_string_pretty(&v).ok())
546            .unwrap_or_else(|| value.to_string())
547    } else {
548        value.to_string()
549    };
550    format!(
551        r#"<!DOCTYPE html>
552<html lang="en">
553<head>
554<meta charset="utf-8"/>
555<meta name="viewport" content="width=device-width, initial-scale=1"/>
556<title>{title}</title>
557<style>
558  :root {{ color-scheme: light; --ink:#1a1f1c; --muted:#5c6b63; --bg:#f3f6f2; --accent:#2f6f4e; }}
559  body {{ margin:0; font:16px/1.5 "IBM Plex Sans","Source Sans 3",system-ui,sans-serif;
560         background: radial-gradient(900px 480px at 0% 0%, #d7e8de 0%, transparent 60%), var(--bg);
561         color:var(--ink); }}
562  header {{ padding:1rem 1.25rem; border-bottom:1px solid #d5ddd7; display:flex; gap:0.75rem; align-items:baseline; }}
563  header .brand {{ font-family:"IBM Plex Serif",Georgia,serif; font-weight:600; }}
564  header a {{ color:var(--accent); margin-left:auto; text-decoration:none; }}
565  main {{ max-width:52rem; margin:1.25rem auto 2rem; padding:0 1.25rem; }}
566  h1 {{ font-size:1.1rem; font-family:ui-monospace,monospace; word-break:break-all; }}
567  pre {{ background:#fff; border:1px solid #d5ddd7; padding:1rem; overflow:auto; white-space:pre-wrap; }}
568</style>
569</head>
570<body>
571<header>
572  <div class="brand">Unifier</div>
573  <span style="color:var(--muted)">key</span>
574  <a href="/keys/">all keys</a>
575</header>
576<main>
577  <h1>{title}</h1>
578  <pre>{body}</pre>
579</main>
580</body>
581</html>
582"#,
583        title = html_escape(key),
584        body = html_escape(&pretty)
585    )
586}
587
588fn keys_list_html(prefix: Option<&str>, keys: &[String]) -> String {
589    let heading = match prefix {
590        Some(p) if !p.is_empty() => format!("keys under {p}"),
591        _ => "keys".to_string(),
592    };
593    let mut items = String::new();
594    for k in keys {
595        items.push_str(&format!(
596            "<li><a href=\"/keys/{}\">{}</a></li>\n",
597            html_escape(k),
598            html_escape(k)
599        ));
600    }
601    if items.is_empty() {
602        items.push_str("<li class=\"empty\">No keys.</li>\n");
603    }
604    format!(
605        r#"<!DOCTYPE html>
606<html lang="en">
607<head>
608<meta charset="utf-8"/>
609<meta name="viewport" content="width=device-width, initial-scale=1"/>
610<title>{heading}</title>
611<style>
612  :root {{ color-scheme: light; --ink:#1a1f1c; --muted:#5c6b63; --bg:#f3f6f2; --accent:#2f6f4e; }}
613  body {{ margin:0; font:16px/1.5 "IBM Plex Sans","Source Sans 3",system-ui,sans-serif;
614         background: radial-gradient(1200px 600px at 10% -10%, #dfece4, var(--bg)); color:var(--ink); }}
615  main {{ max-width:42rem; margin:2rem auto; padding:0 1.25rem; }}
616  h1 {{ font-family:"IBM Plex Serif",Georgia,serif; font-weight:600; }}
617  a {{ color:var(--accent); }}
618  ul {{ list-style:none; padding:0; }}
619  li {{ padding:0.45rem 0; border-bottom:1px solid #d5ddd7; font-family:ui-monospace,monospace; font-size:0.9rem; }}
620  .empty {{ color:var(--muted); border:0; font-family:inherit; }}
621  nav a {{ margin-right:1rem; }}
622</style>
623</head>
624<body>
625<main>
626  <nav><a href="/">reports</a><a href="/keys/">all keys</a></nav>
627  <h1>{heading}</h1>
628  <ul>
629{items}  </ul>
630</main>
631</body>
632</html>
633"#,
634        heading = html_escape(&heading),
635        items = items
636    )
637}
638
639fn index_html(home: &UnifierHome) -> Result<String> {
640    let entries = list(home)?;
641    let mut items = String::new();
642    for e in &entries {
643        items.push_str(&format!(
644            "<li><a href=\"/{}\">{}</a> <span class=\"meta\">{} · {} bytes</span></li>\n",
645            html_escape(&e.name),
646            html_escape(&e.name),
647            html_escape(&e.content_type),
648            e.bytes
649        ));
650    }
651    if items.is_empty() {
652        items.push_str(
653            "<li class=\"empty\">No files yet. Pipe HTML with <code>unifier serve</code>.</li>\n",
654        );
655    }
656    Ok(format!(
657        r#"<!DOCTYPE html>
658<html lang="en">
659<head>
660<meta charset="utf-8"/>
661<meta name="viewport" content="width=device-width, initial-scale=1"/>
662<title>Unifier</title>
663<style>
664  :root {{ color-scheme: light; --ink:#1a1f1c; --muted:#5c6b63; --bg:#f3f6f2; --accent:#2f6f4e; }}
665  body {{ margin:0; font:16px/1.5 "IBM Plex Sans", "Source Sans 3", system-ui, sans-serif;
666         background: radial-gradient(1200px 600px at 10% -10%, #dfece4, var(--bg)); color:var(--ink); }}
667  main {{ max-width:42rem; margin:3rem auto; padding:0 1.25rem; }}
668  h1 {{ font-family:"IBM Plex Serif","Source Serif 4",Georgia,serif; font-weight:600; letter-spacing:-0.02em; }}
669  a {{ color:var(--accent); }}
670  ul {{ list-style:none; padding:0; }}
671  li {{ padding:0.55rem 0; border-bottom:1px solid #d5ddd7; }}
672  .meta {{ color:var(--muted); font-size:0.85rem; margin-left:0.5rem; }}
673  .empty {{ color:var(--muted); border:0; }}
674  code {{ font-family:ui-monospace,monospace; font-size:0.9em; }}
675</style>
676</head>
677<body>
678<main>
679  <h1>Unifier</h1>
680  <p>Temp files served from this daemon. <a href="/keys/">Browse keys</a>.</p>
681  <ul>
682{items}  </ul>
683</main>
684</body>
685</html>
686"#
687    ))
688}
689
690fn html_escape(s: &str) -> String {
691    s.replace('&', "&amp;")
692        .replace('<', "&lt;")
693        .replace('>', "&gt;")
694        .replace('"', "&quot;")
695}
696
697fn write_response(
698    stream: &mut TcpStream,
699    status: u16,
700    content_type: &str,
701    body: &[u8],
702) -> Result<()> {
703    let reason = match status {
704        200 => "OK",
705        400 => "Bad Request",
706        404 => "Not Found",
707        405 => "Method Not Allowed",
708        _ => "Error",
709    };
710    let header = format!(
711        "HTTP/1.1 {status} {reason}\r\n\
712         Content-Type: {content_type}\r\n\
713         Content-Length: {}\r\n\
714         Connection: close\r\n\
715         Cache-Control: no-store\r\n\
716         Access-Control-Allow-Origin: *\r\n\
717         \r\n",
718        body.len()
719    );
720    stream.write_all(header.as_bytes())?;
721    if !body.is_empty() {
722        stream.write_all(body)?;
723    }
724    stream.flush()?;
725    Ok(())
726}
727
728#[cfg(test)]
729mod tests {
730    use super::*;
731    use tempfile::tempdir;
732
733    fn home(dir: &Path) -> UnifierHome {
734        UnifierHome::resolve(Some(dir.to_path_buf()), None).unwrap()
735    }
736
737    #[test]
738    fn publish_list_remove() {
739        let tmp = tempdir().unwrap();
740        let h = home(tmp.path());
741        publish(&h, "report", b"<h1>hi</h1>", None, None).unwrap();
742        let entries = list(&h).unwrap();
743        assert_eq!(entries.len(), 1);
744        assert_eq!(entries[0].name, "report");
745        assert!(remove(&h, "report").unwrap());
746        assert!(list(&h).unwrap().is_empty());
747    }
748
749    #[test]
750    fn rejects_bad_names() {
751        assert!(validate_name("../x").is_err());
752        assert!(validate_name("a/b").is_err());
753        assert!(validate_name("").is_err());
754    }
755
756    #[test]
757    fn ttl_expires() {
758        let tmp = tempdir().unwrap();
759        let h = home(tmp.path());
760        let mut meta = publish(&h, "old", b"x", Some("text/plain"), Some(1)).unwrap();
761        // Force expiry in the past
762        meta.expires_at = Some("2000-01-01T00:00:00Z".into());
763        let www = www_dir(&h);
764        fs::write(
765            meta_path(&www, "old"),
766            serde_json::to_string(&meta).unwrap(),
767        )
768        .unwrap();
769        assert!(load_meta(&h, "old").unwrap().is_none());
770    }
771}