oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

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.
33package slides
34
35import (
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.
59const 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.
66const (
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).
83const 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.
91func (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.
131func (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.
146func 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.
157func (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.
179func (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.
220func (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.
234func (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.
255type 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.
272func (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.
309func (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.
363func (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).
415func 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.
437var 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.
457func (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.
518func (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.
576func 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.
614func 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.
671func 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.
698func 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.
719func (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).
801func (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