Skip to main content

sessionwiki/
web.rs

1use crate::{commands, index, resume};
2use anyhow::{Context, Result};
3use rusqlite::Connection;
4use serde_json::json;
5use tiny_http::{Header, Response, Server};
6
7/// Local read-only viewer over the index. By default it does not sync, so it
8/// stays snappy and can run while a CLI index pass is in progress (WAL allows
9/// concurrent readers) - which means sessions created after the last
10/// `list`/`search` won't show until the index is refreshed. Pass `sync` to
11/// bring the index up to date once before serving.
12pub fn serve(port: u16, no_open: bool, sync: bool) -> Result<()> {
13    let mut conn = index::open()?;
14    if sync {
15        index::sync(&mut conn, None)?;
16    }
17    let addr = format!("127.0.0.1:{port}");
18    let server = Server::http(&addr).map_err(|e| anyhow::anyhow!("bind {addr}: {e}"))?;
19    let url = format!("http://{addr}");
20    println!("sessionwiki web: {url}");
21    if !sync {
22        println!("(read-only view; run `sessionwiki list` or `web --sync` to refresh the index)");
23    }
24    let wsl = is_wsl();
25    if should_open_browser(no_open, wsl) {
26        open_browser(&url);
27    } else if wsl && !no_open {
28        println!("(WSL detected - open the URL above in your browser; auto-open is off here to avoid stray windows)");
29    }
30
31    for request in server.incoming_requests() {
32        // Defend against DNS rebinding: a page on a malicious site can rebind its
33        // own hostname to 127.0.0.1 and have the browser fetch this server, then
34        // read your sessions same-origin. Such a request carries the attacker's
35        // hostname in the Host header, so only serve requests addressed to the
36        // loopback name we actually bound.
37        let host = request
38            .headers()
39            .iter()
40            .find(|h| h.field.equiv("Host"))
41            .map(|h| h.value.as_str().to_string());
42        let origin = request
43            .headers()
44            .iter()
45            .find(|h| h.field.equiv("Origin"))
46            .map(|h| h.value.as_str().to_string());
47        // Host check defeats DNS rebinding (an attacker hostname rebound to
48        // 127.0.0.1 carries its name in Host). The Origin check is a second layer
49        // against a plain cross-origin fetch from a malicious site, which the
50        // browser stamps with its Origin - we don't rely on the same-origin
51        // policy alone.
52        if !host_matches(host.as_deref(), port) || !origin_ok(origin.as_deref(), port) {
53            let _ = request.respond(Response::from_string("forbidden").with_status_code(403));
54            continue;
55        }
56        let url = request.url().to_string();
57        let (path, query) = url.split_once('?').unwrap_or((url.as_str(), ""));
58        let result = match path {
59            "/" => html(INDEX_HTML),
60            "/api/stats" => api_stats(&conn),
61            "/api/sessions" => api_sessions(&conn, query),
62            "/api/search" => api_search(&conn, query),
63            "/api/trace" => api_trace(&conn, query),
64            "/api/file" => api_file(&conn, query),
65            "/api/projects" => api_projects(&conn),
66            p if p.starts_with("/api/related/") => {
67                api_related(&conn, p.trim_start_matches("/api/related/"))
68            }
69            p if p.starts_with("/api/session/") => {
70                api_session(&conn, p.trim_start_matches("/api/session/"))
71            }
72            _ => Ok(Response::from_string("not found")
73                .with_status_code(404)
74                .boxed()),
75        };
76        let response = match result {
77            Ok(r) => r,
78            Err(e) => Response::from_string(json!({ "error": e.to_string() }).to_string())
79                .with_status_code(500)
80                .boxed(),
81        };
82        let _ = request.respond(response);
83    }
84    Ok(())
85}
86
87type Boxed = Response<Box<dyn std::io::Read + Send>>;
88
89/// True only for the loopback host:port we bound. Anything else (notably an
90/// attacker hostname rebound to 127.0.0.1) is rejected, defeating DNS rebinding.
91fn host_matches(host: Option<&str>, port: u16) -> bool {
92    match host {
93        Some(h) => {
94            h == format!("127.0.0.1:{port}")
95                || h == format!("localhost:{port}")
96                || h == format!("[::1]:{port}")
97        }
98        None => false,
99    }
100}
101
102/// A present Origin that is not our own loopback origin means a cross-origin
103/// request (e.g. a `fetch` from a malicious site) - refuse it. A missing Origin
104/// (top-level navigation, same-origin GET) is allowed.
105fn origin_ok(origin: Option<&str>, port: u16) -> bool {
106    match origin {
107        None => true,
108        Some(o) => {
109            o == format!("http://127.0.0.1:{port}")
110                || o == format!("http://localhost:{port}")
111                || o == format!("http://[::1]:{port}")
112        }
113    }
114}
115
116fn html(body: &str) -> Result<Boxed> {
117    Ok(Response::from_string(body)
118        .with_header(
119            Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..]).unwrap(),
120        )
121        .boxed())
122}
123
124fn json_response(v: serde_json::Value) -> Result<Boxed> {
125    Ok(Response::from_string(v.to_string())
126        .with_header(
127            Header::from_bytes(
128                &b"Content-Type"[..],
129                &b"application/json; charset=utf-8"[..],
130            )
131            .unwrap(),
132        )
133        .boxed())
134}
135
136fn api_stats(conn: &Connection) -> Result<Boxed> {
137    let mut stmt = conn.prepare(
138        "SELECT tool, count(*), sum(size), sum(msg_count) FROM files GROUP BY tool ORDER BY 2 DESC",
139    )?;
140    let rows: Vec<serde_json::Value> = stmt
141        .query_map([], |r| {
142            Ok(json!({
143                "tool": r.get::<_, String>(0)?,
144                "sessions": r.get::<_, i64>(1)?,
145                "bytes": r.get::<_, i64>(2)?,
146                "messages": r.get::<_, i64>(3)?,
147            }))
148        })?
149        .collect::<rusqlite::Result<_>>()?;
150    json_response(json!({ "tools": rows }))
151}
152
153fn api_sessions(conn: &Connection, query: &str) -> Result<Boxed> {
154    let tool = param(query, "tool");
155    let project = param(query, "project");
156    let tag = param(query, "tag");
157    let limit = param(query, "limit")
158        .and_then(|s| s.parse().ok())
159        .unwrap_or(200);
160    let rows = index::recent(
161        conn,
162        limit,
163        tool.as_deref(),
164        project.as_deref(),
165        tag.as_deref(),
166        false,
167    )?;
168    json_response(json!(rows.iter().map(row_json).collect::<Vec<_>>()))
169}
170
171fn api_projects(conn: &Connection) -> Result<Boxed> {
172    let rows = index::projects(conn)?;
173    json_response(json!(rows
174        .iter()
175        .map(|p| json!({
176            "project": p.project,
177            "sessions": p.sessions,
178            "messages": p.messages,
179            "newest": p.newest,
180        }))
181        .collect::<Vec<_>>()))
182}
183
184fn api_related(conn: &Connection, id: &str) -> Result<Boxed> {
185    let rel = index::related(conn, id, 8)?;
186    json_response(json!(rel.iter().map(row_json).collect::<Vec<_>>()))
187}
188
189fn api_search(conn: &Connection, query: &str) -> Result<Boxed> {
190    let q = param(query, "q").unwrap_or_default();
191    let qt = q.trim();
192    if qt.is_empty() {
193        return json_response(json!([]));
194    }
195    let tool = param(query, "tool");
196    let limit = param(query, "limit")
197        .and_then(|s| s.parse().ok())
198        .unwrap_or(50);
199    // <3 chars (incl. 2-syllable Korean) is below the trigram floor; LIKE-scan it.
200    let hits = if crate::util::nfc(qt).chars().count() < 3 {
201        index::search_like(conn, qt, limit, tool.as_deref(), None)?
202    } else {
203        index::search(conn, qt, limit, tool.as_deref(), None)?
204    };
205    json_response(json!(hits
206        .iter()
207        .map(|h| {
208            let mut v = row_json(&h.row);
209            let (plain, marked) = crate::commands::clean_snippet(&h.snippet);
210            v["snippet"] = json!(plain);
211            v["snippet_marked"] = json!(marked);
212            v["role"] = json!(h.role);
213            v
214        })
215        .collect::<Vec<_>>()))
216}
217
218/// Reverse provenance lookup: sessions that touched a file. Each result
219/// carries the matched stored path, so the UI can show what it resolved to.
220fn api_trace(conn: &Connection, query: &str) -> Result<Boxed> {
221    let path = param(query, "path").unwrap_or_default();
222    if path.is_empty() {
223        return json_response(json!([]));
224    }
225    let hits = index::sessions_for_file(conn, &path, 50)?;
226    json_response(json!(hits
227        .iter()
228        .map(|(r, matched)| {
229            let mut v = row_json(r);
230            v["matched"] = json!(matched);
231            v
232        })
233        .collect::<Vec<_>>()))
234}
235
236/// A file's evidence chain for the file-history page: the sessions that edited
237/// it (newest first), each session row carrying its own `edits` (kind + snippet
238/// + ts) - "why does this file look like this".
239fn api_file(conn: &Connection, query: &str) -> Result<Boxed> {
240    let path = param(query, "path").unwrap_or_default();
241    if path.is_empty() {
242        return json_response(json!({ "path": "", "sessions": [] }));
243    }
244    let hist = index::evidence_for(conn, &path, 100)?;
245    let sessions: Vec<_> = hist
246        .sessions
247        .iter()
248        .map(|se| {
249            let mut v = row_json(&se.session);
250            v["edits"] = json!(se.edits);
251            v
252        })
253        .collect();
254    json_response(json!({ "path": hist.path, "sessions": sessions }))
255}
256
257fn api_session(conn: &Connection, id: &str) -> Result<Boxed> {
258    let matches = index::resolve(conn, id)?;
259    let row = matches.first().context("session not found")?;
260    let path = std::path::Path::new(&row.path);
261    let session = commands::load_session(conn, row)?;
262    let mut v = serde_json::to_value(&session)?;
263    if row.archived {
264        v["archived"] = json!(true);
265    }
266    if let Some(info) = resume::for_session(&row.tool, path, &row.project) {
267        v["resume"] = json!(info.command_line());
268    }
269    if let Some(s) = &row.summary {
270        v["summary"] = json!(s);
271    }
272    if let Some(t) = &row.tags {
273        v["tags"] = json!(t.split(',').collect::<Vec<_>>());
274    }
275    if let Some(note) = index::note_for(conn, &row.session_id)? {
276        v["note"] = json!(note);
277    }
278    json_response(v)
279}
280
281/// Delegate to SessionRow's Serialize derive so the web JSON and the CLI
282/// `--json` contract are the exact same field set (id/msgs/tags-array/...),
283/// and can never drift apart.
284fn row_json(r: &index::SessionRow) -> serde_json::Value {
285    serde_json::to_value(r).unwrap_or_else(|_| json!({}))
286}
287
288fn param(query: &str, key: &str) -> Option<String> {
289    query.split('&').find_map(|kv| {
290        let (k, v) = kv.split_once('=')?;
291        (k == key && !v.is_empty()).then(|| url_decode(v))
292    })
293}
294
295fn url_decode(s: &str) -> String {
296    let bytes = s.as_bytes();
297    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
298    let mut i = 0;
299    while i < bytes.len() {
300        match bytes[i] {
301            b'%' => match (hex(bytes.get(i + 1)), hex(bytes.get(i + 2))) {
302                (Some(h), Some(l)) => {
303                    out.push(h * 16 + l);
304                    i += 3;
305                }
306                _ => {
307                    out.push(b'%');
308                    i += 1;
309                }
310            },
311            b'+' => {
312                out.push(b' ');
313                i += 1;
314            }
315            b => {
316                out.push(b);
317                i += 1;
318            }
319        }
320    }
321    String::from_utf8_lossy(&out).into_owned()
322}
323
324fn hex(b: Option<&u8>) -> Option<u8> {
325    (*b? as char).to_digit(16).map(|d| d as u8)
326}
327
328/// True when running under WSL, where `xdg-open` pops a WSLg/Windows browser
329/// window on EVERY `web` start (a stray blank window during repeated testing)
330/// and often can't even reach the loopback server. Detected via the kernel
331/// string, which carries "microsoft" on WSL1/WSL2.
332fn is_wsl() -> bool {
333    std::fs::read_to_string("/proc/version")
334        .map(|v| v.to_ascii_lowercase().contains("microsoft"))
335        .unwrap_or(false)
336}
337
338/// Whether to auto-open the browser: never when `--no-open`, and never on WSL
339/// (print the URL there instead - see `is_wsl`).
340fn should_open_browser(no_open: bool, wsl: bool) -> bool {
341    !no_open && !wsl
342}
343
344fn open_browser(url: &str) {
345    #[cfg(target_os = "macos")]
346    let cmd = "open";
347    #[cfg(target_os = "windows")]
348    let cmd = "explorer";
349    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
350    let cmd = "xdg-open";
351    let _ = std::process::Command::new(cmd)
352        .arg(url)
353        .stdout(std::process::Stdio::null())
354        .stderr(std::process::Stdio::null())
355        .spawn();
356}
357
358const INDEX_HTML: &str = include_str!("webui.html");
359
360#[cfg(test)]
361mod tests {
362    use super::{host_matches, origin_ok, should_open_browser};
363
364    #[test]
365    fn wsl_never_auto_opens_a_browser() {
366        // Native (not WSL): open unless the user said --no-open.
367        assert!(should_open_browser(false, false));
368        assert!(!should_open_browser(true, false));
369        // WSL: never auto-open, so `web` stops popping a stray Chrome/WSLg
370        // window on every start - the URL is printed instead.
371        assert!(!should_open_browser(false, true));
372        assert!(!should_open_browser(true, true));
373    }
374
375    #[test]
376    fn host_matches_only_loopback() {
377        assert!(host_matches(Some("127.0.0.1:7575"), 7575));
378        assert!(host_matches(Some("localhost:7575"), 7575));
379        assert!(host_matches(Some("[::1]:7575"), 7575));
380        // DNS-rebinding and cross-origin attempts:
381        assert!(!host_matches(Some("evil.com:7575"), 7575));
382        assert!(!host_matches(Some("127.0.0.1:7575"), 7576)); // wrong port
383        assert!(!host_matches(Some("127.0.0.1"), 7575)); // no port
384        assert!(!host_matches(None, 7575)); // missing Host
385    }
386
387    #[test]
388    fn origin_ok_rejects_cross_origin() {
389        assert!(origin_ok(None, 7575)); // same-origin GET / navigation
390        assert!(origin_ok(Some("http://127.0.0.1:7575"), 7575));
391        assert!(origin_ok(Some("http://localhost:7575"), 7575));
392        assert!(!origin_ok(Some("http://evil.com"), 7575)); // cross-origin fetch
393        assert!(!origin_ok(Some("https://127.0.0.1:7575"), 7575)); // wrong scheme
394        assert!(!origin_ok(Some("http://127.0.0.1:7576"), 7575)); // wrong port
395    }
396}