1mod 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 open: Box<dyn Fn() -> Result<Store> + Send + Sync>,
48}
49
50fn 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 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 pub fn url(&self) -> String {
92 format!("http://127.0.0.1:{}/#{}", self.ctx.port, self.ctx.token)
93 }
94
95 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
106fn 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
127fn 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
150fn 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 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
279fn 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
294fn 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
304fn 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
440fn 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
480pub 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
504pub 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;