grove
at @12
1//! The HTTP API: the store's operations, with the actor taken from the request's token.
2//!
3//! `Authorization: Bearer <token>`. A person's token can do anything that person can; an
4//! agent's token acts as that agent, so the model refuses it the decisions a person must make
5//! in person (landing, declining, groups, tokens). Public projects can be read without a token.
6
7use crate::http::{Request, Response};
8use crate::json::{self, obj, Value};
9use crate::model::{Actor, ChangeState, Step};
10use crate::objects::{Id, Objects};
11use crate::store::{self, Content, Store};
12use crate::{hex, wire};
13use std::collections::HashMap;
14use std::sync::Mutex;
15
16pub struct Api {
17 store: Mutex<Store>,
18 /// Object reads and writes don't need the store's lock.
19 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)>>,
23}
24
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
29type Out = Result<Response, Response>;
30
31fn redirect(to: &str) -> Response {
32 Response::with(303, "text/plain; charset=utf-8", Vec::new()).header("Location", to.to_string())
33}
34
35fn err(status: u16, msg: impl AsRef<str>) -> Response {
36 Response::error(status, msg.as_ref())
37}
38
39fn from_store(e: store::Error) -> Response {
40 match e {
41 store::Error::Refused(m) => err(400, m),
42 store::Error::Behind { .. } => err(409, e.to_string()),
43 other => err(500, other.to_string()),
44 }
45}
46
47fn body(req: &Request) -> Result<Value, Response> {
48 if req.body.is_empty() {
49 return Ok(Value::Object(Vec::new()));
50 }
51 let text = std::str::from_utf8(&req.body).map_err(|_| err(400, "body isn't UTF-8"))?;
52 json::parse(text).map_err(|e| err(400, e.to_string()))
53}
54
55fn field<'a>(v: &'a Value, k: &str) -> Result<&'a str, Response> {
56 v.get(k).and_then(Value::as_str).ok_or_else(|| err(400, format!("missing {k}")))
57}
58
59fn num(v: &Value, k: &str) -> Result<u64, Response> {
60 v.get(k).and_then(Value::as_i64).filter(|n| *n >= 0).map(|n| n as u64).ok_or_else(|| err(400, format!("missing {k}")))
61}
62
63fn parse_id(h: &str) -> Result<Id, Response> {
64 hex::decode(h).and_then(|b| b.try_into().ok()).ok_or_else(|| err(400, format!("{h:?} isn't an id")))
65}
66
67fn ids(v: &Value, k: &str) -> Result<Vec<Id>, Response> {
68 v.get(k).and_then(Value::as_array).ok_or_else(|| err(400, format!("missing {k}")))?.iter().map(|x| parse_id(x.as_str().unwrap_or(""))).collect()
69}
70
71fn ok(v: Value) -> Out {
72 Ok(Response::json(200, &v))
73}
74
75impl Api {
76 pub fn new(store: Store) -> std::io::Result<Api> {
77 let objects = Objects::open(&store.dir().join("objects")).map_err(|e| std::io::Error::other(e.to_string()))?;
78 Ok(Api { store: Mutex::new(store), objects, links: Mutex::new(HashMap::new()) })
79 }
80
81 /// The store, even if an earlier request panicked while holding it. Store writes are
82 /// all-or-nothing, so a panic can't leave it half-changed.
83 fn store(&self) -> std::sync::MutexGuard<'_, Store> {
84 self.store.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
85 }
86
87 pub fn handle(&self, req: Request) -> Response {
88 if req.path != "/api" && !req.path.starts_with("/api/") {
89 return self.web(&req);
90 }
91 match self.route(&req) {
92 Ok(r) | Err(r) => r,
93 }
94 }
95
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
179 fn actor(&self, req: &Request) -> Result<Option<Actor>, Response> {
180 let Some(h) = req.header("authorization") else { return Ok(None) };
181 let token = h.strip_prefix("Bearer ").ok_or_else(|| err(401, "use Authorization: Bearer <token>"))?;
182 let store = self.store();
183 store.authenticate(token.trim()).map(Some).ok_or_else(|| err(401, "unknown or revoked token"))
184 }
185
186 fn required(&self, req: &Request) -> Result<Actor, Response> {
187 self.actor(req)?.ok_or_else(|| err(401, "sign in: send a token"))
188 }
189
190 fn route(&self, req: &Request) -> Out {
191 let segs: Vec<&str> = req.path.trim_matches('/').split('/').collect();
192 let m = req.method.as_str();
193 match (m, segs.as_slice()) {
194 ("GET", ["api", "health"]) => ok(obj([("ok", true.into())])),
195 ("GET", ["api", "whoami"]) => {
196 let a = self.required(req)?;
197 let store = self.store();
198 let agent_name = a.agent.as_ref().and_then(|id| store.state().agent(id)).map(|g| g.name.clone());
199 ok(obj([("person", a.person.clone().into()), ("agent", a.agent.clone().into()), ("agent_name", agent_name.into())]))
200 }
201
202 // ── Objects ──
203 ("POST", ["api", "objects", "missing"]) => {
204 self.required(req)?;
205 let wanted = ids(&body(req)?, "ids")?;
206 let missing: Vec<Value> = wanted.iter().filter(|i| !self.objects.has(i)).map(|i| Value::Str(hex::encode(i))).collect();
207 ok(obj([("missing", Value::Array(missing))]))
208 }
209 ("POST", ["api", "objects"]) => {
210 self.required(req)?;
211 let mut stored = 0u64;
212 for raw in wire::unframe_objects(&req.body).map_err(|e| err(400, e))? {
213 self.objects.put_raw(raw).map_err(|e| err(400, e.to_string()))?;
214 stored += 1;
215 }
216 ok(obj([("stored", stored.into())]))
217 }
218 ("POST", ["api", "objects", "list"]) => {
219 let root = parse_id(field(&body(req)?, "root")?)?;
220 let all = self.objects.ids_under(&root).map_err(|e| err(404, e.to_string()))?;
221 ok(obj([("ids", Value::Array(all.iter().map(|i| Value::Str(hex::encode(i))).collect()))]))
222 }
223 ("POST", ["api", "objects", "fetch"]) => {
224 let wanted = ids(&body(req)?, "ids")?;
225 let raws = wanted.iter().map(|i| self.objects.get_raw(i)).collect::<Result<Vec<_>, _>>().map_err(|e| err(404, e.to_string()))?;
226 Ok(Response::bytes(wire::frame_objects(&raws)))
227 }
228
229 // ── People, agents, tokens, groups ──
230 ("POST", ["api", "people"]) => {
231 // Joining takes an invite: a person, their own token, and optionally an agent
232 // with its token. A project invite also makes them a contributor.
233 let b = body(req)?;
234 let username = field(&b, "username")?.to_string();
235 let invite = field(&b, "invite")?.to_string();
236 let display = b.get("display_name").and_then(Value::as_str).unwrap_or(&username).to_string();
237 let agent_name = b.get("agent").and_then(Value::as_str).map(str::to_string);
238 let mut store = self.store();
239 if !store.invite_open(&invite) {
240 return Err(err(403, "that invite doesn't exist or was already used"));
241 }
242 store.add_person(&username, &display).map_err(from_store)?;
243 store.use_invite(&username, &invite).map_err(from_store)?;
244 let person_token = store.issue_token(&username, None).map_err(from_store)?;
245 let (agent, agent_token) = match agent_name {
246 Some(name) => {
247 let id = store.add_agent(&username, &name).map_err(from_store)?;
248 let t = store.issue_token(&username, Some(&id)).map_err(from_store)?;
249 (Value::from(id), Value::from(t))
250 }
251 None => (Value::Null, Value::Null),
252 };
253 ok(obj([("person", username.into()), ("person_token", person_token.into()), ("agent", agent), ("agent_token", agent_token)]))
254 }
255 ("POST", ["api", "invites"]) => {
256 let a = self.required(req)?;
257 let b = body(req)?;
258 let project = match (b.get("owner").and_then(Value::as_str), b.get("project").and_then(Value::as_str)) {
259 (Some(o), Some(n)) => Some((o.to_string(), n.to_string())),
260 _ => None,
261 };
262 let code = self.store().create_invite(&a, project.as_ref().map(|(o, n)| (o.as_str(), n.as_str()))).map_err(from_store)?;
263 ok(obj([("invite", code.into())]))
264 }
265 ("POST", ["api", "invites", "accept"]) => {
266 let a = self.required(req)?;
267 let code = field(&body(req)?, "invite")?.to_string();
268 let project = self.store().use_invite(&a.person, &code).map_err(from_store)?;
269 ok(obj([("project", project.map(|(o, n)| format!("{o}/{n}")).into())]))
270 }
271 ("POST", ["api", "agents"]) => {
272 let a = self.required(req)?;
273 if a.agent.is_some() {
274 return Err(err(403, "agents are registered in person"));
275 }
276 let name = field(&body(req)?, "name")?.to_string();
277 let id = self.store().add_agent(&a.person, &name).map_err(from_store)?;
278 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())]))
292 }
293 ("POST", ["api", "tokens"]) => {
294 let a = self.required(req)?;
295 if a.agent.is_some() {
296 return Err(err(403, "tokens are issued in person"));
297 }
298 let b = body(req)?;
299 let agent = b.get("agent").and_then(Value::as_str).map(str::to_string);
300 let token = self.store().issue_token(&a.person, agent.as_deref()).map_err(from_store)?;
301 ok(obj([("token", token.into())]))
302 }
303 ("POST", ["api", "groups"]) => {
304 let a = self.required(req)?;
305 let name = field(&body(req)?, "name")?.to_string();
306 self.store().create_group(&a, &name).map_err(from_store)?;
307 ok(obj([("name", name.into())]))
308 }
309 ("POST", ["api", "groups", group, "members"]) => {
310 let a = self.required(req)?;
311 let person = field(&body(req)?, "person")?.to_string();
312 self.store().add_member(&a, group, &person).map_err(from_store)?;
313 ok(obj([("added", person.into())]))
314 }
315
316 // ── Projects ──
317 ("POST", ["api", "projects"]) => {
318 let a = self.required(req)?;
319 let b = body(req)?;
320 let owner = b.get("owner").and_then(Value::as_str).unwrap_or(&a.person).to_string();
321 let name = field(&b, "name")?.to_string();
322 let public = !matches!(b.get("public"), Some(Value::Bool(false)));
323 self.store().create_project(&a, &owner, &name, public).map_err(from_store)?;
324 ok(obj([("owner", owner.into()), ("name", name.into())]))
325 }
326 (_, ["api", "projects", owner, name, rest @ ..]) => self.project_route(req, m, owner, name, rest),
327 _ => Err(err(404, "no such endpoint")),
328 }
329 }
330
331 fn project_route(&self, req: &Request, m: &str, owner: &str, name: &str, rest: &[&str]) -> Out {
332 let actor = self.actor(req)?;
333 let mut store = self.store();
334 {
335 let p = store.project(owner, name).map_err(|_| err(404, format!("no project {owner}/{name}")))?;
336 let can_read = p.public || actor.as_ref().is_some_and(|a| store.state().owns(&p.owner, &a.person));
337 if !can_read {
338 return Err(err(404, format!("no project {owner}/{name}")));
339 }
340 }
341 if m == "GET" && rest.is_empty() {
342 let p = store.project(owner, name).unwrap();
343 return ok(wire::project_json(store.state(), p));
344 }
345 let a = actor.ok_or_else(|| err(401, "sign in: send a token"))?;
346 let b = body(req)?;
347 match (m, rest) {
348 ("POST", ["points"]) => {
349 let snapshot = parse_id(field(&b, "snapshot")?)?;
350 let message = field(&b, "message")?.to_string();
351 let n = store.record_point(&a, owner, name, &snapshot, &message, None).map_err(from_store)?;
352 ok(obj([("n", n.into())]))
353 }
354 ("POST", ["move"]) => {
355 let to = field(&b, "to")?.to_string();
356 store.move_project(&a, owner, name, &to).map_err(from_store)?;
357 ok(obj([("owner", to.into())]))
358 }
359 ("POST", ["changes"]) => {
360 let intent = field(&b, "intent")?.to_string();
361 let n = store.open_change(&a, owner, name, &intent).map_err(from_store)?;
362 ok(obj([("number", n.into())]))
363 }
364 ("POST", ["changes", c, sub]) => {
365 let c: u64 = c.parse().map_err(|_| err(400, "bad change number"))?;
366 match *sub {
367 "revisions" => {
368 let base = num(&b, "base")?;
369 let steps = b
370 .get("steps")
371 .and_then(Value::as_array)
372 .ok_or_else(|| err(400, "missing steps"))?
373 .iter()
374 .map(|st| Ok(Step { id: field(st, "id")?.to_string(), snapshot: parse_id(field(st, "snapshot")?)?, message: field(st, "message")?.to_string() }))
375 .collect::<Result<Vec<_>, Response>>()?;
376 let note = b.get("note").and_then(Value::as_str);
377 let n = store.revise(&a, owner, name, c, base, &steps, note).map_err(from_store)?;
378 ok(obj([("number", (n as u64).into())]))
379 }
380 "state" => {
381 let to = ChangeState::parse(field(&b, "state")?).ok_or_else(|| err(400, "bad state"))?;
382 store.set_state(&a, owner, name, c, to).map_err(from_store)?;
383 ok(obj([("state", to.name().into())]))
384 }
385 "land" => {
386 let (first, last) = store.land(&a, owner, name, c).map_err(from_store)?;
387 ok(obj([("first", first.into()), ("last", last.into())]))
388 }
389 "messages" => {
390 store.say(&a, owner, name, c, field(&b, "body")?).map_err(from_store)?;
391 ok(obj([("ok", true.into())]))
392 }
393 "evidence" => {
394 let revision = num(&b, "revision")? as u32;
395 let content = match (b.get("blob").and_then(Value::as_str), b.get("url").and_then(Value::as_str)) {
396 (Some(blob), None) => Content::Blob(parse_id(blob)?),
397 (None, Some(url)) => Content::Url(url),
398 _ => return Err(err(400, "evidence is a blob or a url")),
399 };
400 store.attach(&a, owner, name, c, revision, field(&b, "title")?, field(&b, "type")?, content).map_err(from_store)?;
401 ok(obj([("ok", true.into())]))
402 }
403 _ => Err(err(404, "no such endpoint")),
404 }
405 }
406 _ => Err(err(404, "no such endpoint")),
407 }
408 }
409}