| | |
| 33 | 33 | } |
| 34 | 34 | |
| 35 | 35 | /// A path segment or query value, percent-encoded. |
| 36 | | fn enc(s: &str) -> String { |
| 36 | pub fn enc(s: &str) -> String { |
| 37 | 37 | let mut out = String::new(); |
| 38 | 38 | for b in s.bytes() { |
| 39 | 39 | if b.is_ascii_alphanumeric() || b"-._~".contains(&b) { |
| | |
| 50 | 50 | p.split('/').map(enc).collect::<Vec<_>>().join("/") |
| 51 | 51 | } |
| 52 | 52 | |
| 53 | | fn decode(s: &str) -> String { |
| 53 | pub fn decode(s: &str) -> String { |
| 54 | 54 | let b = s.as_bytes(); |
| 55 | 55 | let mut out = Vec::with_capacity(b.len()); |
| 56 | 56 | let mut i = 0; |
| | |
| 68 | 68 | String::from_utf8_lossy(&out).into_owned() |
| 69 | 69 | } |
| 70 | 70 | |
| 71 | /// A value from a query string or a form body (`a=1&b=2`), still encoded. |
| 72 | pub fn param<'a>(pairs: &'a str, key: &str) -> Option<&'a str> { |
| 73 | pairs.split('&').find_map(|kv| kv.split_once('=').filter(|(k, _)| *k == key).map(|(_, v)| v)) |
| 74 | } |
| 75 | |
| 71 | 76 | fn query<'a>(req: &'a Request, key: &str) -> Option<&'a str> { |
| 72 | | req.query.split('&').find_map(|kv| kv.split_once('=').filter(|(k, _)| *k == key).map(|(_, v)| v)) |
| 77 | param(&req.query, key) |
| 73 | 78 | } |
| 74 | 79 | |
| 75 | 80 | fn now_ms() -> i64 { |
| | |
| 140 | 145 | |
| 141 | 146 | // ── The page shell ──────────────────────────────────────── |
| 142 | 147 | |
| 143 | | fn page(title: &str, crumbs: &[(String, String)], body: &str) -> Response { |
| 148 | fn page(who: Option<&str>, title: &str, crumbs: &[(String, String)], body: &str) -> Response { |
| 144 | 149 | let day = twig::today(); |
| 145 | 150 | let mark = twig::svg(day, false, "var(--accent)", "var(--halo)", twig::LINE_WIDTH, Some(40)); |
| 146 | 151 | let mut trail = String::new(); |
| | |
| 152 | 157 | let _ = write!(trail, r#"<a href="{href}">{}</a>"#, esc(label)); |
| 153 | 158 | } |
| 154 | 159 | } |
| 160 | let account = match who { |
| 161 | Some(w) => format!(r#"<form class="account" method="post" action="/signout"><a href="/{}">{}</a><button>sign out</button></form>"#, enc(w), esc(w)), |
| 162 | None => String::new(), |
| 163 | }; |
| 155 | 164 | let html = format!( |
| 156 | 165 | r#"<!doctype html> |
| 157 | 166 | <html lang="en"> |
| | |
| 163 | 172 | <link rel="stylesheet" href="/style.css?v={}"> |
| 164 | 173 | </head> |
| 165 | 174 | <body> |
| 166 | | <header class="top"><div class="wrap"><a class="brand" href="/">{mark}<span>grove</span></a><nav class="crumbs">{trail}</nav></div></header> |
| 175 | <header class="top"><div class="wrap"><a class="brand" href="/">{mark}<span>grove</span></a><nav class="crumbs">{trail}</nav>{account}</div></header> |
| 167 | 176 | <main class="wrap"> |
| 168 | 177 | {body} |
| 169 | 178 | </main> |
| | |
| 176 | 185 | Response::html(200, html) |
| 177 | 186 | } |
| 178 | 187 | |
| 179 | | fn not_found(what: &str) -> Response { |
| 180 | | let mut r = page("not found · grove", &[], &format!(r#"<div class="empty">{}</div>"#, esc(what))); |
| 188 | fn not_found(who: Option<&str>, what: &str) -> Response { |
| 189 | let mut r = page(who, "not found · grove", &[], &format!(r#"<div class="empty">{}</div>"#, esc(what))); |
| 181 | 190 | r.status = 404; |
| 182 | 191 | r |
| 183 | 192 | } |
| | |
| 197 | 206 | pub projects: Vec<Project>, |
| 198 | 207 | names: Names, |
| 199 | 208 | owners: Vec<String>, |
| 209 | /// The signed-in person, if any. |
| 210 | viewer: Option<String>, |
| 211 | /// Of `projects`, the ones the viewer owns, and the ones they can contribute to. |
| 212 | owned: Vec<(String, String)>, |
| 213 | contributing: Vec<(String, String)>, |
| 200 | 214 | } |
| 201 | 215 | |
| 202 | 216 | impl Snapshot { |
| 203 | | /// Public projects only, until the web has sign-in. |
| 204 | | pub fn of(state: &State, only: Option<(&str, &str)>) -> Snapshot { |
| 205 | | let projects = state |
| 206 | | .projects |
| 207 | | .iter() |
| 208 | | .filter(|p| p.public && only.is_none_or(|(o, n)| p.owner == o && p.name == n)) |
| 209 | | .cloned() |
| 210 | | .collect(); |
| 217 | /// Public projects, and the private ones the viewer owns or contributes to. |
| 218 | pub fn of(state: &State, only: Option<(&str, &str)>, viewer: Option<&str>) -> Snapshot { |
| 219 | let can_read = |p: &Project| p.public || viewer.is_some_and(|v| state.can_contribute(&p.owner, &p.name, v)); |
| 220 | let projects: Vec<Project> = |
| 221 | state.projects.iter().filter(|p| only.is_none_or(|(o, n)| p.owner == o && p.name == n) && can_read(p)).cloned().collect(); |
| 222 | let key = |p: &Project| (p.owner.clone(), p.name.clone()); |
| 223 | let (owned, contributing) = match viewer { |
| 224 | Some(v) => ( |
| 225 | projects.iter().filter(|p| state.owns(&p.owner, v)).map(key).collect(), |
| 226 | projects.iter().filter(|p| state.can_contribute(&p.owner, &p.name, v)).map(key).collect(), |
| 227 | ), |
| 228 | None => (Vec::new(), Vec::new()), |
| 229 | }; |
| 211 | 230 | let owners = state.people.iter().map(|p| p.username.clone()).chain(state.groups.iter().map(|g| g.name.clone())).collect(); |
| 212 | | Snapshot { projects, names: Names::of(state), owners } |
| 231 | Snapshot { projects, names: Names::of(state), owners, viewer: viewer.map(str::to_string), owned, contributing } |
| 213 | 232 | } |
| 233 | fn who(&self) -> Option<&str> { |
| 234 | self.viewer.as_deref() |
| 235 | } |
| 236 | fn owns(&self, p: &Project) -> bool { |
| 237 | self.owned.iter().any(|(o, n)| *o == p.owner && *n == p.name) |
| 238 | } |
| 239 | fn contributes(&self, p: &Project) -> bool { |
| 240 | self.contributing.iter().any(|(o, n)| *o == p.owner && *n == p.name) |
| 241 | } |
| 214 | 242 | } |
| 215 | 243 | |
| 216 | 244 | /// Which slice of the state a path needs: one project, or the list. |
| | |
| 223 | 251 | } |
| 224 | 252 | |
| 225 | 253 | pub fn handle(req: &Request, snap: &Snapshot, objects: &Objects) -> Response { |
| 226 | | if req.method != "GET" && req.method != "HEAD" { |
| 227 | | return Response::error(405, "the web pages are read-only; use the API"); |
| 228 | | } |
| 229 | 254 | let segs: Vec<String> = req.path.trim_matches('/').split('/').filter(|s| !s.is_empty()).map(decode).collect(); |
| 230 | 255 | let segs: Vec<&str> = segs.iter().map(String::as_str).collect(); |
| 231 | 256 | let now = now_ms(); |
| | |
| 243 | 268 | [owner] => owner_page(snap, owner, now), |
| 244 | 269 | [o, n, rest @ ..] => { |
| 245 | 270 | let Some(p) = snap.projects.iter().find(|p| p.owner == *o && p.name == *n) else { |
| 246 | | return not_found(&format!("no project {o}/{n}")); |
| 271 | return not_found(snap.who(), &format!("no project {o}/{n}")); |
| 247 | 272 | }; |
| 248 | 273 | match rest { |
| 249 | | [] => project_page(p, &snap.names, objects, now), |
| 250 | | ["changes"] => changes_page(p, &snap.names, now), |
| 274 | [] => project_page(p, snap, objects, now), |
| 275 | ["changes"] => changes_page(p, snap, now), |
| 251 | 276 | ["changes", c] => match c.parse::<u64>().ok().and_then(|c| p.change(c)) { |
| 252 | | Some(c) => change_page(p, c, &snap.names, objects, query(req, "r").and_then(|r| r.parse().ok()), now), |
| 253 | | None => not_found(&format!("no change #{c} in {o}/{n}")), |
| 277 | Some(c) => change_page(p, c, snap, objects, query(req, "r").and_then(|r| r.parse().ok()), query(req, "e").map(decode).as_deref(), now), |
| 278 | None => not_found(snap.who(), &format!("no change #{c} in {o}/{n}")), |
| 254 | 279 | }, |
| 255 | 280 | [at] if at.starts_with('@') => match at[1..].parse::<u64>().ok().and_then(|n| p.point(n)) { |
| 256 | | Some(pt) => point_page(p, pt.n, &snap.names, objects, now), |
| 257 | | None => not_found(&format!("no point {at} in {o}/{n}")), |
| 281 | Some(pt) => point_page(p, pt.n, snap, objects, now), |
| 282 | None => not_found(snap.who(), &format!("no point {at} in {o}/{n}")), |
| 258 | 283 | }, |
| 259 | 284 | ["files", path @ ..] => { |
| 260 | 285 | let at = query(req, "at").and_then(|a| a.trim_start_matches('@').parse().ok()).unwrap_or(p.tip()); |
| 261 | | files_page(p, at, &path.join("/"), objects) |
| 286 | files_page(p, at, &path.join("/"), objects, snap) |
| 262 | 287 | } |
| 263 | 288 | ["raw", snap_hex, path @ ..] => raw(p, snap_hex, &path.join("/"), objects), |
| 264 | 289 | ["evidence", blob] => evidence(p, blob, objects), |
| 265 | | _ => not_found("no such page"), |
| 290 | _ => not_found(snap.who(), "no such page"), |
| 266 | 291 | } |
| 267 | 292 | } |
| 268 | 293 | } |
| | |
| 303 | 328 | } else { |
| 304 | 329 | format!(r#"<h1 class="title">Projects</h1>{}"#, tiles(&list, true, now)) |
| 305 | 330 | }; |
| 306 | | page("grove", &[], &body) |
| 331 | page(snap.who(), "grove", &[], &body) |
| 307 | 332 | } |
| 308 | 333 | |
| 309 | 334 | fn owner_page(snap: &Snapshot, owner: &str, now: i64) -> Response { |
| 310 | 335 | if !snap.owners.iter().any(|o| o == owner) { |
| 311 | | return not_found(&format!("no one called {owner}")); |
| 336 | return not_found(snap.who(), &format!("no one called {owner}")); |
| 312 | 337 | } |
| 313 | 338 | let mut list: Vec<&Project> = snap.projects.iter().filter(|p| p.owner == owner).collect(); |
| 314 | 339 | list.sort_by_key(|p| std::cmp::Reverse(last_activity(p))); |
| | |
| 317 | 342 | } else { |
| 318 | 343 | format!(r#"<h1 class="title">{}</h1>{}"#, esc(owner), tiles(&list, false, now)) |
| 319 | 344 | }; |
| 320 | | page(&format!("{owner} · grove"), &[(String::new(), owner.to_string())], &body) |
| 345 | page(snap.who(), &format!("{owner} · grove"), &[(String::new(), owner.to_string())], &body) |
| 321 | 346 | } |
| 322 | 347 | |
| 323 | 348 | // ── A project ───────────────────────────────────────────── |
| | |
| 347 | 372 | out + "</div>" |
| 348 | 373 | } |
| 349 | 374 | |
| 350 | | fn project_page(p: &Project, names: &Names, objects: &Objects, now: i64) -> Response { |
| 375 | fn project_page(p: &Project, snap: &Snapshot, objects: &Objects, now: i64) -> Response { |
| 376 | let names = &snap.names; |
| 351 | 377 | let base = base_url(p); |
| 352 | 378 | let count = |s: ChangeState| p.changes.iter().filter(|c| c.state == s).count(); |
| 353 | 379 | let open: Vec<&Change> = p.changes.iter().filter(|c| c.state.is_open()).collect(); |
| | |
| 401 | 427 | body += "</div></section><section><h2>Open changes</h2>"; |
| 402 | 428 | body += &change_list(&open, &base, names, now, "No open changes."); |
| 403 | 429 | body += "</section></div>"; |
| 404 | | page(&format!("{}/{} · grove", p.owner, p.name), &project_crumbs(p), &body) |
| 430 | page(snap.who(), &format!("{}/{} · grove", p.owner, p.name), &project_crumbs(p), &body) |
| 405 | 431 | } |
| 406 | 432 | |
| 407 | 433 | fn change_list(list: &[&Change], base: &str, names: &Names, now: i64, empty: &str) -> String { |
| | |
| 427 | 453 | out + "</div>" |
| 428 | 454 | } |
| 429 | 455 | |
| 430 | | fn changes_page(p: &Project, names: &Names, now: i64) -> Response { |
| 456 | fn changes_page(p: &Project, snap: &Snapshot, now: i64) -> Response { |
| 457 | let names = &snap.names; |
| 431 | 458 | let base = base_url(p); |
| 432 | 459 | let mut body = format!(r#"<h1 class="title">Changes</h1>"#); |
| 433 | 460 | for (title, states) in [ |
| | |
| 443 | 470 | } |
| 444 | 471 | let mut crumbs = project_crumbs(p); |
| 445 | 472 | crumbs.push((String::new(), "changes".into())); |
| 446 | | page(&format!("changes · {}/{} · grove", p.owner, p.name), &crumbs, &body) |
| 473 | page(snap.who(), &format!("changes · {}/{} · grove", p.owner, p.name), &crumbs, &body) |
| 447 | 474 | } |
| 448 | 475 | |
| 449 | 476 | // ── Diffs ───────────────────────────────────────────────── |
| | |
| 574 | 601 | |
| 575 | 602 | // ── A change ────────────────────────────────────────────── |
| 576 | 603 | |
| 577 | | fn change_page(p: &Project, c: &Change, names: &Names, objects: &Objects, r: Option<u32>, now: i64) -> Response { |
| 604 | fn change_page(p: &Project, c: &Change, snap: &Snapshot, objects: &Objects, r: Option<u32>, error: Option<&str>, now: i64) -> Response { |
| 605 | let names = &snap.names; |
| 578 | 606 | let base = base_url(p); |
| 579 | 607 | let rev = r.and_then(|n| c.revisions.iter().find(|x| x.number == n)).or_else(|| c.latest()); |
| 580 | 608 | let mut body = format!( |
| | |
| 597 | 625 | let _ = write!(body, " · r{rn} landed as {pts}"); |
| 598 | 626 | } |
| 599 | 627 | body += "</div>"; |
| 628 | if let Some(e) = error { |
| 629 | let _ = write!(body, r#"<div class="error">{}</div>"#, esc(e)); |
| 630 | } |
| 631 | // The owner decides, in person. |
| 632 | if snap.owns(p) && c.state.is_open() { |
| 633 | let action = format!("{base}/changes/{}", c.number); |
| 634 | body += r#"<div class="decide">"#; |
| 635 | if c.state == ChangeState::Proposed { |
| 636 | let _ = write!(body, r#"<form method="post" action="{action}/land"><button class="primary">Land r{}</button></form>"#, c.latest().map(|r| r.number).unwrap_or(0)); |
| 637 | } |
| 638 | let _ = write!(body, r#"<form method="post" action="{action}/decline"><button>Decline</button></form></div>"#); |
| 639 | } |
| 600 | 640 | |
| 601 | 641 | if let Some(rev) = rev { |
| 602 | 642 | body += r#"<nav class="revs">"#; |
| | |
| 673 | 713 | }; |
| 674 | 714 | let _ = write!(body, r#"<div class="event">{} {what} · {}</div>"#, names.actor(by), when(*at, now)); |
| 675 | 715 | } |
| 676 | | body += "</div></section>"; |
| 716 | body += "</div>"; |
| 717 | if snap.contributes(p) { |
| 718 | let _ = write!( |
| 719 | body, |
| 720 | r#"<form class="say" method="post" action="{base}/changes/{}/say"><textarea name="body" rows="4" required placeholder="Reply"></textarea><button>Send</button></form>"#, |
| 721 | c.number |
| 722 | ); |
| 723 | } |
| 724 | body += "</section>"; |
| 677 | 725 | |
| 678 | 726 | let mut crumbs = project_crumbs(p); |
| 679 | 727 | crumbs.push((format!("{base}/changes"), "changes".into())); |
| 680 | 728 | crumbs.push((String::new(), format!("#{}", c.number))); |
| 681 | | page(&format!("#{} {} · {}/{}", c.number, c.intent.lines().next().unwrap_or(""), p.owner, p.name), &crumbs, &body) |
| 729 | page(snap.who(), &format!("#{} {} · {}/{}", c.number, c.intent.lines().next().unwrap_or(""), p.owner, p.name), &crumbs, &body) |
| 682 | 730 | } |
| 683 | 731 | |
| 684 | 732 | // ── A point on main ─────────────────────────────────────── |
| 685 | 733 | |
| 686 | | fn point_page(p: &Project, n: u64, names: &Names, objects: &Objects, now: i64) -> Response { |
| 734 | fn point_page(p: &Project, n: u64, snap: &Snapshot, objects: &Objects, now: i64) -> Response { |
| 687 | 735 | let base = base_url(p); |
| 688 | 736 | let pt = p.point(n).unwrap(); |
| 689 | 737 | let prev = p.point(n - 1).map(|x| x.snapshot); |
| | |
| 696 | 744 | if !rest.join("").trim().is_empty() { |
| 697 | 745 | let _ = write!(body, r#"<div class="prose">{}</div>"#, esc(rest.join("\n").trim())); |
| 698 | 746 | } |
| 699 | | let _ = write!(body, r#"<div class="meta line">by {} · landed by {} · {}"#, names.actor(&pt.author), names.actor(&pt.landed_by), when(pt.at, now)); |
| 747 | let _ = write!(body, r#"<div class="meta line">by {} · landed by {} · {}"#, snap.names.actor(&pt.author), snap.names.actor(&pt.landed_by), when(pt.at, now)); |
| 700 | 748 | if let Some(c) = pt.change { |
| 701 | 749 | let _ = write!(body, r##" · from <a href="{base}/changes/{c}">#{c}</a>"##); |
| 702 | 750 | } |
| | |
| 712 | 760 | body += "</section>"; |
| 713 | 761 | let mut crumbs = project_crumbs(p); |
| 714 | 762 | crumbs.push((String::new(), format!("@{n}"))); |
| 715 | | page(&format!("@{n} {} · {}/{}", pt.message.lines().next().unwrap_or(""), p.owner, p.name), &crumbs, &body) |
| 763 | page(snap.who(), &format!("@{n} {} · {}/{}", pt.message.lines().next().unwrap_or(""), p.owner, p.name), &crumbs, &body) |
| 716 | 764 | } |
| 717 | 765 | |
| 718 | 766 | // ── Files ───────────────────────────────────────────────── |
| | |
| 743 | 791 | Some(cur) |
| 744 | 792 | } |
| 745 | 793 | |
| 746 | | fn files_page(p: &Project, at: u64, path: &str, objects: &Objects) -> Response { |
| 794 | fn files_page(p: &Project, at: u64, path: &str, objects: &Objects, snap: &Snapshot) -> Response { |
| 795 | let who = snap.who(); |
| 747 | 796 | let base = base_url(p); |
| 748 | | let Some(pt) = p.point(at) else { return not_found("nothing on main yet") }; |
| 749 | | let Some((kind, id)) = lookup(objects, &pt.snapshot, path) else { return not_found(&format!("no {path} at @{at}")) }; |
| 797 | let Some(pt) = p.point(at) else { return not_found(who, "nothing on main yet") }; |
| 798 | let Some((kind, id)) = lookup(objects, &pt.snapshot, path) else { return not_found(who, &format!("no {path} at @{at}")) }; |
| 750 | 799 | let q = if at == p.tip() { String::new() } else { format!("?at={at}") }; |
| 751 | 800 | let mut trail = format!(r#"<a href="{base}/files{q}">{}</a>"#, esc(&p.name)); |
| 752 | 801 | let mut so_far = String::new(); |
| | |
| 782 | 831 | } |
| 783 | 832 | let mut crumbs = project_crumbs(p); |
| 784 | 833 | crumbs.push((String::new(), "files".into())); |
| 785 | | page(&format!("{} · {}/{}", if path.is_empty() { "files" } else { path }, p.owner, p.name), &crumbs, &body) |
| 834 | page(who, &format!("{} · {}/{}", if path.is_empty() { "files" } else { path }, p.owner, p.name), &crumbs, &body) |
| 786 | 835 | } |
| 787 | 836 | |
| 788 | 837 | /// Raw bytes may be anything someone stored; never let them run as a page on this site. |
| | |
| 793 | 842 | } |
| 794 | 843 | |
| 795 | 844 | fn raw(p: &Project, snap_hex: &str, path: &str, objects: &Objects) -> Response { |
| 796 | | let Some(id) = hex::decode(snap_hex).and_then(|b| <Id>::try_from(b).ok()) else { return not_found("bad snapshot") }; |
| 845 | let Some(id) = hex::decode(snap_hex).and_then(|b| <Id>::try_from(b).ok()) else { return not_found(None, "bad snapshot") }; |
| 797 | 846 | // Only snapshots that belong to this project: points, and steps of its changes. |
| 798 | 847 | let known = p.points.iter().any(|pt| pt.snapshot == id) || p.changes.iter().flat_map(|c| &c.revisions).flat_map(|r| &r.steps).any(|s| s.snapshot == id); |
| 799 | 848 | if !known { |
| 800 | | return not_found("no such snapshot in this project"); |
| 849 | return not_found(None, "no such snapshot in this project"); |
| 801 | 850 | } |
| 802 | 851 | match lookup(objects, &id, path) { |
| 803 | 852 | Some((k, blob)) if k != Kind::Dir => match objects.get_blob(&blob) { |
| | |
| 807 | 856 | } |
| 808 | 857 | Err(e) => Response::error(500, &e.to_string()), |
| 809 | 858 | }, |
| 810 | | _ => not_found(&format!("no file {path}")), |
| 859 | _ => not_found(None, &format!("no file {path}")), |
| 811 | 860 | } |
| 812 | 861 | } |
| 813 | 862 | |
| 814 | 863 | fn evidence(p: &Project, blob_hex: &str, objects: &Objects) -> Response { |
| 815 | | let Some(id) = hex::decode(blob_hex).and_then(|b| <Id>::try_from(b).ok()) else { return not_found("bad id") }; |
| 816 | | let Some(e) = p.changes.iter().flat_map(|c| &c.evidence).find(|e| e.blob == Some(id)) else { return not_found("no such evidence") }; |
| 864 | let Some(id) = hex::decode(blob_hex).and_then(|b| <Id>::try_from(b).ok()) else { return not_found(None, "bad id") }; |
| 865 | let Some(e) = p.changes.iter().flat_map(|c| &c.evidence).find(|e| e.blob == Some(id)) else { return not_found(None, "no such evidence") }; |
| 817 | 866 | match objects.get_blob(&id) { |
| 818 | 867 | Ok(bytes) => { |
| 819 | 868 | // Stored media types are whatever the agent said; keep them to something a browser |