| 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 | |
| 7 | use crate::http::{Request, Response}; |
| 8 | use crate::json::{self, obj, Value}; |
| 9 | use crate::model::{Actor, ChangeState, Step}; |
| 10 | use crate::objects::{Id, Objects}; |
| 11 | use crate::store::{self, Content, Store}; |
| 12 | use crate::{hex, wire}; |
| 13 | use std::collections::HashMap; |
| 14 | use std::sync::Mutex; |
| 15 | |
| 16 | pub 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. |
| 26 | const COOKIE: &str = "grove"; |
| 27 | const LINK_TTL_MS: i64 = 5 * 60 * 1000; |
| 28 | |
| 29 | type Out = Result<Response, Response>; |
| 30 | |
| 31 | fn redirect(to: &str) -> Response { |
| 32 | Response::with(303, "text/plain; charset=utf-8", Vec::new()).header("Location", to.to_string()) |
| 33 | } |
| 34 | |
| 35 | fn err(status: u16, msg: impl AsRef<str>) -> Response { |
| 36 | Response::error(status, msg.as_ref()) |
| 37 | } |
| 38 | |
| 39 | fn 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 | |
| 47 | fn 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 | |
| 55 | fn 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 | |
| 59 | fn 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 | |
| 63 | fn 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 | |
| 67 | fn 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 | |
| 71 | fn ok(v: Value) -> Out { |
| 72 | Ok(Response::json(200, &v)) |
| 73 | } |
| 74 | |
| 75 | impl 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 | } |