use std::fs;
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::daemon::paths::{http_port_path, www_dir};
use crate::error::{Error, Result};
use crate::home::UnifierHome;
use crate::scope::resolve_under_root;
use crate::store::{validate_key, HotStore};
const DEFAULT_CONTENT_TYPE: &str = "text/html; charset=utf-8";
const DEFAULT_PORT_ENV: &str = "UNIFIER_HTTP_PORT";
const PREFERRED_PORT: u16 = 17355;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WwwMeta {
pub name: String,
pub content_type: String,
pub created_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
pub bytes: u64,
}
pub fn validate_name(name: &str) -> Result<()> {
if name.is_empty() {
return Err(Error::msg("web name must not be empty"));
}
if name.contains('/') || name.contains('\\') || name.contains('\0') {
return Err(Error::msg("web name must not contain path separators"));
}
if name == "." || name == ".." || name.ends_with(".meta.json") {
return Err(Error::msg(format!("invalid web name: {name}")));
}
if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
{
return Err(Error::msg(
"web name may only contain [A-Za-z0-9._-] characters",
));
}
Ok(())
}
fn meta_path(www: &Path, name: &str) -> PathBuf {
www.join(format!("{name}.meta.json"))
}
fn body_path(www: &Path, name: &str) -> PathBuf {
www.join(name)
}
fn now_rfc3339() -> String {
chrono::Utc::now().to_rfc3339()
}
fn expires_rfc3339(ttl_secs: u64) -> String {
let when = SystemTime::now() + Duration::from_secs(ttl_secs);
let secs = when
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
chrono::DateTime::from_timestamp(secs, 0)
.unwrap_or_else(chrono::Utc::now)
.to_rfc3339()
}
fn is_expired(meta: &WwwMeta) -> bool {
let Some(exp) = &meta.expires_at else {
return false;
};
match chrono::DateTime::parse_from_rfc3339(exp) {
Ok(dt) => dt.with_timezone(&chrono::Utc) < chrono::Utc::now(),
Err(_) => false,
}
}
pub fn publish(
home: &UnifierHome,
name: &str,
body: &[u8],
content_type: Option<&str>,
ttl_secs: Option<u64>,
) -> Result<WwwMeta> {
validate_name(name)?;
let www = www_dir(home);
fs::create_dir_all(&www)?;
let dest = resolve_under_root(&www, name)?;
let tmp = www.join(format!(".{name}.tmp"));
fs::write(&tmp, body)?;
fs::rename(&tmp, &dest)?;
let meta = WwwMeta {
name: name.to_string(),
content_type: content_type.unwrap_or(DEFAULT_CONTENT_TYPE).to_string(),
created_at: now_rfc3339(),
expires_at: ttl_secs.map(expires_rfc3339),
bytes: body.len() as u64,
};
let meta_json = serde_json::to_string_pretty(&meta)?;
let meta_tmp = www.join(format!(".{name}.meta.tmp"));
fs::write(&meta_tmp, &meta_json)?;
fs::rename(meta_tmp, meta_path(&www, name))?;
Ok(meta)
}
pub fn remove(home: &UnifierHome, name: &str) -> Result<bool> {
validate_name(name)?;
let www = www_dir(home);
let body = body_path(&www, name);
let meta = meta_path(&www, name);
let had = body.exists() || meta.exists();
let _ = fs::remove_file(&body);
let _ = fs::remove_file(&meta);
Ok(had)
}
pub fn load_meta(home: &UnifierHome, name: &str) -> Result<Option<WwwMeta>> {
validate_name(name)?;
let path = meta_path(&www_dir(home), name);
if !path.is_file() {
return Ok(None);
}
let text = fs::read_to_string(&path)?;
let meta: WwwMeta = serde_json::from_str(&text)?;
if is_expired(&meta) {
let _ = remove(home, name);
return Ok(None);
}
Ok(Some(meta))
}
pub fn list(home: &UnifierHome) -> Result<Vec<WwwMeta>> {
let www = www_dir(home);
if !www.is_dir() {
return Ok(vec![]);
}
let mut out = Vec::new();
for entry in fs::read_dir(&www)? {
let entry = entry?;
let fname = entry.file_name().to_string_lossy().into_owned();
let Some(name) = fname.strip_suffix(".meta.json") else {
continue;
};
if let Ok(Some(meta)) = load_meta(home, name) {
out.push(meta);
}
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
pub fn read_port(home: &UnifierHome) -> Option<u16> {
let text = fs::read_to_string(http_port_path(home)).ok()?;
text.trim().parse().ok()
}
pub fn base_url(home: &UnifierHome) -> Option<String> {
read_port(home).map(|p| format!("http://127.0.0.1:{p}"))
}
pub fn entry_url(home: &UnifierHome, name: &str) -> Result<String> {
validate_name(name)?;
let base = base_url(home).ok_or_else(|| Error::msg("web server port not available"))?;
Ok(format!("{base}/{name}"))
}
pub fn key_url(home: &UnifierHome, key: &str) -> Result<String> {
validate_key(key)?;
let base = base_url(home).ok_or_else(|| Error::msg("web server port not available"))?;
Ok(format!("{base}/keys/{key}"))
}
pub fn wrap_html(title: &str, body: &str) -> String {
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{title}</title>
<style>
:root {{ color-scheme: light; --ink:#1a1f1c; --muted:#5c6b63; --bg:#f3f6f2; --accent:#2f6f4e; --card:#fff; }}
* {{ box-sizing: border-box; }}
body {{ margin:0; font:16px/1.55 "IBM Plex Sans","Source Sans 3",system-ui,sans-serif;
background:
radial-gradient(900px 480px at 0% 0%, #d7e8de 0%, transparent 60%),
radial-gradient(700px 420px at 100% 10%, #e8efe4 0%, transparent 55%),
var(--bg);
color:var(--ink); min-height:100vh; }}
header {{ padding:1.25rem 1.5rem; border-bottom:1px solid #d5ddd7;
backdrop-filter: blur(6px); background:rgba(243,246,242,0.85);
display:flex; align-items:baseline; gap:0.75rem; }}
header .brand {{ font-family:"IBM Plex Serif","Source Serif 4",Georgia,serif;
font-size:1.35rem; font-weight:600; letter-spacing:-0.02em; }}
header .title {{ color:var(--muted); font-size:0.95rem; }}
header a {{ color:var(--accent); text-decoration:none; margin-left:auto; font-size:0.9rem; }}
main {{ max-width:56rem; margin:1.5rem auto 3rem; padding:1.25rem 1.5rem;
background:var(--card); border:1px solid #d5ddd7; border-radius:10px;
box-shadow:0 10px 30px rgba(26,31,28,0.04); }}
</style>
</head>
<body>
<header>
<div class="brand">Unifier</div>
<div class="title">{title}</div>
<a href="/">all reports</a>
</header>
<main>
{body}
</main>
</body>
</html>
"#,
title = html_escape(title),
body = body
)
}
fn preferred_port() -> u16 {
std::env::var(DEFAULT_PORT_ENV)
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(PREFERRED_PORT)
}
pub fn spawn(
home: UnifierHome,
store: Arc<Mutex<HotStore>>,
shutdown: Arc<AtomicBool>,
http_activity: Arc<AtomicBool>,
) -> Result<u16> {
let preferred = preferred_port();
let listener = match TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], preferred))) {
Ok(l) => l,
Err(_) => TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0)))?,
};
listener.set_nonblocking(true)?;
let port = listener.local_addr()?.port();
fs::create_dir_all(crate::daemon::paths::daemon_dir(&home))?;
fs::write(http_port_path(&home), format!("{port}\n"))?;
fs::create_dir_all(www_dir(&home))?;
thread::spawn(move || {
let mut last_gc = Instant::now();
while !shutdown.load(Ordering::Relaxed) {
match listener.accept() {
Ok((stream, _)) => {
http_activity.store(true, Ordering::Relaxed);
if let Err(e) = handle_http(stream, &home, &store) {
eprintln!("www http error: {e}");
}
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
if last_gc.elapsed() > Duration::from_secs(30) {
let _ = gc_expired(&home);
last_gc = Instant::now();
}
thread::sleep(Duration::from_millis(25));
}
Err(e) => {
eprintln!("www accept error: {e}");
thread::sleep(Duration::from_millis(100));
}
}
}
let _ = fs::remove_file(http_port_path(&home));
});
Ok(port)
}
fn gc_expired(home: &UnifierHome) -> Result<()> {
for meta in list(home)? {
let _ = meta;
}
Ok(())
}
fn handle_http(
mut stream: TcpStream,
home: &UnifierHome,
store: &Arc<Mutex<HotStore>>,
) -> Result<()> {
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
stream.set_write_timeout(Some(Duration::from_secs(5)))?;
let mut buf = [0u8; 4096];
let n = stream.read(&mut buf)?;
if n == 0 {
return Ok(());
}
let req = String::from_utf8_lossy(&buf[..n]);
let mut lines = req.lines();
let request_line = lines.next().unwrap_or("");
let mut parts = request_line.split_whitespace();
let method = parts.next().unwrap_or("");
let raw_path = parts.next().unwrap_or("/");
let path = raw_path.split('?').next().unwrap_or("/");
let want_html = accept_prefers_html(&req);
if method != "GET" && method != "HEAD" {
write_response(
&mut stream,
405,
"text/plain; charset=utf-8",
b"method not allowed",
)?;
return Ok(());
}
if path == "/" || path == "/index.html" {
let body = index_html(home)?;
write_response(
&mut stream,
200,
"text/html; charset=utf-8",
if method == "HEAD" {
b""
} else {
body.as_bytes()
},
)?;
return Ok(());
}
if path == "/keys" || path == "/keys/" || path.starts_with("/keys/") {
return handle_keys_http(&mut stream, method, path, want_html, store);
}
let name = path.trim_start_matches('/');
if name.contains('/') || validate_name(name).is_err() {
write_response(&mut stream, 404, "text/plain; charset=utf-8", b"not found")?;
return Ok(());
}
let Some(meta) = load_meta(home, name)? else {
write_response(&mut stream, 404, "text/plain; charset=utf-8", b"not found")?;
return Ok(());
};
let body_path = body_path(&www_dir(home), name);
let body = fs::read(&body_path).unwrap_or_default();
write_response(
&mut stream,
200,
&meta.content_type,
if method == "HEAD" { b"" } else { &body },
)?;
Ok(())
}
fn accept_prefers_html(req: &str) -> bool {
for line in req.lines().skip(1) {
if line.is_empty() {
break;
}
let lower = line.to_ascii_lowercase();
if let Some(rest) = lower.strip_prefix("accept:") {
let v = rest.trim();
let html = v.find("text/html");
let json = v.find("application/json");
return match (html, json) {
(Some(h), Some(j)) => h < j,
(Some(_), None) => true,
_ => false,
};
}
}
false
}
fn percent_decode_path(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let (Ok(hi), Ok(lo)) = (
u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 2]).unwrap_or(""), 16),
u8::from_str_radix(std::str::from_utf8(&bytes[i + 2..i + 3]).unwrap_or(""), 16),
) {
out.push((hi << 4) | lo);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
fn guess_key_content_type(value: &str) -> &'static str {
let t = value.trim_start();
if (t.starts_with('{') && t.ends_with('}')) || (t.starts_with('[') && t.ends_with(']')) {
"application/json; charset=utf-8"
} else {
"text/plain; charset=utf-8"
}
}
fn handle_keys_http(
stream: &mut TcpStream,
method: &str,
path: &str,
want_html: bool,
store: &Arc<Mutex<HotStore>>,
) -> Result<()> {
let rest = path
.strip_prefix("/keys/")
.or_else(|| path.strip_prefix("/keys").map(|_| ""))
.unwrap_or("");
let rest = percent_decode_path(rest.trim_matches('/'));
let list_only = path == "/keys" || path == "/keys/" || path.ends_with('/');
let store = store
.lock()
.map_err(|e| Error::msg(format!("store lock poisoned: {e}")))?;
if !list_only && !rest.is_empty() {
match store.get_key(&rest) {
Ok(Some(value)) => {
let ct = guess_key_content_type(&value);
let body: Vec<u8> = if want_html {
key_value_html(&rest, &value, ct).into_bytes()
} else {
value.into_bytes()
};
let out_ct = if want_html {
"text/html; charset=utf-8"
} else {
ct
};
write_response(
stream,
200,
out_ct,
if method == "HEAD" { b"" } else { &body },
)?;
return Ok(());
}
Ok(None) => {
}
Err(e) => {
write_response(
stream,
400,
"text/plain; charset=utf-8",
e.to_string().as_bytes(),
)?;
return Ok(());
}
}
}
let prefix = if rest.is_empty() { None } else { Some(rest.as_str()) };
let keys = match store.list_keys(prefix) {
Ok(k) => k,
Err(e) => {
write_response(
stream,
400,
"text/plain; charset=utf-8",
e.to_string().as_bytes(),
)?;
return Ok(());
}
};
if !list_only && !rest.is_empty() && keys.is_empty() {
write_response(stream, 404, "text/plain; charset=utf-8", b"not found")?;
return Ok(());
}
if want_html {
let body = keys_list_html(prefix, &keys);
write_response(
stream,
200,
"text/html; charset=utf-8",
if method == "HEAD" {
b""
} else {
body.as_bytes()
},
)?;
} else {
let body = keys.join("\n") + if keys.is_empty() { "" } else { "\n" };
write_response(
stream,
200,
"text/plain; charset=utf-8",
if method == "HEAD" {
b""
} else {
body.as_bytes()
},
)?;
}
Ok(())
}
fn key_value_html(key: &str, value: &str, content_type: &str) -> String {
let pretty = if content_type.starts_with("application/json") {
serde_json::from_str::<serde_json::Value>(value)
.ok()
.and_then(|v| serde_json::to_string_pretty(&v).ok())
.unwrap_or_else(|| value.to_string())
} else {
value.to_string()
};
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{title}</title>
<style>
:root {{ color-scheme: light; --ink:#1a1f1c; --muted:#5c6b63; --bg:#f3f6f2; --accent:#2f6f4e; }}
body {{ margin:0; font:16px/1.5 "IBM Plex Sans","Source Sans 3",system-ui,sans-serif;
background: radial-gradient(900px 480px at 0% 0%, #d7e8de 0%, transparent 60%), var(--bg);
color:var(--ink); }}
header {{ padding:1rem 1.25rem; border-bottom:1px solid #d5ddd7; display:flex; gap:0.75rem; align-items:baseline; }}
header .brand {{ font-family:"IBM Plex Serif",Georgia,serif; font-weight:600; }}
header a {{ color:var(--accent); margin-left:auto; text-decoration:none; }}
main {{ max-width:52rem; margin:1.25rem auto 2rem; padding:0 1.25rem; }}
h1 {{ font-size:1.1rem; font-family:ui-monospace,monospace; word-break:break-all; }}
pre {{ background:#fff; border:1px solid #d5ddd7; padding:1rem; overflow:auto; white-space:pre-wrap; }}
</style>
</head>
<body>
<header>
<div class="brand">Unifier</div>
<span style="color:var(--muted)">key</span>
<a href="/keys/">all keys</a>
</header>
<main>
<h1>{title}</h1>
<pre>{body}</pre>
</main>
</body>
</html>
"#,
title = html_escape(key),
body = html_escape(&pretty)
)
}
fn keys_list_html(prefix: Option<&str>, keys: &[String]) -> String {
let heading = match prefix {
Some(p) if !p.is_empty() => format!("keys under {p}"),
_ => "keys".to_string(),
};
let mut items = String::new();
for k in keys {
items.push_str(&format!(
"<li><a href=\"/keys/{}\">{}</a></li>\n",
html_escape(k),
html_escape(k)
));
}
if items.is_empty() {
items.push_str("<li class=\"empty\">No keys.</li>\n");
}
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{heading}</title>
<style>
:root {{ color-scheme: light; --ink:#1a1f1c; --muted:#5c6b63; --bg:#f3f6f2; --accent:#2f6f4e; }}
body {{ margin:0; font:16px/1.5 "IBM Plex Sans","Source Sans 3",system-ui,sans-serif;
background: radial-gradient(1200px 600px at 10% -10%, #dfece4, var(--bg)); color:var(--ink); }}
main {{ max-width:42rem; margin:2rem auto; padding:0 1.25rem; }}
h1 {{ font-family:"IBM Plex Serif",Georgia,serif; font-weight:600; }}
a {{ color:var(--accent); }}
ul {{ list-style:none; padding:0; }}
li {{ padding:0.45rem 0; border-bottom:1px solid #d5ddd7; font-family:ui-monospace,monospace; font-size:0.9rem; }}
.empty {{ color:var(--muted); border:0; font-family:inherit; }}
nav a {{ margin-right:1rem; }}
</style>
</head>
<body>
<main>
<nav><a href="/">reports</a><a href="/keys/">all keys</a></nav>
<h1>{heading}</h1>
<ul>
{items} </ul>
</main>
</body>
</html>
"#,
heading = html_escape(&heading),
items = items
)
}
fn index_html(home: &UnifierHome) -> Result<String> {
let entries = list(home)?;
let mut items = String::new();
for e in &entries {
items.push_str(&format!(
"<li><a href=\"/{}\">{}</a> <span class=\"meta\">{} · {} bytes</span></li>\n",
html_escape(&e.name),
html_escape(&e.name),
html_escape(&e.content_type),
e.bytes
));
}
if items.is_empty() {
items.push_str(
"<li class=\"empty\">No files yet. Pipe HTML with <code>unifier serve</code>.</li>\n",
);
}
Ok(format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Unifier</title>
<style>
:root {{ color-scheme: light; --ink:#1a1f1c; --muted:#5c6b63; --bg:#f3f6f2; --accent:#2f6f4e; }}
body {{ margin:0; font:16px/1.5 "IBM Plex Sans", "Source Sans 3", system-ui, sans-serif;
background: radial-gradient(1200px 600px at 10% -10%, #dfece4, var(--bg)); color:var(--ink); }}
main {{ max-width:42rem; margin:3rem auto; padding:0 1.25rem; }}
h1 {{ font-family:"IBM Plex Serif","Source Serif 4",Georgia,serif; font-weight:600; letter-spacing:-0.02em; }}
a {{ color:var(--accent); }}
ul {{ list-style:none; padding:0; }}
li {{ padding:0.55rem 0; border-bottom:1px solid #d5ddd7; }}
.meta {{ color:var(--muted); font-size:0.85rem; margin-left:0.5rem; }}
.empty {{ color:var(--muted); border:0; }}
code {{ font-family:ui-monospace,monospace; font-size:0.9em; }}
</style>
</head>
<body>
<main>
<h1>Unifier</h1>
<p>Temp files served from this daemon. <a href="/keys/">Browse keys</a>.</p>
<ul>
{items} </ul>
</main>
</body>
</html>
"#
))
}
fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
fn write_response(
stream: &mut TcpStream,
status: u16,
content_type: &str,
body: &[u8],
) -> Result<()> {
let reason = match status {
200 => "OK",
400 => "Bad Request",
404 => "Not Found",
405 => "Method Not Allowed",
_ => "Error",
};
let header = format!(
"HTTP/1.1 {status} {reason}\r\n\
Content-Type: {content_type}\r\n\
Content-Length: {}\r\n\
Connection: close\r\n\
Cache-Control: no-store\r\n\
Access-Control-Allow-Origin: *\r\n\
\r\n",
body.len()
);
stream.write_all(header.as_bytes())?;
if !body.is_empty() {
stream.write_all(body)?;
}
stream.flush()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn home(dir: &Path) -> UnifierHome {
UnifierHome::resolve(Some(dir.to_path_buf()), None).unwrap()
}
#[test]
fn publish_list_remove() {
let tmp = tempdir().unwrap();
let h = home(tmp.path());
publish(&h, "report", b"<h1>hi</h1>", None, None).unwrap();
let entries = list(&h).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "report");
assert!(remove(&h, "report").unwrap());
assert!(list(&h).unwrap().is_empty());
}
#[test]
fn rejects_bad_names() {
assert!(validate_name("../x").is_err());
assert!(validate_name("a/b").is_err());
assert!(validate_name("").is_err());
}
#[test]
fn ttl_expires() {
let tmp = tempdir().unwrap();
let h = home(tmp.path());
let mut meta = publish(&h, "old", b"x", Some("text/plain"), Some(1)).unwrap();
meta.expires_at = Some("2000-01-01T00:00:00Z".into());
let www = www_dir(&h);
fs::write(
meta_path(&www, "old"),
serde_json::to_string(&meta).unwrap(),
)
.unwrap();
assert!(load_meta(&h, "old").unwrap().is_none());
}
}