| 1 | // api.go is the drive surface (spec §7): the JSON an agent speaks to |
| 2 | // build a deck, look at what it made, and put it on a projector. No |
| 3 | // person's browser ever reaches it. |
| 4 | // |
| 5 | // # Why it is mounted outside the chi router |
| 6 | // |
| 7 | // csrf.Protect is app-wide (app.go) and it is right to be: it refuses |
| 8 | // a state-changing request that carries no browser evidence of |
| 9 | // same-origin submission, and "no evidence" is exactly what a |
| 10 | // non-browser client sends — no Sec-Fetch-Site, no Origin, no Referer. |
| 11 | // csrf.SameOrigin's last clause refuses that outright, so every POST |
| 12 | // an agent made through the chi router would be a 403 with an HTML |
| 13 | // body. |
| 14 | // |
| 15 | // So /api/v1 is mounted on the ServeMux ahead of the chi router, with |
| 16 | // its own gate. That is not a hole in the CSRF defence: a browser |
| 17 | // never attaches an `Authorization` header of its own, so a page on |
| 18 | // another origin cannot make a request this surface will authenticate, |
| 19 | // whatever it does with credentials or forms. The capability is the |
| 20 | // header, and CSRF is an attack on ambient credentials. |
| 21 | // |
| 22 | // # What it is |
| 23 | // |
| 24 | // Every answer with a body is JSON, including every error, and every |
| 25 | // error is `{"error": "<identifier>"}` — a fixed English word a |
| 26 | // program branches on, never a translated sentence. Some carry more: |
| 27 | // a refused batch adds `op` and `message`, which is the core's own |
| 28 | // account of which operation it refused and why. |
| 29 | // |
| 30 | // Visibility is the OPERATOR's visibility, evaluated through |
| 31 | // decks.go's visibleDecks like every screen. An agent is exactly as |
| 32 | // powerful as the person who minted its token and never a step more. |
| 33 | package slides |
| 34 | |
| 35 | import ( |
| 36 | "context" |
| 37 | "encoding/json" |
| 38 | "errors" |
| 39 | "fmt" |
| 40 | "io" |
| 41 | "mime" |
| 42 | "net/http" |
| 43 | "regexp" |
| 44 | "strconv" |
| 45 | "strings" |
| 46 | |
| 47 | "github.com/go-chi/chi/v5" |
| 48 | "gorm.io/gorm" |
| 49 | |
| 50 | "amadan.net/oficina/slides/internal/slides/core" |
| 51 | "amadan.net/oficina/slides/internal/slides/deck" |
| 52 | slidehtml "amadan.net/oficina/slides/internal/slides/render" |
| 53 | ) |
| 54 | |
| 55 | // apiPrefix is where the drive surface lives, and what app.go mounts |
| 56 | // on the ServeMux. Versioned from the first day it exists: an agent's |
| 57 | // configuration outlives a deploy, so the day this shape changes there |
| 58 | // has to be somewhere for the old one to keep answering. |
| 59 | const apiPrefix = "/api/v1" |
| 60 | |
| 61 | // The error identifiers. Fixed English words, not catalog keys: this |
| 62 | // is a machine surface, and a caller that branches on the error has to |
| 63 | // see the same string whatever Accept-Language it happened to send. |
| 64 | // The words a PERSON reads about a failure are the API caller's to |
| 65 | // write, from these plus the message a 422 carries. |
| 66 | const ( |
| 67 | errUnauthorized = "unauthorized" |
| 68 | errNotFound = "not_found" |
| 69 | errInvalid = "invalid" |
| 70 | errConflict = "conflict" |
| 71 | errUnsupported = "unsupported_media_type" |
| 72 | errTooLarge = "too_large" |
| 73 | errMethod = "method_not_allowed" |
| 74 | errInternal = "internal" |
| 75 | ) |
| 76 | |
| 77 | // maxAPIBody is the largest request body this surface reads. A mebibyte |
| 78 | // is far more than any batch of operations needs — the biggest thing an |
| 79 | // op carries is a slide's Markdown — and it is the bound that stops an |
| 80 | // unbounded read from being the way to take the process down. Asset |
| 81 | // bytes never come through here (spec §7.2: a presigned upload, then |
| 82 | // register_asset naming the result). |
| 83 | const maxAPIBody = 1 << 20 |
| 84 | |
| 85 | // apiRouter is the whole drive surface, gated by the bearer and |
| 86 | // nothing else. |
| 87 | // |
| 88 | // Full paths rather than a mount, because the ServeMux hands the |
| 89 | // request over with its path intact: what is written here is what a |
| 90 | // caller types, which is also what docs/api.md documents. |
| 91 | func (a *app) apiRouter() http.Handler { |
| 92 | r := chi.NewRouter() |
| 93 | r.Use(a.bearerActor) |
| 94 | r.Get(apiPrefix+"/decks", a.apiListDecks) |
| 95 | r.Post(apiPrefix+"/decks", a.apiCreateDeck) |
| 96 | r.Get(apiPrefix+"/decks/{sqid}", a.apiGetDeck) |
| 97 | r.Post(apiPrefix+"/decks/{sqid}/ops", a.apiOps) |
| 98 | // {file} rather than {id}.html: chi can match a static tail inside |
| 99 | // a segment, but the extension is part of what this endpoint means |
| 100 | // — it is the HTML rendering of a slide, and the .svg thumbnail of |
| 101 | // spec §7.2 lands beside it in slice 3 — so the handler reads the |
| 102 | // extension itself and there is one place to add the second one. |
| 103 | r.Get(apiPrefix+"/decks/{sqid}/slides/{file}", a.apiSlide) |
| 104 | r.Post(apiPrefix+"/decks/{sqid}/present", a.apiPresent) |
| 105 | // Exporting (exports.go, spec §4.3). Three endpoints because it is |
| 106 | // a job: start it, watch it, take the file. {id} carries the .pdf |
| 107 | // itself for the same reason {file} does above — chi matches whole |
| 108 | // segments. |
| 109 | r.Post(apiPrefix+"/decks/{sqid}/export", a.apiStartExport) |
| 110 | r.Get(apiPrefix+"/decks/{sqid}/export/{id}", a.apiExportStatus) |
| 111 | // Anything else under the prefix is a 404 in this surface's own |
| 112 | // shape, not chi's plain-text one: a caller parsing JSON should |
| 113 | // never be handed something else because it mistyped a path. |
| 114 | r.NotFound(func(w http.ResponseWriter, r *http.Request) { |
| 115 | apiFail(w, http.StatusNotFound, errNotFound) |
| 116 | }) |
| 117 | r.MethodNotAllowed(func(w http.ResponseWriter, r *http.Request) { |
| 118 | apiFail(w, http.StatusMethodNotAllowed, errMethod) |
| 119 | }) |
| 120 | return r |
| 121 | } |
| 122 | |
| 123 | // --- answering --- |
| 124 | |
| 125 | // writeJSON is every successful answer: the header, the status, the |
| 126 | // body, in that order. |
| 127 | // |
| 128 | // Encoded into a buffer before anything touches the wire, render.go's |
| 129 | // reason: an encoding failure becomes a clean 500 rather than a |
| 130 | // half-written body with a 200 already on it. |
| 131 | func (a *app) writeJSON(w http.ResponseWriter, status int, body any) { |
| 132 | b, err := json.Marshal(body) |
| 133 | if err != nil { |
| 134 | a.logger.Error("slides: encode an API answer", "err", err) |
| 135 | apiFail(w, http.StatusInternalServerError, errInternal) |
| 136 | return |
| 137 | } |
| 138 | w.Header().Set("Content-Type", "application/json; charset=utf-8") |
| 139 | w.WriteHeader(status) |
| 140 | w.Write(b) |
| 141 | } |
| 142 | |
| 143 | // apiFail is every error with nothing more to say than which one it |
| 144 | // is. Hand-encoded rather than through json.Marshal so the error path |
| 145 | // cannot itself fail to encode. |
| 146 | func apiFail(w http.ResponseWriter, status int, id string) { |
| 147 | w.Header().Set("Content-Type", "application/json; charset=utf-8") |
| 148 | w.WriteHeader(status) |
| 149 | fmt.Fprintf(w, "{\"error\":%q}\n", id) |
| 150 | } |
| 151 | |
| 152 | // apiInvalid is the 422 with an account of what was wrong: the message |
| 153 | // as the core (or this file) put it, and the index of the operation it |
| 154 | // belongs to when there is one. op is omitted when the failure is not |
| 155 | // about a particular operation — a missing title, a theme that is not |
| 156 | // one of the six. |
| 157 | func (a *app) apiInvalid(w http.ResponseWriter, message string, op int) { |
| 158 | body := map[string]any{"error": errInvalid, "message": message} |
| 159 | if op >= 0 { |
| 160 | body["op"] = op |
| 161 | } |
| 162 | a.writeJSON(w, http.StatusUnprocessableEntity, body) |
| 163 | } |
| 164 | |
| 165 | // --- reading --- |
| 166 | |
| 167 | // readJSON reads a request body into dst, answering the four refusals |
| 168 | // a JSON surface owes a caller: the wrong media type, a body too big |
| 169 | // to be an edit, bytes that are not the shape the endpoint takes, and |
| 170 | // a body that carries anything after its JSON value. It reports |
| 171 | // whether the caller may carry on. |
| 172 | // |
| 173 | // The last of those is the one a decoder will not raise on its own. |
| 174 | // json.Decoder reads ONE value and stops, so `{...}{...}` — an encoder |
| 175 | // that wrote twice, two batches concatenated by a shell — would have |
| 176 | // applied the first batch and thrown the second away silently. Anything |
| 177 | // but whitespace after the value is a 422, and the caller sends the |
| 178 | // batch it meant. |
| 179 | func (a *app) readJSON(w http.ResponseWriter, r *http.Request, dst any) bool { |
| 180 | ct := r.Header.Get("Content-Type") |
| 181 | if ct != "" { |
| 182 | kind, _, err := mime.ParseMediaType(ct) |
| 183 | if err != nil || kind != "application/json" { |
| 184 | apiFail(w, http.StatusUnsupportedMediaType, errUnsupported) |
| 185 | return false |
| 186 | } |
| 187 | } |
| 188 | r.Body = http.MaxBytesReader(w, r.Body, maxAPIBody) |
| 189 | dec := json.NewDecoder(r.Body) |
| 190 | if err := dec.Decode(dst); err != nil { |
| 191 | var tooBig *http.MaxBytesError |
| 192 | if errors.As(err, &tooBig) { |
| 193 | apiFail(w, http.StatusRequestEntityTooLarge, errTooLarge) |
| 194 | return false |
| 195 | } |
| 196 | a.apiInvalid(w, "the body is not the JSON this endpoint takes: "+err.Error(), -1) |
| 197 | return false |
| 198 | } |
| 199 | // io.EOF, and nothing else, means the value was the whole body. |
| 200 | // Trailing whitespace decodes to EOF too, so `curl -d @file`'s |
| 201 | // newline is not an error. |
| 202 | if err := dec.Decode(new(json.RawMessage)); !errors.Is(err, io.EOF) { |
| 203 | var tooBig *http.MaxBytesError |
| 204 | if errors.As(err, &tooBig) { |
| 205 | apiFail(w, http.StatusRequestEntityTooLarge, errTooLarge) |
| 206 | return false |
| 207 | } |
| 208 | a.apiInvalid(w, "the body carries more than one JSON value", -1) |
| 209 | return false |
| 210 | } |
| 211 | return true |
| 212 | } |
| 213 | |
| 214 | // --- resolving --- |
| 215 | |
| 216 | // apiPrincipal is the resolved bearer, or a 500. Past bearerActor |
| 217 | // there is always one; this exists so a handler reads it in one line |
| 218 | // and a wiring mistake surfaces as an error rather than a zero value |
| 219 | // that would scope every query to user 0. |
| 220 | func (a *app) apiPrincipal(w http.ResponseWriter, r *http.Request) (principal, bool) { |
| 221 | p, ok := principalFrom(r) |
| 222 | if !ok { |
| 223 | a.logger.Error("slides: no principal past the bearer gate", "path", r.URL.Path) |
| 224 | apiFail(w, http.StatusInternalServerError, errInternal) |
| 225 | return principal{}, false |
| 226 | } |
| 227 | return p, true |
| 228 | } |
| 229 | |
| 230 | // apiDeckFor resolves the {sqid} in r to a deck the OPERATOR may read, |
| 231 | // answering 404 itself when there is none — deckFor's rule and |
| 232 | // deckFor's reason, in JSON: a deck that is not yours does not exist, |
| 233 | // so a stranger holding a sqid cannot learn whether it is real. |
| 234 | func (a *app) apiDeckFor(w http.ResponseWriter, r *http.Request, p principal) (Deck, bool) { |
| 235 | var d Deck |
| 236 | err := a.visibleDecks(r.Context(), p.operator). |
| 237 | Where("decks.sqid = ?", chi.URLParam(r, "sqid")).Take(&d).Error |
| 238 | switch { |
| 239 | case err == nil: |
| 240 | return d, true |
| 241 | case errors.Is(err, gorm.ErrRecordNotFound): |
| 242 | apiFail(w, http.StatusNotFound, errNotFound) |
| 243 | default: |
| 244 | a.logger.Error("slides: load a deck for the API", "err", err) |
| 245 | apiFail(w, http.StatusInternalServerError, errInternal) |
| 246 | } |
| 247 | return Deck{}, false |
| 248 | } |
| 249 | |
| 250 | // --- GET /api/v1/decks --- |
| 251 | |
| 252 | // apiDeckSummary is one row of the list: everything an agent needs to |
| 253 | // pick a deck without opening it, plus the slide count, which it can |
| 254 | // only know by opening it. |
| 255 | type apiDeckSummary struct { |
| 256 | Sqid string `json:"sqid"` |
| 257 | Title string `json:"title"` |
| 258 | Slides int `json:"slides"` |
| 259 | Rung string `json:"rung"` |
| 260 | UpdatedAt string `json:"updated_at"` |
| 261 | Seq int64 `json:"seq"` |
| 262 | } |
| 263 | |
| 264 | // apiListDecks serves GET /api/v1/decks: every deck the operator may |
| 265 | // open, most recently edited first — the deck list screen's query, |
| 266 | // answered as JSON. |
| 267 | // |
| 268 | // The slide count costs one wasm instance per deck (decks.go's |
| 269 | // slideCount says why), and a deck whose document will not open is |
| 270 | // reported with a count of zero rather than taking the whole list |
| 271 | // down with it. |
| 272 | func (a *app) apiListDecks(w http.ResponseWriter, r *http.Request) { |
| 273 | p, ok := a.apiPrincipal(w, r) |
| 274 | if !ok { |
| 275 | return |
| 276 | } |
| 277 | var rows []Deck |
| 278 | if err := a.visibleDecks(r.Context(), p.operator). |
| 279 | Order("decks.updated_at DESC, decks.id DESC").Find(&rows).Error; err != nil { |
| 280 | a.logger.Error("slides: list decks for the API", "err", err) |
| 281 | apiFail(w, http.StatusInternalServerError, errInternal) |
| 282 | return |
| 283 | } |
| 284 | out := make([]apiDeckSummary, 0, len(rows)) |
| 285 | for _, d := range rows { |
| 286 | slides, _ := a.slideCount(r.Context(), d) |
| 287 | out = append(out, apiDeckSummary{ |
| 288 | Sqid: d.Sqid, |
| 289 | Title: d.Title, |
| 290 | Slides: slides, |
| 291 | Rung: d.Rung, |
| 292 | UpdatedAt: d.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"), |
| 293 | Seq: d.Seq, |
| 294 | }) |
| 295 | } |
| 296 | a.writeJSON(w, http.StatusOK, map[string]any{"decks": out}) |
| 297 | } |
| 298 | |
| 299 | // --- POST /api/v1/decks --- |
| 300 | |
| 301 | // apiCreateDeck serves POST /api/v1/decks. The deck is the OPERATOR's |
| 302 | // — an agent owns nothing, because an agent can be revoked and a deck |
| 303 | // must outlive that — and its first update is recorded against the |
| 304 | // AGENT, so the history says which program made it. |
| 305 | // |
| 306 | // The opening batch is createDeck's own (decks.go's openingOps): a new |
| 307 | // deck opens on a title slide whichever surface made it, so an agent |
| 308 | // and a person produce the same thing. |
| 309 | func (a *app) apiCreateDeck(w http.ResponseWriter, r *http.Request) { |
| 310 | p, ok := a.apiPrincipal(w, r) |
| 311 | if !ok { |
| 312 | return |
| 313 | } |
| 314 | var body struct { |
| 315 | Title string `json:"title"` |
| 316 | Theme string `json:"theme"` |
| 317 | } |
| 318 | if !a.readJSON(w, r, &body) { |
| 319 | return |
| 320 | } |
| 321 | title := strings.TrimSpace(body.Title) |
| 322 | if title == "" { |
| 323 | a.apiInvalid(w, "title is required", -1) |
| 324 | return |
| 325 | } |
| 326 | theme := body.Theme |
| 327 | if theme == "" { |
| 328 | theme = defaultTheme |
| 329 | } |
| 330 | if !validTheme(theme) { |
| 331 | a.apiInvalid(w, "theme must be one of "+strings.Join(themeIDs, ", "), -1) |
| 332 | return |
| 333 | } |
| 334 | |
| 335 | d, err := a.createDeckRow(r.Context(), p.operator, title, theme) |
| 336 | if err != nil { |
| 337 | a.logger.Error("slides: create a deck for the API", "err", err) |
| 338 | apiFail(w, http.StatusInternalServerError, errInternal) |
| 339 | return |
| 340 | } |
| 341 | ops, err := openingOps(title, theme) |
| 342 | if err != nil { |
| 343 | a.logger.Error("slides: build the opening ops for the API", "err", err) |
| 344 | apiFail(w, http.StatusInternalServerError, errInternal) |
| 345 | return |
| 346 | } |
| 347 | if err := a.editDeck(r.Context(), &d, p.actor(), ops, nil); err != nil { |
| 348 | a.logger.Error("slides: write the opening ops for the API", "err", err) |
| 349 | apiFail(w, http.StatusInternalServerError, errInternal) |
| 350 | return |
| 351 | } |
| 352 | a.writeJSON(w, http.StatusCreated, map[string]any{"sqid": d.Sqid}) |
| 353 | } |
| 354 | |
| 355 | // --- GET /api/v1/decks/{sqid} --- |
| 356 | |
| 357 | // apiGetDeck serves the whole document: the core's own view (§2's |
| 358 | // model, core/src/view.rs) with the deck's sqid and seq alongside it. |
| 359 | // |
| 360 | // The seq is the ETag, unquoted nowhere and quoted everywhere, because |
| 361 | // the seq is exactly what an ETag is for: it moves on every write and |
| 362 | // on nothing else, so an agent holding one already has the document. |
| 363 | func (a *app) apiGetDeck(w http.ResponseWriter, r *http.Request) { |
| 364 | p, ok := a.apiPrincipal(w, r) |
| 365 | if !ok { |
| 366 | return |
| 367 | } |
| 368 | d, ok := a.apiDeckFor(w, r, p) |
| 369 | if !ok { |
| 370 | return |
| 371 | } |
| 372 | |
| 373 | etag := strconv.Quote(strconv.FormatInt(d.Seq, 10)) |
| 374 | w.Header().Set("ETag", etag) |
| 375 | if matchesETag(r.Header.Get("If-None-Match"), etag) { |
| 376 | // Before the document is opened, which is the whole point: a |
| 377 | // 304 costs no wasm instance and no log replay. |
| 378 | w.WriteHeader(http.StatusNotModified) |
| 379 | return |
| 380 | } |
| 381 | |
| 382 | h, _, err := a.openDeck(r.Context(), d) |
| 383 | if err != nil { |
| 384 | a.logger.Error("slides: open a deck for the API", "deck", d.Sqid, "err", err) |
| 385 | apiFail(w, http.StatusInternalServerError, errInternal) |
| 386 | return |
| 387 | } |
| 388 | defer h.Close(r.Context()) |
| 389 | raw, err := h.JSON(r.Context()) |
| 390 | if err != nil { |
| 391 | a.logger.Error("slides: read a deck view for the API", "deck", d.Sqid, "err", err) |
| 392 | apiFail(w, http.StatusInternalServerError, errInternal) |
| 393 | return |
| 394 | } |
| 395 | |
| 396 | // Decoded to raw fragments rather than to any: the view carries |
| 397 | // fractional z values and box coordinates, and a round trip |
| 398 | // through map[string]any would reformat every number the core |
| 399 | // wrote. This adds two keys and re-encodes the rest byte for byte. |
| 400 | var view map[string]json.RawMessage |
| 401 | if err := json.Unmarshal(raw, &view); err != nil { |
| 402 | a.logger.Error("slides: the deck view is not a JSON object", "deck", d.Sqid, "err", err) |
| 403 | apiFail(w, http.StatusInternalServerError, errInternal) |
| 404 | return |
| 405 | } |
| 406 | view["sqid"], _ = json.Marshal(d.Sqid) |
| 407 | view["seq"], _ = json.Marshal(d.Seq) |
| 408 | a.writeJSON(w, http.StatusOK, view) |
| 409 | } |
| 410 | |
| 411 | // matchesETag reads an If-None-Match header the way RFC 9110 says to: |
| 412 | // a comma-separated list, `*` matching anything, and a weak validator |
| 413 | // comparing equal to its strong twin (weak comparison is what |
| 414 | // If-None-Match uses). |
| 415 | func matchesETag(header, etag string) bool { |
| 416 | if header == "" { |
| 417 | return false |
| 418 | } |
| 419 | for _, candidate := range strings.Split(header, ",") { |
| 420 | candidate = strings.TrimSpace(candidate) |
| 421 | if candidate == "*" { |
| 422 | return true |
| 423 | } |
| 424 | if strings.TrimPrefix(candidate, "W/") == strings.TrimPrefix(etag, "W/") { |
| 425 | return true |
| 426 | } |
| 427 | } |
| 428 | return false |
| 429 | } |
| 430 | |
| 431 | // --- POST /api/v1/decks/{sqid}/ops --- |
| 432 | |
| 433 | // coreOpIndex finds which operation the core refused. Its messages are |
| 434 | // prefixed "op <i>: " and "op <i> (applying): " (core/src/ops.rs's |
| 435 | // apply_ops), which is the only place that index exists — the batch is |
| 436 | // checked as one thing, so nothing on this side knows it. |
| 437 | var coreOpIndex = regexp.MustCompile(`^op (\d+)\b`) |
| 438 | |
| 439 | // apiOps serves the operation batch (spec §7.2): the one way anything |
| 440 | // in a document changes. |
| 441 | // |
| 442 | // Two things it does that the editor's own path does not: |
| 443 | // |
| 444 | // - It MINTS the ids. The core never draws entropy (decks.go's |
| 445 | // newID), so `add_slide`, `add_block` and `duplicate_slide` each |
| 446 | // need an id supplied by the host — and an agent writing a batch |
| 447 | // should not have to invent twelve base32 characters to add a |
| 448 | // slide. Any it left out are minted here and returned keyed by the |
| 449 | // operation's index, so the next batch can address what this one |
| 450 | // made. Ids the caller DID supply are left exactly as they are. |
| 451 | // - It reports which operation was refused, so a caller can fix that |
| 452 | // one rather than bisecting its own batch. |
| 453 | // |
| 454 | // A refused batch applies nothing — that is the core's guarantee, not |
| 455 | // this handler's (core/src/ops.rs's two-pass check) — and this answers |
| 456 | // 422 with the core's own account of it. |
| 457 | func (a *app) apiOps(w http.ResponseWriter, r *http.Request) { |
| 458 | p, ok := a.apiPrincipal(w, r) |
| 459 | if !ok { |
| 460 | return |
| 461 | } |
| 462 | d, ok := a.apiDeckFor(w, r, p) |
| 463 | if !ok { |
| 464 | return |
| 465 | } |
| 466 | |
| 467 | // Raw fragments, not any: an op carries fractional z values and box |
| 468 | // coordinates the core wrote a contract for, and this handler adds |
| 469 | // keys to an op rather than rewriting it. |
| 470 | var body struct { |
| 471 | Ops []map[string]json.RawMessage `json:"ops"` |
| 472 | } |
| 473 | if !a.readJSON(w, r, &body) { |
| 474 | return |
| 475 | } |
| 476 | if len(body.Ops) == 0 { |
| 477 | a.apiInvalid(w, "ops must be a non-empty list of operations", -1) |
| 478 | return |
| 479 | } |
| 480 | |
| 481 | ids, err := a.applyAgentOps(r.Context(), &d, p.actor(), body.Ops) |
| 482 | switch { |
| 483 | case err == nil: |
| 484 | a.writeJSON(w, http.StatusOK, map[string]any{"seq": d.Seq, "ids": ids}) |
| 485 | case errors.Is(err, errDeckBusy): |
| 486 | // Two attempts have already been made; a third would be |
| 487 | // spinning. 409 is the honest answer, and the caller re-reads |
| 488 | // the deck and decides what its batch now means. |
| 489 | apiFail(w, http.StatusConflict, errConflict) |
| 490 | default: |
| 491 | var rejected *core.RejectedError |
| 492 | if errors.As(err, &rejected) { |
| 493 | msg := rejected.Error() |
| 494 | op := -1 |
| 495 | if m := coreOpIndex.FindStringSubmatch(msg); m != nil { |
| 496 | if i, convErr := strconv.Atoi(m[1]); convErr == nil { |
| 497 | op = i |
| 498 | } |
| 499 | } |
| 500 | a.apiInvalid(w, msg, op) |
| 501 | return |
| 502 | } |
| 503 | a.logger.Error("slides: apply an agent's ops", "deck", d.Sqid, "err", err) |
| 504 | apiFail(w, http.StatusInternalServerError, errInternal) |
| 505 | } |
| 506 | } |
| 507 | |
| 508 | // applyAgentOps mints the batch's missing ids and applies it, with one |
| 509 | // retry on the optimistic lock. |
| 510 | // |
| 511 | // The deck is opened once on the happy path: minting needs the current |
| 512 | // document (how many blocks a slide has, for duplicate_slide), and so |
| 513 | // does applying, so both run against the one handle. A second writer |
| 514 | // landing in between is errDeckBusy, and the second attempt re-reads |
| 515 | // the row and reopens — the ids minted on the first attempt are |
| 516 | // already written into the ops, so it mints nothing new and the caller |
| 517 | // is told the same ids either way. |
| 518 | func (a *app) applyAgentOps(ctx context.Context, d *Deck, actor string, ops []map[string]json.RawMessage) (map[string]string, error) { |
| 519 | var ids map[string]string |
| 520 | var lastErr error |
| 521 | for attempt := 0; attempt < 2; attempt++ { |
| 522 | h, view, err := a.openDeck(ctx, *d) |
| 523 | if err != nil { |
| 524 | return nil, err |
| 525 | } |
| 526 | minted, err := mintOpIDs(ops, view) |
| 527 | if err != nil { |
| 528 | h.Close(ctx) |
| 529 | return nil, err |
| 530 | } |
| 531 | if attempt == 0 { |
| 532 | ids = minted |
| 533 | } |
| 534 | encoded, err := json.Marshal(ops) |
| 535 | if err != nil { |
| 536 | h.Close(ctx) |
| 537 | return nil, fmt.Errorf("slides: encode ops: %w", err) |
| 538 | } |
| 539 | _, err = a.applyOps(ctx, h, d, actor, encoded, metaRowUpdates(ops)) |
| 540 | h.Close(ctx) |
| 541 | if err == nil { |
| 542 | return ids, nil |
| 543 | } |
| 544 | lastErr = err |
| 545 | if !errors.Is(err, errDeckBusy) { |
| 546 | return nil, err |
| 547 | } |
| 548 | // Our copy's Seq is what just lost; re-read it before retrying. |
| 549 | if err := a.db.G.WithContext(ctx).First(d, d.ID).Error; err != nil { |
| 550 | return nil, fmt.Errorf("slides: reload deck %s: %w", d.Sqid, err) |
| 551 | } |
| 552 | } |
| 553 | return nil, lastErr |
| 554 | } |
| 555 | |
| 556 | // metaRowUpdates is the decks row's share of a batch: the title and |
| 557 | // theme columns a list, a link and the settings form read, which live |
| 558 | // in the row as well as in the document's meta. |
| 559 | // |
| 560 | // It reads the batch rather than the view the batch produces, and that |
| 561 | // is the point: the row write has to be IN applyOps' one transaction |
| 562 | // with the update-log insert (saveDeckSettings' own rule — the row and |
| 563 | // the document commit or roll back together), and the view only exists |
| 564 | // after the commit, because reading it is a round trip into the wasm |
| 565 | // module that would hold SQLite's single writer for its whole |
| 566 | // duration. set_meta is the only operation that touches meta, so |
| 567 | // walking the batch answers exactly what the view would: last write in |
| 568 | // the batch wins, a key the batch left out is a column left alone. |
| 569 | // |
| 570 | // The theme is folded in only when it is one of the app's six. The |
| 571 | // core takes any string there, so an agent can put something else in |
| 572 | // the document's meta; the row is what themes a render and what the |
| 573 | // settings form offers, and a value that is not in that set has no |
| 574 | // business in it. Nil when the batch changes no meta at all, which is |
| 575 | // the vast majority of them. |
| 576 | func metaRowUpdates(ops []map[string]json.RawMessage) map[string]any { |
| 577 | var out map[string]any |
| 578 | set := func(column string, value any) { |
| 579 | if out == nil { |
| 580 | out = map[string]any{} |
| 581 | } |
| 582 | out[column] = value |
| 583 | } |
| 584 | for _, op := range ops { |
| 585 | if rawString(op, "op") != "set_meta" { |
| 586 | continue |
| 587 | } |
| 588 | if _, has := op["title"]; has { |
| 589 | set("title", rawString(op, "title")) |
| 590 | } |
| 591 | if _, has := op["theme"]; has { |
| 592 | if theme := rawString(op, "theme"); validTheme(theme) { |
| 593 | set("theme", theme) |
| 594 | } |
| 595 | } |
| 596 | } |
| 597 | return out |
| 598 | } |
| 599 | |
| 600 | // mintOpIDs fills in the ids the core requires and the caller left |
| 601 | // out, in place, and returns them keyed by the operation's index as a |
| 602 | // string — a JSON object's keys are strings, and "3" is the third |
| 603 | // operation the caller sent. |
| 604 | // |
| 605 | // It keeps a running count of each slide's blocks, starting from the |
| 606 | // document and updated as it walks the batch, because duplicate_slide |
| 607 | // needs exactly as many new block ids as the slide it copies has — |
| 608 | // including blocks an earlier operation in the same batch added. |
| 609 | // |
| 610 | // It validates nothing else. Every vocabulary, every reference and |
| 611 | // every required field is the core's to check (core/src/ops.rs), and a |
| 612 | // second opinion here would be a copy to keep in step for no checking |
| 613 | // that is not already done. |
| 614 | func mintOpIDs(ops []map[string]json.RawMessage, view *deck.Deck) (map[string]string, error) { |
| 615 | blocks := map[string]int{} |
| 616 | for _, s := range view.Slides { |
| 617 | blocks[s.ID] = len(s.Blocks) |
| 618 | } |
| 619 | |
| 620 | ids := map[string]string{} |
| 621 | for i, op := range ops { |
| 622 | name := rawString(op, "op") |
| 623 | switch name { |
| 624 | case "add_slide": |
| 625 | id, err := existingOrNew(op, "id", ids, i) |
| 626 | if err != nil { |
| 627 | return nil, err |
| 628 | } |
| 629 | blocks[id] = 0 |
| 630 | case "add_block": |
| 631 | if _, err := existingOrNew(op, "id", ids, i); err != nil { |
| 632 | return nil, err |
| 633 | } |
| 634 | blocks[rawString(op, "slide")]++ |
| 635 | case "remove_block": |
| 636 | if slide := rawString(op, "slide"); blocks[slide] > 0 { |
| 637 | blocks[slide]-- |
| 638 | } |
| 639 | case "remove_slide": |
| 640 | delete(blocks, rawString(op, "id")) |
| 641 | case "duplicate_slide": |
| 642 | source := rawString(op, "id") |
| 643 | copyID, err := existingOrNew(op, "new_id", ids, i) |
| 644 | if err != nil { |
| 645 | return nil, err |
| 646 | } |
| 647 | n := blocks[source] |
| 648 | if _, has := op["new_block_ids"]; !has { |
| 649 | list := make([]string, 0, n) |
| 650 | for range n { |
| 651 | id, err := newID() |
| 652 | if err != nil { |
| 653 | return nil, err |
| 654 | } |
| 655 | list = append(list, id) |
| 656 | } |
| 657 | encoded, err := json.Marshal(list) |
| 658 | if err != nil { |
| 659 | return nil, fmt.Errorf("slides: encode new_block_ids: %w", err) |
| 660 | } |
| 661 | op["new_block_ids"] = encoded |
| 662 | } |
| 663 | blocks[copyID] = n |
| 664 | } |
| 665 | } |
| 666 | return ids, nil |
| 667 | } |
| 668 | |
| 669 | // existingOrNew returns the id at key, minting and recording one when |
| 670 | // the caller left it out. |
| 671 | func existingOrNew(op map[string]json.RawMessage, key string, ids map[string]string, index int) (string, error) { |
| 672 | if raw, has := op[key]; has { |
| 673 | var id string |
| 674 | if err := json.Unmarshal(raw, &id); err == nil && id != "" { |
| 675 | return id, nil |
| 676 | } |
| 677 | // A key that is there but is not a usable id is left alone for |
| 678 | // the core to refuse by name — overwriting it would silently |
| 679 | // turn a caller's mistake into a slide it never asked for. |
| 680 | return "", nil |
| 681 | } |
| 682 | id, err := newID() |
| 683 | if err != nil { |
| 684 | return "", err |
| 685 | } |
| 686 | encoded, err := json.Marshal(id) |
| 687 | if err != nil { |
| 688 | return "", fmt.Errorf("slides: encode a minted id: %w", err) |
| 689 | } |
| 690 | op[key] = encoded |
| 691 | ids[strconv.Itoa(index)] = id |
| 692 | return id, nil |
| 693 | } |
| 694 | |
| 695 | // rawString reads a JSON string out of an op, answering "" for a key |
| 696 | // that is absent or is not a string. The core is what refuses either; |
| 697 | // this only needs enough of the op to know what to mint. |
| 698 | func rawString(op map[string]json.RawMessage, key string) string { |
| 699 | raw, ok := op[key] |
| 700 | if !ok { |
| 701 | return "" |
| 702 | } |
| 703 | var s string |
| 704 | if err := json.Unmarshal(raw, &s); err != nil { |
| 705 | return "" |
| 706 | } |
| 707 | return s |
| 708 | } |
| 709 | |
| 710 | // --- GET /api/v1/decks/{sqid}/slides/{id}.html --- |
| 711 | |
| 712 | // apiSlide renders one slide as the section the viewer would show it |
| 713 | // in — spec §7.2's "so an agent can look at what it made", standing in |
| 714 | // for the SVG thumbnail until slice 3 has one. |
| 715 | // |
| 716 | // text/html rather than JSON, and the section alone rather than a |
| 717 | // page: it is markup to look at, paste into a report, or diff against |
| 718 | // the last one, and a whole document around it would be noise. |
| 719 | func (a *app) apiSlide(w http.ResponseWriter, r *http.Request) { |
| 720 | p, ok := a.apiPrincipal(w, r) |
| 721 | if !ok { |
| 722 | return |
| 723 | } |
| 724 | d, ok := a.apiDeckFor(w, r, p) |
| 725 | if !ok { |
| 726 | return |
| 727 | } |
| 728 | // The picture twin (thumbs.go). §7.2 asked for the HTML "standing |
| 729 | // in for the SVG thumbnail until slice 3 has one"; this is it, and |
| 730 | // the markup stays because an agent diffing what it made wants |
| 731 | // words rather than coordinates. |
| 732 | if strings.HasSuffix(chi.URLParam(r, "file"), ".svg") { |
| 733 | a.serveThumb(w, r, d, "/d/"+d.Sqid, func() { apiFail(w, http.StatusNotFound, errNotFound) }) |
| 734 | return |
| 735 | } |
| 736 | |
| 737 | slideID, ok := strings.CutSuffix(chi.URLParam(r, "file"), ".html") |
| 738 | if !ok || slideID == "" { |
| 739 | apiFail(w, http.StatusNotFound, errNotFound) |
| 740 | return |
| 741 | } |
| 742 | |
| 743 | h, view, err := a.openDeck(r.Context(), d) |
| 744 | if err != nil { |
| 745 | a.logger.Error("slides: open a deck to render a slide", "deck", d.Sqid, "err", err) |
| 746 | apiFail(w, http.StatusInternalServerError, errInternal) |
| 747 | return |
| 748 | } |
| 749 | defer h.Close(r.Context()) |
| 750 | |
| 751 | index := -1 |
| 752 | for i, s := range view.Slides { |
| 753 | if s.ID == slideID { |
| 754 | index = i |
| 755 | break |
| 756 | } |
| 757 | } |
| 758 | if index < 0 { |
| 759 | apiFail(w, http.StatusNotFound, errNotFound) |
| 760 | return |
| 761 | } |
| 762 | |
| 763 | vp := slidehtml.Desktop |
| 764 | if r.URL.Query().Get("vp") == "phone" { |
| 765 | vp = slidehtml.Phone |
| 766 | } |
| 767 | // The signed-in surface's base. An agent reads this markup rather |
| 768 | // than rendering it, and pointing it somewhere that needs no |
| 769 | // session would be inventing a fourth capability for a caller that |
| 770 | // already has a bearer token for the deck. |
| 771 | section, err := slidehtml.SlideWith(view, index, vp, |
| 772 | slidehtml.Options{AssetBase: assetBaseFor("/d/" + d.Sqid)}) |
| 773 | if err != nil { |
| 774 | a.logger.Error("slides: render a slide for the API", "deck", d.Sqid, "slide", slideID, "err", err) |
| 775 | apiFail(w, http.StatusInternalServerError, errInternal) |
| 776 | return |
| 777 | } |
| 778 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 779 | w.Header().Set("ETag", strconv.Quote(strconv.FormatInt(d.Seq, 10)+"-"+slideID+"-"+vp.String())) |
| 780 | w.WriteHeader(http.StatusOK) |
| 781 | fmt.Fprint(w, section) |
| 782 | } |
| 783 | |
| 784 | // --- POST /api/v1/decks/{sqid}/present --- |
| 785 | |
| 786 | // apiPresent starts a talk, or picks up the one this agent already has |
| 787 | // open on this deck — startPresenting's behaviour (presentation.go) |
| 788 | // and startPresenting's row, reached by an agent rather than by a |
| 789 | // button. |
| 790 | // |
| 791 | // Reusing rather than starting a second session is what makes this |
| 792 | // safe to call again: a projector already showing /p/{session} keeps |
| 793 | // showing the same talk, and the agent gets a FRESH remote token, |
| 794 | // which is the one surprising thing here and is deliberate — only the |
| 795 | // hash is stored, so the old token cannot be handed back. |
| 796 | // |
| 797 | // All four addresses come back absolute, built on a.cfg.Origin rather |
| 798 | // than on the request's Host: an agent puts these in a chat message or |
| 799 | // on a projector, and the origin is what the app resolved at boot and |
| 800 | // refused to start without (config.go). |
| 801 | func (a *app) apiPresent(w http.ResponseWriter, r *http.Request) { |
| 802 | p, ok := a.apiPrincipal(w, r) |
| 803 | if !ok { |
| 804 | return |
| 805 | } |
| 806 | d, ok := a.apiDeckFor(w, r, p) |
| 807 | if !ok { |
| 808 | return |
| 809 | } |
| 810 | |
| 811 | token, err := newRemoteToken() |
| 812 | if err != nil { |
| 813 | a.logger.Error("slides: mint a remote token for the API", "err", err) |
| 814 | apiFail(w, http.StatusInternalServerError, errInternal) |
| 815 | return |
| 816 | } |
| 817 | actor := p.actor() |
| 818 | |
| 819 | var live Presentation |
| 820 | err = a.livePresentations(r).Where("deck_id = ? AND actor = ?", d.ID, actor). |
| 821 | Order("started_at DESC").Take(&live).Error |
| 822 | switch { |
| 823 | case err == nil: |
| 824 | if err := a.db.G.WithContext(r.Context()).Model(&Presentation{}). |
| 825 | Where("id = ? AND ended_at IS NULL", live.ID). |
| 826 | Update("remote_token_hash", hashRemoteToken(token)).Error; err != nil { |
| 827 | a.logger.Error("slides: re-pair a presentation for the API", "err", err) |
| 828 | apiFail(w, http.StatusInternalServerError, errInternal) |
| 829 | return |
| 830 | } |
| 831 | case errors.Is(err, gorm.ErrRecordNotFound): |
| 832 | live, err = a.createPresentation(r, d, actor, token) |
| 833 | if err != nil { |
| 834 | a.logger.Error("slides: start a presentation for the API", "err", err) |
| 835 | apiFail(w, http.StatusInternalServerError, errInternal) |
| 836 | return |
| 837 | } |
| 838 | default: |
| 839 | a.logger.Error("slides: look for a live presentation for the API", "err", err) |
| 840 | apiFail(w, http.StatusInternalServerError, errInternal) |
| 841 | return |
| 842 | } |
| 843 | |
| 844 | a.writeJSON(w, http.StatusOK, map[string]any{ |
| 845 | "session": live.Sqid, |
| 846 | // The projector's address: a viewing capability, safe to put on |
| 847 | // a screen in front of a room. |
| 848 | "present_url": a.cfg.Origin + "/p/" + live.Sqid, |
| 849 | // The presenter's own view, and the remote — both carry the |
| 850 | // driving capability, so both are secrets. |
| 851 | "notes_url": a.cfg.Origin + "/p/" + live.Sqid + "/notes?t=" + token, |
| 852 | "remote_url": a.cfg.Origin + "/r/" + live.Code + "?t=" + token, |
| 853 | "code": live.Code, |
| 854 | }) |
| 855 | } |
| 856 | |