1use crate::{commands, index, resume};
2use anyhow::{Context, Result};
3use rusqlite::Connection;
4use serde_json::json;
5use tiny_http::{Header, Response, Server};
6
7pub 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 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 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
89fn 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
102fn 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 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
218fn 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
236fn 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
281fn 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
328fn 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
338fn 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 assert!(should_open_browser(false, false));
368 assert!(!should_open_browser(true, false));
369 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 assert!(!host_matches(Some("evil.com:7575"), 7575));
382 assert!(!host_matches(Some("127.0.0.1:7575"), 7576)); assert!(!host_matches(Some("127.0.0.1"), 7575)); assert!(!host_matches(None, 7575)); }
386
387 #[test]
388 fn origin_ok_rejects_cross_origin() {
389 assert!(origin_ok(None, 7575)); 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)); assert!(!origin_ok(Some("https://127.0.0.1:7575"), 7575)); assert!(!origin_ok(Some("http://127.0.0.1:7576"), 7575)); }
396}