Skip to main content

pma_web/
lib.rs

1//! `pma web`: the TUI's views and actions in a browser, strictly local.
2//!
3//! It listens on 127.0.0.1 only, at a random port, behind a random token made
4//! at each launch. The page trades the token for an `HttpOnly`,
5//! `SameSite=Strict` cookie, and every data request needs that cookie. Each
6//! request's `Host` must be a loopback name, against DNS rebinding; any port
7//! is taken, so an SSH forward to another local port works. Each POST must
8//! come from the origin its own `Host` names and send JSON, against
9//! cross-site requests.
10//! Rows, keys and actions come from `pma_core::screen` and `pma_core::keys`,
11//! as the TUI's do. Design: `docs/dev/frontends.md`.
12
13mod http;
14
15use std::io::Write;
16use std::net::{TcpListener, TcpStream};
17use std::sync::Arc;
18use std::time::Duration;
19
20use serde_json::{Value, json};
21
22use pma_core::api;
23use pma_core::keys::{self, Act, Ask, Command, Tab};
24use pma_core::report_runs::By;
25use pma_core::screen::{self, Body, Item, Pick, Screen};
26use pma_core::store::Store;
27
28use http::{Request, Response};
29
30type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
31
32const INDEX: &str = include_str!("../assets/index.html");
33const SCRIPT: &str = include_str!("../assets/app.js");
34const STYLE: &str = include_str!("../assets/app.css");
35
36const COOKIE: &str = "pma_session";
37
38pub struct Server {
39    listener: TcpListener,
40    ctx: Arc<Ctx>,
41}
42
43struct Ctx {
44    port: u16,
45    token: String,
46    /// Opens the store for one request. A test passes its own.
47    open: Box<dyn Fn() -> Result<Store> + Send + Sync>,
48}
49
50/// 32 random bytes as hex, from the kernel's generator.
51fn token() -> Result<String> {
52    use std::io::Read;
53    let mut bytes = [0u8; 32];
54    std::fs::File::open("/dev/urandom")?.read_exact(&mut bytes)?;
55    Ok(bytes.iter().map(|b| format!("{b:02x}")).collect())
56}
57
58impl Server {
59    /// Binds 127.0.0.1 at `port`; 0 lets the kernel pick one.
60    pub fn bind(port: u16) -> Result<Server> {
61        Server::bind_with(port, Box::new(Store::open_default))
62    }
63
64    pub fn bind_with(
65        port: u16,
66        open: Box<dyn Fn() -> Result<Store> + Send + Sync>,
67    ) -> Result<Server> {
68        let listener =
69            TcpListener::bind(("127.0.0.1", port)).map_err(|e| format!("127.0.0.1:{port}: {e}"))?;
70        let port = listener.local_addr()?.port();
71        Ok(Server {
72            listener,
73            ctx: Arc::new(Ctx {
74                port,
75                token: token()?,
76                open,
77            }),
78        })
79    }
80
81    pub fn port(&self) -> u16 {
82        self.ctx.port
83    }
84
85    pub fn token(&self) -> &str {
86        &self.ctx.token
87    }
88
89    /// The address to open. The token rides in the fragment, which a browser
90    /// never sends in a request.
91    pub fn url(&self) -> String {
92        format!("http://127.0.0.1:{}/#{}", self.ctx.port, self.ctx.token)
93    }
94
95    /// Serves until the process ends, one thread per connection.
96    pub fn serve(self) -> Result<()> {
97        for stream in self.listener.incoming() {
98            let Ok(stream) = stream else { continue };
99            let ctx = Arc::clone(&self.ctx);
100            std::thread::spawn(move || handle(stream, &ctx));
101        }
102        Ok(())
103    }
104}
105
106/// Equal without an early exit, so timing does not reveal a prefix.
107fn same(a: &str, b: &str) -> bool {
108    a.len() == b.len() && a.bytes().zip(b.bytes()).fold(0u8, |d, (x, y)| d | (x ^ y)) == 0
109}
110
111fn handle(mut stream: TcpStream, ctx: &Ctx) {
112    let _ = stream.set_read_timeout(Some(Duration::from_secs(10)));
113    let _ = stream.set_write_timeout(Some(Duration::from_secs(10)));
114    let response = match http::read(&mut stream) {
115        Err(status) => Response::status(status),
116        Ok(req) => {
117            if req.path == "/api/events" && req.method == "GET" && checked(&req, ctx).is_none() {
118                let _ = events(&mut stream, ctx);
119                return;
120            }
121            route(&req, ctx)
122        }
123    };
124    let _ = http::write(&mut stream, &response);
125}
126
127/// Why a request is refused, if it is: the wrong `Host`; for a POST, another
128/// origin or a body that is not JSON; for data, no session cookie.
129fn checked(req: &Request, ctx: &Ctx) -> Option<Response> {
130    let Some(host) = req.header("host").filter(|h| loopback(h)) else {
131        return Some(Response::status(421));
132    };
133    if req.method == "POST" {
134        if req.header("origin") != Some(&format!("http://{host}")) {
135            return Some(Response::status(403));
136        }
137        if !req
138            .header("content-type")
139            .is_some_and(|t| t.starts_with("application/json"))
140        {
141            return Some(Response::status(415));
142        }
143    }
144    if req.path.starts_with("/api/") && !req.cookie(COOKIE).is_some_and(|c| same(c, &ctx.token)) {
145        return Some(Response::status(403));
146    }
147    None
148}
149
150/// `127.0.0.1:<port>` or `localhost:<port>`. A name an attacker controls,
151/// such as one rebound to 127.0.0.1, is not one of them.
152fn loopback(host: &str) -> bool {
153    host.rsplit_once(':').is_some_and(|(name, port)| {
154        matches!(name, "127.0.0.1" | "localhost") && port.parse::<u16>().is_ok()
155    })
156}
157
158fn route(req: &Request, ctx: &Ctx) -> Response {
159    if let Some(refused) = checked(req, ctx) {
160        return refused;
161    }
162    let body = || -> std::result::Result<Value, Response> {
163        serde_json::from_slice(&req.body).map_err(|_| Response::status(400))
164    };
165    match (req.method.as_str(), req.path.as_str()) {
166        ("GET", "/") => Response::new(200, "text/html; charset=utf-8", INDEX),
167        ("GET", "/app.js") => Response::new(200, "text/javascript; charset=utf-8", SCRIPT),
168        ("GET", "/app.css") => Response::new(200, "text/css; charset=utf-8", STYLE),
169        ("POST", "/session") => {
170            let Ok(b) = body() else {
171                return Response::status(400);
172            };
173            match b["token"].as_str() {
174                Some(t) if same(t, &ctx.token) => {
175                    let mut r = Response::status(204);
176                    r.body.clear();
177                    r.extra.push((
178                        "Set-Cookie",
179                        format!("{COOKIE}={}; HttpOnly; SameSite=Strict; Path=/", ctx.token),
180                    ));
181                    r
182                }
183                _ => Response::status(403),
184            }
185        }
186        ("GET", "/api/keys") => Response::json(200, &keys_json()),
187        ("GET", "/api/screen") => {
188            let Some(tab) = req.param("tab").as_deref().and_then(tab_named) else {
189                return Response::status(400);
190            };
191            let by = req
192                .param("by")
193                .as_deref()
194                .and_then(By::parse)
195                .unwrap_or(By::Class);
196            let project = req.param("project").unwrap_or_default();
197            answer(ctx, |store| {
198                Ok(screen_json(&screen::screen(api::query(
199                    store,
200                    tab.query(by, &project),
201                )?)))
202            })
203        }
204        ("POST", "/api/ask") => {
205            let Ok(b) = body() else {
206                return Response::status(400);
207            };
208            let (Some(tab), Some(act), Some(pick)) = (
209                b["tab"].as_str().and_then(tab_named),
210                act_named(&b["act"]),
211                pick_from(&b["pick"]),
212            ) else {
213                return Response::status(400);
214            };
215            answer(ctx, |store| {
216                // As the TUI does: an act that cannot apply to the pick is
217                // refused before the question, not after the answer.
218                let confirm = keys::BINDINGS
219                    .iter()
220                    .any(|b| b.command == Command::Act(act) && b.ask == Ask::Confirm);
221                if confirm {
222                    screen::action_in(
223                        act,
224                        &pick,
225                        &rows(store, tab, act, &b)?,
226                        "",
227                        b["project"].as_str().unwrap_or(""),
228                    )?;
229                }
230                Ok(json!({
231                    "question": screen::question(act, &pick),
232                    "input": screen::initial_input(act, &pick),
233                }))
234            })
235        }
236        ("POST", "/api/open") => {
237            let Ok(b) = body() else {
238                return Response::status(400);
239            };
240            let q = match pick_from(&b["pick"]) {
241                Some(Pick::Run { id, .. }) => api::Query::Run { id },
242                Some(Pick::Job { id, .. }) => api::Query::JobLog { id },
243                _ => return Response::status(400),
244            };
245            answer(ctx, |store| {
246                Ok(screen_json(&screen::screen(api::query(store, q)?)))
247            })
248        }
249        ("POST", "/api/act") => {
250            let Ok(b) = body() else {
251                return Response::status(400);
252            };
253            let (Some(tab), Some(act), Some(pick)) = (
254                b["tab"].as_str().and_then(tab_named),
255                act_named(&b["act"]),
256                pick_from(&b["pick"]),
257            ) else {
258                return Response::status(400);
259            };
260            let input = b["input"].as_str().unwrap_or("").to_string();
261            answer(ctx, |store| {
262                let action = screen::action_in(
263                    act,
264                    &pick,
265                    &rows(store, tab, act, &b)?,
266                    &input,
267                    b["project"].as_str().unwrap_or(""),
268                )?;
269                let out = api::act(store, action)?;
270                Ok(json!({ "said": out.said, "warnings": out.warnings }))
271            })
272        }
273        (_, "/" | "/app.js" | "/app.css" | "/session" | "/api/keys" | "/api/screen")
274        | (_, "/api/ask" | "/api/open" | "/api/act") => Response::status(405),
275        _ => Response::status(404),
276    }
277}
278
279/// The rows an act over many reads, as the store has them now rather than as
280/// the page last drew them. Other acts read none.
281fn rows(store: &Store, tab: Tab, act: Act, body: &Value) -> Result<Vec<Item>> {
282    if act != Act::ApproveRuns {
283        return Ok(Vec::new());
284    }
285    let project = body["project"].as_str().unwrap_or("");
286    Ok(
287        match screen::screen(api::query(store, tab.query(By::Class, project))?).body {
288            Body::Panes(panes) => panes.into_iter().flat_map(|p| p.items).collect(),
289            Body::Detail(_) => Vec::new(),
290        },
291    )
292}
293
294/// Runs `f` against a store opened for this request. An error goes back as
295/// JSON with status 400, for the page to show.
296fn answer(ctx: &Ctx, f: impl FnOnce(&Store) -> Result<Value>) -> Response {
297    let result = (ctx.open)().and_then(|store| f(&store));
298    match result {
299        Ok(v) => Response::json(200, &v),
300        Err(e) => Response::json(400, &json!({ "error": e.to_string() })),
301    }
302}
303
304/// A `tick` every 2 seconds while a job or a run is under way, every 30
305/// otherwise, until the page goes away. The page reloads its tab on each.
306fn events(stream: &mut TcpStream, ctx: &Ctx) -> std::io::Result<()> {
307    http::start_events(stream)?;
308    loop {
309        let busy = (ctx.open)().and_then(|s| api::busy(&s)).unwrap_or(false);
310        write!(stream, "event: tick\ndata: {{\"busy\":{busy}}}\n\n")?;
311        stream.flush()?;
312        std::thread::sleep(Duration::from_secs(if busy { 2 } else { 30 }));
313    }
314}
315
316fn tab_named(name: &str) -> Option<Tab> {
317    Tab::ALL.into_iter().find(|t| t.title() == name)
318}
319
320fn act_named(v: &Value) -> Option<Act> {
321    let name = v.as_str()?;
322    Act::ALL.into_iter().find(|a| a.name() == name)
323}
324
325fn keys_json() -> Value {
326    let bindings: Vec<Value> = keys::BINDINGS
327        .iter()
328        .map(|b| {
329            let command = match b.command {
330                Command::Tab(t) => json!({ "kind": "tab", "tab": t.title() }),
331                Command::Act(a) => json!({
332                    "kind": "act",
333                    "act": a.name(),
334                    "row": a.on_row(),
335                    "weight": a.weight(),
336                }),
337                other => json!({ "kind": format!("{other:?}") }),
338            };
339            let ask = match b.ask {
340                Ask::Nothing => json!({ "kind": "nothing" }),
341                Ask::Confirm => json!({ "kind": "confirm" }),
342                Ask::Text(label) => json!({ "kind": "text", "label": label }),
343            };
344            json!({
345                "key": b.key,
346                "command": command,
347                "tabs": b.tabs.iter().map(|t| t.title()).collect::<Vec<_>>(),
348                "ask": ask,
349                "label": b.label,
350            })
351        })
352        .collect();
353    json!({
354        "tabs": Tab::ALL.iter().map(|t| t.title()).collect::<Vec<_>>(),
355        "by": By::ALL.iter().map(|(n, _)| *n).collect::<Vec<_>>(),
356        "bindings": bindings,
357    })
358}
359
360fn cell_json(c: &screen::Cell) -> Value {
361    json!({ "text": c.text, "tone": c.tone.name() })
362}
363
364fn screen_json(s: &Screen) -> Value {
365    let body = match &s.body {
366        Body::Detail(d) => json!({ "detail": {
367            "title": d.title,
368            "badge": d.badge.as_ref().map(cell_json),
369            "notice": d.notice,
370            "fields": d.fields,
371            "sections": d.sections.iter().map(|(title, kind, text)| json!({
372                "title": title,
373                "kind": match kind {
374                    screen::Section::Prose => "prose",
375                    screen::Section::Diff => "diff",
376                    screen::Section::Log => "log",
377                },
378                "text": text,
379            })).collect::<Vec<_>>(),
380        }}),
381        Body::Panes(panes) => json!({
382            "panes": panes.iter().map(|p| json!({
383                "title": p.title,
384                "empty": p.empty,
385                "columns": p.columns.iter().map(|c| json!({
386                    "title": c.title,
387                    "kind": match c.kind {
388                        screen::Kind::Text => "text",
389                        screen::Kind::Wide => "wide",
390                        screen::Kind::Number => "number",
391                        screen::Kind::Badge => "badge",
392                    },
393                })).collect::<Vec<_>>(),
394                "items": p.items.iter().map(|i| json!({
395                    "cells": i.cells.iter().map(cell_json).collect::<Vec<_>>(),
396                    "pick": pick_json(&i.pick),
397                    "acts": Act::ALL
398                        .iter()
399                        .filter(|a| a.on_row() && screen::fits(**a, &i.pick))
400                        .map(|a| a.name())
401                        .collect::<Vec<_>>(),
402                })).collect::<Vec<_>>(),
403            })).collect::<Vec<_>>(),
404        }),
405    };
406    json!({
407        "tab": s.tab.map(|t| t.title()),
408        "header": s.header,
409        "summary": s.summary.iter().map(|(l, c)| json!([l, cell_json(c)])).collect::<Vec<_>>(),
410        "body": body,
411    })
412}
413
414fn pick_json(p: &Pick) -> Value {
415    match p {
416        Pick::Nothing => json!({ "kind": "nothing" }),
417        Pick::Task {
418            project,
419            target,
420            retry,
421        } => json!({ "kind": "task", "project": project, "target": target, "retry": retry }),
422        Pick::Manual { project } => json!({ "kind": "manual", "project": project }),
423        Pick::Run {
424            id,
425            clean,
426            state,
427            project,
428        } => {
429            json!({ "kind": "run", "id": id, "clean": clean, "state": state, "project": project })
430        }
431        Pick::Job { id, awaiting } => json!({ "kind": "job", "id": id, "awaiting": awaiting }),
432        Pick::Instance { id, workflow, open } => {
433            json!({ "kind": "instance", "id": id, "workflow": workflow, "open": open })
434        }
435        Pick::Note { id, text } => json!({ "kind": "note", "id": id, "text": text }),
436        Pick::Campaign { name } => json!({ "kind": "campaign", "name": name }),
437    }
438}
439
440/// A pick the page sent back. Anything malformed is `None`, so a request
441/// that names no real subject is refused rather than guessed at.
442fn pick_from(v: &Value) -> Option<Pick> {
443    let s = |k: &str| v[k].as_str().map(String::from);
444    let n = |k: &str| v[k].as_i64();
445    let b = |k: &str| v[k].as_bool();
446    Some(match v["kind"].as_str()? {
447        "nothing" => Pick::Nothing,
448        "task" => Pick::Task {
449            project: s("project")?,
450            target: s("target")?,
451            retry: b("retry")?,
452        },
453        "manual" => Pick::Manual {
454            project: s("project")?,
455        },
456        "run" => Pick::Run {
457            id: n("id")?,
458            clean: b("clean")?,
459            state: s("state")?,
460            project: s("project")?,
461        },
462        "job" => Pick::Job {
463            id: n("id")?,
464            awaiting: b("awaiting")?,
465        },
466        "instance" => Pick::Instance {
467            id: n("id")?,
468            workflow: s("workflow")?,
469            open: b("open")?,
470        },
471        "note" => Pick::Note {
472            id: n("id")?,
473            text: s("text")?,
474        },
475        "campaign" => Pick::Campaign { name: s("name")? },
476        _ => return None,
477    })
478}
479
480/// Opens the page in a Chrome or Chromium window with no browser controls:
481/// an app window, from the first such browser found.
482pub fn open_app(url: &str) -> Result<()> {
483    const BROWSERS: [&str; 5] = [
484        "google-chrome",
485        "google-chrome-stable",
486        "chromium",
487        "chromium-browser",
488        "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
489    ];
490    for program in BROWSERS {
491        let spawned = std::process::Command::new(program)
492            .arg(format!("--app={url}"))
493            .stdin(std::process::Stdio::null())
494            .stdout(std::process::Stdio::null())
495            .stderr(std::process::Stdio::null())
496            .spawn();
497        if spawned.is_ok() {
498            return Ok(());
499        }
500    }
501    Err("no Chrome or Chromium found for --app; open the address in a browser".into())
502}
503
504/// Opens the page in the user's browser, without waiting for it.
505pub fn open_browser(url: &str) -> Result<()> {
506    let program = if cfg!(target_os = "macos") {
507        "open"
508    } else {
509        "xdg-open"
510    };
511    std::process::Command::new(program)
512        .arg(url)
513        .stdin(std::process::Stdio::null())
514        .stdout(std::process::Stdio::null())
515        .stderr(std::process::Stdio::null())
516        .spawn()
517        .map_err(|e| format!("{program}: {e}"))?;
518    Ok(())
519}
520
521#[cfg(test)]
522mod tests;