grove

#11 Sign in on the web and decide from the change page

landed
proposed by claude for antonky · · r1 landed as @12
grove web asks the server (in person) for a one-time link valid five minutes; opening it issues an ordinary person token kept in an HttpOnly, Secure, SameSite=Lax cookie, and signing out revokes it. Signed in, private projects you own or contribute to show; owners get Land and Decline, contributors a reply form. Forms are refused unless Origin matches the host.
Web sign-in with one-time links; land, decline and reply from the change page

Files

src/api.rsmodified+110 −5
1010use crate::objects::{Id, Objects};
1111use crate::store::{self, Content, Store};
1212use crate::{hex, wire};
13use std::collections::HashMap;
1314use std::sync::Mutex;
1415
1516pub struct Api {
1617 store: Mutex<Store>,
1718 /// Object reads and writes don't need the store's lock.
1819 objects: Objects,
20 /// One-time web sign-in codes: code → (person, expiry in ms). Kept in memory; a restart
21 /// only means asking for a new link.
22 links: Mutex<HashMap<String, (String, i64)>>,
1923}
2024
25/// The cookie holding a web session: a person token, like any other.
26const COOKIE: &str = "grove";
27const LINK_TTL_MS: i64 = 5 * 60 * 1000;
28
2129type Out = Result<Response, Response>;
2230
31fn redirect(to: &str) -> Response {
32 Response::with(303, "text/plain; charset=utf-8", Vec::new()).header("Location", to.to_string())
33}
34
2335fn err(status: u16, msg: impl AsRef<str>) -> Response {
2436 Response::error(status, msg.as_ref())
2537}
6375impl Api {
6476 pub fn new(store: Store) -> std::io::Result<Api> {
6577 let objects = Objects::open(&store.dir().join("objects")).map_err(|e| std::io::Error::other(e.to_string()))?;
66 Ok(Api { store: Mutex::new(store), objects })
78 Ok(Api { store: Mutex::new(store), objects, links: Mutex::new(HashMap::new()) })
6779 }
6880
6981 /// The store, even if an earlier request panicked while holding it. Store writes are
7486
7587 pub fn handle(&self, req: Request) -> Response {
7688 if req.path != "/api" && !req.path.starts_with("/api/") {
77 // The web pages: copy out what the page needs, then render without the lock.
78 let only = crate::web::wants(&req.path);
79 let snap = crate::web::Snapshot::of(self.store().state(), only.as_ref().map(|(o, n)| (o.as_str(), n.as_str())));
80 return crate::web::handle(&req, &snap, &self.objects);
89 return self.web(&req);
8190 }
8291 match self.route(&req) {
8392 Ok(r) | Err(r) => r,
8493 }
8594 }
8695
96 // ── The web pages ──────────────────────────────────────
97
98 /// The signed-in person and their session secret, from the cookie. Only person tokens.
99 fn viewer(&self, req: &Request) -> Option<(String, String)> {
100 let cookies = req.header("cookie")?;
101 let secret = cookies.split(';').find_map(|c| c.trim().strip_prefix(COOKIE).and_then(|v| v.strip_prefix('=')))?;
102 match self.store().authenticate(secret) {
103 Some(Actor { person, agent: None }) => Some((person, secret.to_string())),
104 _ => None,
105 }
106 }
107
108 fn web(&self, req: &Request) -> Response {
109 let viewer = self.viewer(req);
110 if req.method == "POST" {
111 return self.web_post(req, viewer);
112 }
113 if req.path == "/signin" {
114 return self.signin(req);
115 }
116 // Copy out what the page needs, then render without the lock.
117 let only = crate::web::wants(&req.path);
118 let snap = crate::web::Snapshot::of(self.store().state(), only.as_ref().map(|(o, n)| (o.as_str(), n.as_str())), viewer.as_ref().map(|(p, _)| p.as_str()));
119 crate::web::handle(req, &snap, &self.objects)
120 }
121
122 fn signin(&self, req: &Request) -> Response {
123 let code = crate::web::param(&req.query, "code").unwrap_or("");
124 let entry = self.links.lock().unwrap_or_else(|p| p.into_inner()).remove(code);
125 let person = match entry {
126 Some((person, exp)) if exp > store::now_ms() => person,
127 _ => return Response::html(400, "<!doctype html><p>That sign-in link has expired or was used. Run <code>grove web</code> for a new one.</p>".into()),
128 };
129 match self.store().issue_token(&person, None) {
130 Ok(secret) => redirect("/").header("Set-Cookie", format!("{COOKIE}={secret}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=31536000")),
131 Err(e) => err(500, e.to_string()),
132 }
133 }
134
135 /// Forms on the pages. Same-site only: the browser must say where the form came from.
136 fn web_post(&self, req: &Request, viewer: Option<(String, String)>) -> Response {
137 let host = req.header("host").unwrap_or("");
138 let origin = req.header("origin").or(req.header("referer")).unwrap_or("");
139 let origin_host = origin.split("://").nth(1).unwrap_or("").split('/').next().unwrap_or("");
140 if host.is_empty() || origin_host != host {
141 return err(403, "forms only work from grove's own pages");
142 }
143 let Some((person, secret)) = viewer else { return err(401, "sign in first: run `grove web`") };
144 if req.path == "/signout" {
145 let _ = self.store().revoke_token(&person, &secret);
146 return redirect("/").header("Set-Cookie", format!("{COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"));
147 }
148 let segs: Vec<String> = req.path.trim_matches('/').split('/').map(crate::web::decode).collect();
149 let [owner, name, changes, number, action] = segs.as_slice() else { return err(404, "no such form") };
150 let Ok(number) = number.parse::<u64>() else { return err(404, "no such change") };
151 if changes != "changes" {
152 return err(404, "no such form");
153 }
154 let form = String::from_utf8_lossy(&req.body).into_owned();
155 let me = Actor::person(&person);
156 let result = {
157 let mut store = self.store();
158 match action.as_str() {
159 "say" => {
160 let text = crate::web::param(&form, "body").map(crate::web::decode).unwrap_or_default().replace("\r\n", "\n");
161 if text.trim().is_empty() {
162 Err(store::Error::Refused("say something".into()))
163 } else {
164 store.say(&me, owner, name, number, text.trim())
165 }
166 }
167 "land" => store.land(&me, owner, name, number).map(|_| ()),
168 "decline" => store.set_state(&me, owner, name, number, ChangeState::Declined),
169 _ => return err(404, "no such form"),
170 }
171 };
172 let back = format!("/{}/{}/changes/{number}", crate::web::enc(owner), crate::web::enc(name));
173 match result {
174 Ok(()) => redirect(&back),
175 Err(e) => redirect(&format!("{back}?e={}", crate::web::enc(&e.to_string()))),
176 }
177 }
178
87179 fn actor(&self, req: &Request) -> Result<Option<Actor>, Response> {
88180 let Some(h) = req.header("authorization") else { return Ok(None) };
89181 let token = h.strip_prefix("Bearer ").ok_or_else(|| err(401, "use Authorization: Bearer <token>"))?;
184276 let name = field(&body(req)?, "name")?.to_string();
185277 let id = self.store().add_agent(&a.person, &name).map_err(from_store)?;
186278 ok(obj([("id", id.into())]))
279 }
280 ("POST", ["api", "web", "link"]) => {
281 // A one-time link that signs the person in on the web, asked for in person.
282 let a = self.required(req)?;
283 if a.agent.is_some() {
284 return Err(err(403, "web sign-in links are asked for in person"));
285 }
286 let code = crate::random::hex(16);
287 let mut links = self.links.lock().unwrap_or_else(|p| p.into_inner());
288 let now = store::now_ms();
289 links.retain(|_, (_, exp)| *exp > now);
290 links.insert(code.clone(), (a.person, now + LINK_TTL_MS));
291 ok(obj([("path", format!("/signin?code={code}").into())]))
187292 }
188293 ("POST", ["api", "tokens"]) => {
189294 let a = self.required(req)?;
src/cli.rsmodified+18 −0
292292 "join" => join(&args),
293293 "invite" => invite(&args),
294294 "accept" => accept(&args),
295 "web" => web(),
295296 "watch" => watch(&args),
296297 "init" => init(&args),
297298 "checkout" => checkout(&args),
330331 accept CODE use a project invite on an account you have
331332 invite [OWNER/NAME | --server] invite someone (in person): to this project, or just to join
332333 login <server> --token T [--person-token P] work against a grove server
334 web sign in on the server's web pages (in person)
333335 init <name> [--owner GROUP] [--private] [-m M] make this folder a new project
334336 checkout <owner/name> [DIR] get a project's files
335337 status what changed since the last step
464466 None => eprintln!("invite to join (works once):"),
465467 }
466468 println!("{code}");
469 Ok(())
470}
471
472/// Open a one-time sign-in link for the server's web pages, asked for with the person's token.
473fn web() -> Result<()> {
474 let cfg = Config::load()?;
475 let server = cfg.server.as_deref().ok_or("no server: run `grove login <server>` first")?;
476 let token = cfg.person_token.clone().or_else(|| cfg.agent.is_none().then(|| cfg.token.clone()).flatten()).ok_or("signing in on the web is done in person, and there's no person token")?;
477 let reply = crate::client::Client::new(server, Some(token)).post("/api/web/link", &Value::Object(Vec::new()))?;
478 let path = reply.get("path").and_then(Value::as_str).ok_or("the server sent no link")?;
479 let url = format!("{}{path}", server.trim_end_matches('/'));
480 let opener = if cfg!(target_os = "macos") { "open" } else { "xdg-open" };
481 if !std::process::Command::new(opener).arg(&url).status().is_ok_and(|s| s.success()) {
482 println!("{url}");
483 }
484 eprintln!("sign-in link opened; it works once, for five minutes");
467485 Ok(())
468486}
469487
src/http.rsmodified+1 −0
6060 match status {
6161 200 => "OK",
6262 302 => "Found",
63 303 => "See Other",
6364 400 => "Bad Request",
6465 401 => "Unauthorized",
6566 403 => "Forbidden",
src/store.rsmodified+5 −0
202202 self.state.token_actor(&token_hash(secret))
203203 }
204204
205 /// Revoke one of `person`'s tokens by its secret.
206 pub fn revoke_token(&mut self, person: &str, secret: &str) -> Result<()> {
207 self.write(&Actor::person(person), Self::rec("token.revoke", vec![("hash", token_hash(secret).into())]))
208 }
209
205210 pub fn revoke_agent(&mut self, person: &str, id: &str) -> Result<()> {
206211 self.write(&Actor::person(person), Self::rec("agent.revoke", vec![("id", id.into())]))
207212 }
src/web.rsmodified+99 −50
3333}
3434
3535/// A path segment or query value, percent-encoded.
36fn enc(s: &str) -> String {
36pub fn enc(s: &str) -> String {
3737 let mut out = String::new();
3838 for b in s.bytes() {
3939 if b.is_ascii_alphanumeric() || b"-._~".contains(&b) {
5050 p.split('/').map(enc).collect::<Vec<_>>().join("/")
5151}
5252
53fn decode(s: &str) -> String {
53pub fn decode(s: &str) -> String {
5454 let b = s.as_bytes();
5555 let mut out = Vec::with_capacity(b.len());
5656 let mut i = 0;
6868 String::from_utf8_lossy(&out).into_owned()
6969}
7070
71/// A value from a query string or a form body (`a=1&b=2`), still encoded.
72pub 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
7176fn 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)
7378}
7479
7580fn now_ms() -> i64 {
140145
141146// ── The page shell ────────────────────────────────────────
142147
143fn page(title: &str, crumbs: &[(String, String)], body: &str) -> Response {
148fn page(who: Option<&str>, title: &str, crumbs: &[(String, String)], body: &str) -> Response {
144149 let day = twig::today();
145150 let mark = twig::svg(day, false, "var(--accent)", "var(--halo)", twig::LINE_WIDTH, Some(40));
146151 let mut trail = String::new();
152157 let _ = write!(trail, r#"<a href="{href}">{}</a>"#, esc(label));
153158 }
154159 }
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 };
155164 let html = format!(
156165 r#"<!doctype html>
157166<html lang="en">
163172<link rel="stylesheet" href="/style.css?v={}">
164173</head>
165174<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>
167176<main class="wrap">
168177{body}
169178</main>
176185 Response::html(200, html)
177186}
178187
179fn not_found(what: &str) -> Response {
180 let mut r = page("not found · grove", &[], &format!(r#"<div class="empty">{}</div>"#, esc(what)));
188fn 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)));
181190 r.status = 404;
182191 r
183192}
197206 pub projects: Vec<Project>,
198207 names: Names,
199208 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)>,
200214}
201215
202216impl 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 };
211230 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 }
213232 }
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 }
214242}
215243
216244/// Which slice of the state a path needs: one project, or the list.
223251}
224252
225253pub 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 }
229254 let segs: Vec<String> = req.path.trim_matches('/').split('/').filter(|s| !s.is_empty()).map(decode).collect();
230255 let segs: Vec<&str> = segs.iter().map(String::as_str).collect();
231256 let now = now_ms();
243268 [owner] => owner_page(snap, owner, now),
244269 [o, n, rest @ ..] => {
245270 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}"));
247272 };
248273 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),
251276 ["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}")),
254279 },
255280 [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}")),
258283 },
259284 ["files", path @ ..] => {
260285 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)
262287 }
263288 ["raw", snap_hex, path @ ..] => raw(p, snap_hex, &path.join("/"), objects),
264289 ["evidence", blob] => evidence(p, blob, objects),
265 _ => not_found("no such page"),
290 _ => not_found(snap.who(), "no such page"),
266291 }
267292 }
268293 }
303328 } else {
304329 format!(r#"<h1 class="title">Projects</h1>{}"#, tiles(&list, true, now))
305330 };
306 page("grove", &[], &body)
331 page(snap.who(), "grove", &[], &body)
307332}
308333
309334fn owner_page(snap: &Snapshot, owner: &str, now: i64) -> Response {
310335 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}"));
312337 }
313338 let mut list: Vec<&Project> = snap.projects.iter().filter(|p| p.owner == owner).collect();
314339 list.sort_by_key(|p| std::cmp::Reverse(last_activity(p)));
317342 } else {
318343 format!(r#"<h1 class="title">{}</h1>{}"#, esc(owner), tiles(&list, false, now))
319344 };
320 page(&format!("{owner} · grove"), &[(String::new(), owner.to_string())], &body)
345 page(snap.who(), &format!("{owner} · grove"), &[(String::new(), owner.to_string())], &body)
321346}
322347
323348// ── A project ─────────────────────────────────────────────
347372 out + "</div>"
348373}
349374
350fn project_page(p: &Project, names: &Names, objects: &Objects, now: i64) -> Response {
375fn project_page(p: &Project, snap: &Snapshot, objects: &Objects, now: i64) -> Response {
376 let names = &snap.names;
351377 let base = base_url(p);
352378 let count = |s: ChangeState| p.changes.iter().filter(|c| c.state == s).count();
353379 let open: Vec<&Change> = p.changes.iter().filter(|c| c.state.is_open()).collect();
401427 body += "</div></section><section><h2>Open changes</h2>";
402428 body += &change_list(&open, &base, names, now, "No open changes.");
403429 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)
405431}
406432
407433fn change_list(list: &[&Change], base: &str, names: &Names, now: i64, empty: &str) -> String {
427453 out + "</div>"
428454}
429455
430fn changes_page(p: &Project, names: &Names, now: i64) -> Response {
456fn changes_page(p: &Project, snap: &Snapshot, now: i64) -> Response {
457 let names = &snap.names;
431458 let base = base_url(p);
432459 let mut body = format!(r#"<h1 class="title">Changes</h1>"#);
433460 for (title, states) in [
443470 }
444471 let mut crumbs = project_crumbs(p);
445472 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)
447474}
448475
449476// ── Diffs ─────────────────────────────────────────────────
574601
575602// ── A change ──────────────────────────────────────────────
576603
577fn change_page(p: &Project, c: &Change, names: &Names, objects: &Objects, r: Option<u32>, now: i64) -> Response {
604fn change_page(p: &Project, c: &Change, snap: &Snapshot, objects: &Objects, r: Option<u32>, error: Option<&str>, now: i64) -> Response {
605 let names = &snap.names;
578606 let base = base_url(p);
579607 let rev = r.and_then(|n| c.revisions.iter().find(|x| x.number == n)).or_else(|| c.latest());
580608 let mut body = format!(
597625 let _ = write!(body, " · r{rn} landed as {pts}");
598626 }
599627 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 }
600640
601641 if let Some(rev) = rev {
602642 body += r#"<nav class="revs">"#;
673713 };
674714 let _ = write!(body, r#"<div class="event">{} {what} · {}</div>"#, names.actor(by), when(*at, now));
675715 }
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>";
677725
678726 let mut crumbs = project_crumbs(p);
679727 crumbs.push((format!("{base}/changes"), "changes".into()));
680728 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)
682730}
683731
684732// ── A point on main ───────────────────────────────────────
685733
686fn point_page(p: &Project, n: u64, names: &Names, objects: &Objects, now: i64) -> Response {
734fn point_page(p: &Project, n: u64, snap: &Snapshot, objects: &Objects, now: i64) -> Response {
687735 let base = base_url(p);
688736 let pt = p.point(n).unwrap();
689737 let prev = p.point(n - 1).map(|x| x.snapshot);
696744 if !rest.join("").trim().is_empty() {
697745 let _ = write!(body, r#"<div class="prose">{}</div>"#, esc(rest.join("\n").trim()));
698746 }
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));
700748 if let Some(c) = pt.change {
701749 let _ = write!(body, r##" · from <a href="{base}/changes/{c}">#{c}</a>"##);
702750 }
712760 body += "</section>";
713761 let mut crumbs = project_crumbs(p);
714762 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)
716764}
717765
718766// ── Files ─────────────────────────────────────────────────
743791 Some(cur)
744792}
745793
746fn files_page(p: &Project, at: u64, path: &str, objects: &Objects) -> Response {
794fn files_page(p: &Project, at: u64, path: &str, objects: &Objects, snap: &Snapshot) -> Response {
795 let who = snap.who();
747796 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}")) };
750799 let q = if at == p.tip() { String::new() } else { format!("?at={at}") };
751800 let mut trail = format!(r#"<a href="{base}/files{q}">{}</a>"#, esc(&p.name));
752801 let mut so_far = String::new();
782831 }
783832 let mut crumbs = project_crumbs(p);
784833 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)
786835}
787836
788837/// Raw bytes may be anything someone stored; never let them run as a page on this site.
793842}
794843
795844fn 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") };
797846 // Only snapshots that belong to this project: points, and steps of its changes.
798847 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);
799848 if !known {
800 return not_found("no such snapshot in this project");
849 return not_found(None, "no such snapshot in this project");
801850 }
802851 match lookup(objects, &id, path) {
803852 Some((k, blob)) if k != Kind::Dir => match objects.get_blob(&blob) {
807856 }
808857 Err(e) => Response::error(500, &e.to_string()),
809858 },
810 _ => not_found(&format!("no file {path}")),
859 _ => not_found(None, &format!("no file {path}")),
811860 }
812861}
813862
814863fn 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") };
817866 match objects.get_blob(&id) {
818867 Ok(bytes) => {
819868 // Stored media types are whatever the agent said; keep them to something a browser
src/web/style.cssmodified+13 −0
5959.state.declined, .state.withdrawn { color: #a05050; }
6060.behind { color: #a05050; }
6161
62/* Signing in and deciding */
63.account { margin-left: auto; display: flex; align-items: center; gap: 12px; font-size: 12px; }
64.account button, .decide button, .say button { font: inherit; font-size: 12px; padding: 4px 12px; border: 1px solid var(--line); background: var(--surface); color: var(--ink); cursor: pointer; }
65.account button { padding: 2px 8px; color: var(--muted); }
66button:hover { border-color: var(--ink); }
67button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
68button.primary:hover { background: #3d7a68; }
69.decide { display: flex; gap: 8px; margin: 4px 0 12px; }
70.error { border: 1px solid #a05050; color: #a05050; background: #f5e8e8; padding: 8px 12px; margin: 8px 0; font-size: 12px; }
71.say { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
72.say textarea { font: inherit; font-size: 13px; padding: 8px 10px; border: 1px solid var(--line); background: var(--surface); resize: vertical; }
73.say button { align-self: flex-start; }
74
6275/* Tiles: one hairline between every tile. */
6376.tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 1px; background: var(--line-2); border: 1px solid var(--line-2); margin-top: 16px; }
6477.tile { display: flex; flex-direction: column; gap: 4px; min-width: 0; padding: 10px 12px; background: var(--surface); }

Discussion

claude for antonky opened the change ·
claude for antonky sent r1 ·
claude for antonky marked it proposed ·
antonky landed r1 as @12 ·