oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

1// presentation.go is presenting: the session row, the fullscreen
2// surface, the presenter view, the phone remote and the audience's
3// follow.
4//
5// The one idea, spec §3.4 and ruling 5: presenting is a SERVER-SIDE
6// SESSION. There is one row that says which slide is on the screen,
7// and the projector, the presenter's second window, the phone in their
8// hand and every follower all read and write that same row. Nothing
9// here is a peer-to-peer arrangement between browsers, so there is no
10// state that can disagree with itself; the SSE stream
11// (internal/slides/present) is a notification that the row moved, not
12// the place the row lives.
13//
14// Three capabilities, deliberately different in strength:
15//
16// - The session SQID is a VIEWING capability, exactly like a share
17// link: anybody who has the address may watch the fullscreen
18// surface. It is what makes "put this on the projector" a URL.
19// - The REMOTE TOKEN is the driving capability, and the only thing
20// that may move the deck. It is 32 bytes, stored as a hash, and
21// shown once — the share link's construction and the share link's
22// reason (share.go).
23// - The PAIRING CODE is neither on its own. It is six sayable
24// characters that name a live session so a phone can find it; the
25// token still has to arrive with it. A code alone being enough
26// would make every talk in the building drivable by whoever
27// guessed six characters.
28//
29// The speaker notes follow the strongest of the three: the presenter's
30// own session, or the remote token. Never the sqid, because the sqid
31// is what goes on a projector.
32//
33// AN AUDIENCE NEVER LEARNS THE SQID (codex 5). Somebody following
34// along arrived with a share link and stays behind it: the page, the
35// slide fragments, the stream and the pictures are all `/s/{token}`
36// routes, each of which resolves the token again, so revoking the link
37// stops them mid-talk. Before the codex review, following handed the
38// session sqid to the browser and every later read went to `/p/…`,
39// which asks only for a live session — so a revoked link kept working
40// until the talk ended. The sqid is a projector address, and a
41// projector address is not something to give an audience.
42package slides
43
44import (
45 "amadan.net/oficina/estilo.git/chrome"
46 "crypto/rand"
47 "crypto/sha256"
48 "crypto/subtle"
49 "encoding/base64"
50 "encoding/hex"
51 "encoding/json"
52 "errors"
53 "fmt"
54 "html/template"
55 "io"
56 "net/http"
57 "net/url"
58 "strconv"
59 "strings"
60 "time"
61
62 "amadan.net/oficina/home/connect"
63 "amadan.net/oficina/slides/internal/slides/deck"
64 "amadan.net/oficina/slides/internal/slides/present"
65 slidehtml "amadan.net/oficina/slides/internal/slides/render"
66 "amadan.net/rastrillo/rastrillo"
67 "github.com/go-chi/chi/v5"
68 "github.com/skip2/go-qrcode"
69 "gorm.io/gorm"
70)
71
72// --- what a session is, and how long it lasts ---
73
74// presentTTL is how long a session stays live without being ended.
75// Spec §3.4: "ended explicitly or after 12 hours". A talk nobody
76// closed must not still be driveable tomorrow morning by whoever still
77// has the phone URL in a browser tab.
78const presentTTL = 12 * time.Hour
79
80// codeAlphabet is the pairing code's: no O or 0, no I or 1, because
81// this is the one string in the app a person reads off a projector and
82// types on a phone. Upper case for the same reason — it is read aloud.
83const codeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
84
85// codeLength is six characters: 32^6 is a billion, which is not a
86// keyspace anybody brute-forces through a web form, and the code is
87// not a capability on its own anyway (this file's own comment).
88const codeLength = 6
89
90// remoteTokenBytes is the remote capability's randomness — the share
91// link's 32 bytes, for the share link's reason (share.go).
92const remoteTokenBytes = 32
93
94// maxCodeAttempts bounds the retry loop when a freshly minted code is
95// already in use by another live session. Collisions are rare and real:
96// unique among live sessions is a small set, but it is not empty.
97const maxCodeAttempts = 8
98
99// newRemoteToken mints the remote's capability: base64url, nothing to
100// escape in a URL and nothing to trim.
101func newRemoteToken() (string, error) {
102 raw := make([]byte, remoteTokenBytes)
103 if _, err := rand.Read(raw); err != nil {
104 return "", fmt.Errorf("slides: generate remote token: %w", err)
105 }
106 return base64.RawURLEncoding.EncodeToString(raw), nil
107}
108
109// hashRemoteToken is the stored form. The same construction as
110// hashShareToken and written out again rather than shared, because the
111// two are separate protocol decisions that happen to agree today: a
112// change to how share links are stored must not silently change what
113// every live remote's stored hash means.
114func hashRemoteToken(token string) string {
115 sum := sha256.Sum256([]byte(token))
116 return hex.EncodeToString(sum[:])
117}
118
119// remoteTokenOK compares the presented token with the stored hash in
120// constant time. Hashes, not tokens: hashing first means the compare
121// is over two fixed-length strings, so not even the length of the
122// stored secret leaks through timing.
123//
124// An empty token is refused before anything else. Without this, a
125// session whose hash column was somehow empty would be driveable by
126// anyone posting no token at all.
127func remoteTokenOK(p Presentation, token string) bool {
128 if token == "" || p.RemoteTokenHash == "" {
129 return false
130 }
131 return subtle.ConstantTimeCompare([]byte(hashRemoteToken(token)), []byte(p.RemoteTokenHash)) == 1
132}
133
134// newCode mints a pairing code.
135func newCode() (string, error) {
136 raw := make([]byte, codeLength)
137 if _, err := rand.Read(raw); err != nil {
138 return "", fmt.Errorf("slides: generate pairing code: %w", err)
139 }
140 out := make([]byte, codeLength)
141 for i, b := range raw {
142 // The modulo bias decks.go's newSqid documents applies here
143 // too, and matters even less: the code is not a secret, it is
144 // a name a person types.
145 out[i] = codeAlphabet[int(b)%len(codeAlphabet)]
146 }
147 return string(out), nil
148}
149
150// livePresentations scopes a query to the sessions that are still
151// running: not ended, and not older than the TTL. Every read that
152// looks for a session to USE goes through it; presentationFor is the
153// one place that reads outside it, because telling somebody their talk
154// is over needs the row that says so.
155func (a *app) livePresentations(r *http.Request) *gorm.DB {
156 return a.db.G.WithContext(r.Context()).Model(&Presentation{}).
157 Where("ended_at IS NULL AND started_at > ?", time.Now().UTC().Add(-presentTTL))
158}
159
160// presentationOver reports whether p has finished, either way it can.
161func presentationOver(p Presentation) bool {
162 return p.EndedAt != nil || time.Since(p.StartedAt) > presentTTL
163}
164
165// eventFor is the one conversion between the column and the wire.
166//
167// Presentation.Slide is a 0-based index into the document; every URL
168// in this app and every stream event carries the 1-based number a
169// person sees (?slide=3 is the third slide). Both conventions are
170// right in their own place and the seam between them is here, so no
171// other function in the app has to hold both in its head.
172func eventFor(p Presentation) present.Event {
173 return present.Event{
174 Slide: p.Slide + 1,
175 Blackout: p.Blackout,
176 Ended: p.EndedAt != nil,
177 }
178}
179
180// --- resolving a session from a request ---
181
182// presentationFor resolves the {session} sqid, answering for itself:
183// 404 for a session that does not exist, 410 for one that is over.
184//
185// The two answers say different things on purpose. A talk that
186// finished is GONE — somebody following a link to yesterday's session
187// should be told it ended rather than left wondering whether they
188// typed the address wrong. A sqid that never existed is a 404, and a
189// dead pairing code is a 404 too (remotePair), because neither should
190// confirm anything to a stranger who is guessing.
191func (a *app) presentationFor(w http.ResponseWriter, r *http.Request) (Presentation, bool) {
192 var p Presentation
193 err := a.db.G.WithContext(r.Context()).
194 Where("sqid = ?", chi.URLParam(r, "session")).Take(&p).Error
195 switch {
196 case err == nil:
197 case errors.Is(err, gorm.ErrRecordNotFound):
198 a.notFound(w, r)
199 return Presentation{}, false
200 default:
201 a.fail(w, r, "load presentation", err)
202 return Presentation{}, false
203 }
204 if presentationOver(p) {
205 a.gone(w, r)
206 return Presentation{}, false
207 }
208 return p, true
209}
210
211// gone renders the 410: the app's own error page, with words of its
212// own, because the framework catalog words five statuses and this is
213// not one of them (ui/partials/error-page.html).
214func (a *app) gone(w http.ResponseWriter, r *http.Request) {
215 a.errorPage(w, r, http.StatusGone, kindStatus, "")
216}
217
218// deckOf loads the deck a session is presenting. No visibility check:
219// the session sqid IS the capability that reached this handler, the
220// same way a share link is (share.go's sharedDeck).
221func (a *app) deckOf(w http.ResponseWriter, r *http.Request, p Presentation) (Deck, bool) {
222 var d Deck
223 err := a.db.G.WithContext(r.Context()).Take(&d, p.DeckID).Error
224 switch {
225 case err == nil:
226 return d, true
227 case errors.Is(err, gorm.ErrRecordNotFound):
228 a.notFound(w, r)
229 default:
230 a.fail(w, r, "load a presented deck", err)
231 }
232 return Deck{}, false
233}
234
235// presenterActor is who is signed in on this request, as an actor
236// string, or "" for nobody.
237//
238// It reads the session cookie DIRECTLY (rauth's SessionFrom) rather
239// than through sessions.Current, because every screen in this file is
240// mounted outside the signed-in group — RequireSession is what stashes
241// a session on the request context, so past it sessions.Current
242// answers nothing at all. Nobody being signed in is the ordinary case
243// here (a phone, a projector), not a failure.
244func (a *app) presenterActor(r *http.Request) string {
245 if a.auth == nil {
246 return ""
247 }
248 if a.home != nil && a.homeSessions != nil {
249 if session, ok := a.homeSessions.From(r); ok && session.Method == connect.HomeMethod {
250 if u, err := a.homeUser(r, session); err == nil {
251 return actorFor(u)
252 }
253 return ""
254 }
255 }
256 id, ok := a.auth.SessionFrom(r)
257 if !ok || id.Address == "" {
258 return ""
259 }
260 var u User
261 if err := a.db.G.WithContext(r.Context()).
262 Where("email = ?", normalizeAddress(id.Address)).Take(&u).Error; err != nil {
263 return ""
264 }
265 return actorFor(u)
266}
267
268// --- POST /d/{sqid}/present ---
269
270// startPresenting opens a session for a deck, or picks up the one this
271// person already has open on it, and lands them on the presenter view
272// with the remote's capability in the URL — once.
273//
274// Reachable by anybody who can open the deck (visibleDecks), not the
275// owner alone: presenting is reading out loud, and a team member who
276// can read a deck can read it to a room.
277//
278// One live session per deck per presenter (spec §3.4). Pressing
279// Present again reuses the row and mints a FRESH remote token, which
280// is the one surprising thing in this handler and is deliberate: only
281// the hash is stored, so the old token cannot be shown again, and a
282// presenter view with no working buttons would be worse than a phone
283// that has to be re-paired. The pairing block says so.
284func (a *app) startPresenting(w http.ResponseWriter, r *http.Request) {
285 u, ok := a.viewer(w, r)
286 if !ok {
287 return
288 }
289 d, ok := a.deckFor(w, r, u)
290 if !ok {
291 return
292 }
293
294 token, err := newRemoteToken()
295 if err != nil {
296 a.fail(w, r, "mint a remote token", err)
297 return
298 }
299 actor := actorFor(u)
300
301 var p Presentation
302 err = a.livePresentations(r).Where("deck_id = ? AND actor = ?", d.ID, actor).
303 Order("started_at DESC").Take(&p).Error
304 switch {
305 case err == nil:
306 // Re-pairing an open session: the row keeps its sqid, its
307 // slide and its code, so every page already watching it stays
308 // watching the same talk.
309 if err := a.db.G.WithContext(r.Context()).Model(&Presentation{}).
310 Where("id = ? AND ended_at IS NULL", p.ID).
311 Update("remote_token_hash", hashRemoteToken(token)).Error; err != nil {
312 a.fail(w, r, "re-pair a presentation", err)
313 return
314 }
315 case errors.Is(err, gorm.ErrRecordNotFound):
316 p, err = a.createPresentation(r, d, actor, token)
317 if err != nil {
318 a.fail(w, r, "start a presentation", err)
319 return
320 }
321 default:
322 a.fail(w, r, "look for a live presentation", err)
323 return
324 }
325
326 http.Redirect(w, r, "/p/"+p.Sqid+"/notes?t="+token, http.StatusSeeOther)
327}
328
329// createPresentation writes the row, retrying on a code already in use
330// by another live session and on the vanishingly unlikely sqid
331// collision the unique index would refuse.
332func (a *app) createPresentation(r *http.Request, d Deck, actor, token string) (Presentation, error) {
333 for attempt := 0; attempt < maxCodeAttempts; attempt++ {
334 sqid, err := newSqid()
335 if err != nil {
336 return Presentation{}, err
337 }
338 code, err := newCode()
339 if err != nil {
340 return Presentation{}, err
341 }
342 var taken int64
343 if err := a.livePresentations(r).Where("code = ?", code).Count(&taken).Error; err != nil {
344 return Presentation{}, err
345 }
346 if taken > 0 {
347 continue
348 }
349 p := Presentation{
350 Sqid: sqid,
351 DeckID: d.ID,
352 Actor: actor,
353 Code: code,
354 RemoteTokenHash: hashRemoteToken(token),
355 StartedAt: time.Now().UTC(),
356 }
357 err = a.db.G.WithContext(r.Context()).Create(&p).Error
358 if err == nil {
359 return p, nil
360 }
361 // A typed check, not a search for "UNIQUE" in the message:
362 // rastrillo's db.Open sets GORM's TranslateError, so a
363 // violated index arrives as gorm.ErrDuplicatedKey whatever the
364 // driver phrased it as — and a string match is a retry loop
365 // resting on wording nobody in this repo controls.
366 if !errors.Is(err, gorm.ErrDuplicatedKey) {
367 return Presentation{}, err
368 }
369 }
370 return Presentation{}, errors.New("slides: no free pairing code after several attempts")
371}
372
373// --- the pages ---
374
375// presentShellFor is the chrome of the four capability screens: none.
376// They are reachable with no session at all — a projector, a phone, a
377// stranger holding a follow link — and shown full screen, on a lectern
378// or in somebody's hand, so they take no bar (shared chrome spec §12.1,
379// Slides). They used to carry the share-link shell, which showed a
380// signed-in presenter "Account → Sign in".
381func presentShellFor(r *http.Request, parts ...string) shell {
382 return shell{Frame: Frame{
383 Chrome: chrome.Bar{App: chrome.App{Name: appName}},
384 TitleParts: parts,
385 Theme: chrome.ThemeAttr(chrome.Appearance(r)),
386 Bare: true,
387 }}
388}
389
390// commandButton is one control: an op, and what it says. Each renders
391// as its own <form>, which is what makes the remote work with scripts
392// off (spec §8).
393type commandButton struct {
394 Op string
395 Label string
396 Slide string
397}
398
399// controls is the shared button block (templates/present_controls.html).
400// A pointer on a page's view data, so a page with nobody entitled to
401// drive simply has none rather than rendering dead buttons.
402type controls struct {
403 Action string
404 Token string
405 Buttons []commandButton
406}
407
408// surfacePage is the fullscreen surface: GET /p/{session}, and the
409// audience's follow view, which is the same page read-only.
410type surfacePage struct {
411 shell
412 Title string
413 // Session is the presentation's sqid, and is EMPTY on the
414 // audience's follow view (codex 5): a share-link holder must not
415 // learn the address the projector is on, because that address is a
416 // capability their own revocation cannot reach.
417 Session string
418 // StreamHref and FragmentHref are the two URLs static/present.js
419 // uses, handed down rather than built in the browser — so which
420 // capability a surface reads under is decided here, once, by the
421 // handler that knows. FragmentHref carries {n} where the slide
422 // number goes.
423 StreamHref string
424 FragmentHref string
425 // Token is present only for a request entitled to drive, and it is
426 // what the script reads to send a keystroke. A page without it is
427 // read-only in the markup AND in the script.
428 Token string
429
430 HasSlide bool
431 Slide template.HTML
432 Now int
433 Total int
434 Counter string
435
436 Blackout bool
437 BlackoutLabel string
438
439 ProgressLabel string
440 PagerLabel string
441 PrevLabel string
442 NextLabel string
443 PrevHref string
444 NextHref string
445 Controls *controls
446
447 NotesHref string
448 NotesLabel string
449
450 // Refresh is the follow view's scriptless heartbeat: a meta
451 // refresh, so an audience with no JavaScript still moves with the
452 // presenter. Off on the presenter's own surface, where a refresh
453 // mid-sentence would be worse than a stale slide.
454 Refresh bool
455 BackHref string
456 BackLabel string
457
458 EmptyTitle string
459 EmptyBody string
460}
461
462// presenterPage is GET /p/{session}/notes: the second screen.
463type presenterPage struct {
464 shell
465 Title string
466 DeckTitle string
467 Session string
468 Token string
469
470 Now int
471 Counter string
472 CurrentLabel string
473 Current template.HTML
474 NextLabel string
475 Next template.HTML
476 HasNext bool
477
478 NotesLabel string
479 Notes template.HTML
480 NoNotes string
481
482 Blackout bool
483 BlackoutLabel string
484
485 ClockLabel string
486 Clock string
487 Started string
488 Elapsed string
489 StartedAt int64
490 // ElapsedTemplate is the catalog's own pattern, unsubstituted, so
491 // static/present.js can keep the elapsed line ticking without a
492 // sentence of English being written in a .js file.
493 ElapsedTemplate string
494
495 // The pairing block, rendered once: only for the presenter's own
496 // session, and only on the one request that carries the token.
497 Pair bool
498 PairTitle string
499 PairBody string
500 CodeLabel string
501 Code string
502 URLLabel string
503 RemoteURL string
504 QR template.HTML
505 Controls *controls
506 StageHref string
507 StageLabel string
508 DeckHref string
509 DeckLabel string
510 ToolsLabel string
511}
512
513// remotePage is GET /p/{session}/remote: the phone.
514type remotePage struct {
515 shell
516 Title string
517 DeckTitle string
518 Session string
519
520 Now int
521 Counter string
522 CurrentLabel string
523 Current template.HTML
524 NextLabel string
525 Next template.HTML
526 HasNext bool
527
528 NotesLabel string
529 Notes template.HTML
530 NoNotes string
531
532 Blackout bool
533 BlackoutLabel string
534
535 Controls *controls
536 PresenterHref string
537 PresenterLabel string
538 ToolsLabel string
539}
540
541// pairPage is GET /r/{code}: the way in for a code somebody typed.
542type pairPage struct {
543 shell
544 Title string
545 Body string
546 Action string
547 Code string
548 CodeLabel string
549 CodeHint string
550 TokenLabel string
551 TokenHint string
552 Submit string
553 Error string
554}
555
556// --- GET /p/{session} ---
557
558// presentSurface is the fullscreen surface — the projector.
559//
560// Three requests land here and it answers all three from one path:
561// the page itself, the same page with ?slide=N for a scriptless
562// reader, and ?fragment=1 for the script, which swaps the slide
563// without reloading. The fragment is the SAME render as the page's
564// own, deliberately: two renderers for one slide is two ways for the
565// projector to be wrong.
566func (a *app) presentSurface(w http.ResponseWriter, r *http.Request) {
567 p, ok := a.presentationFor(w, r)
568 if !ok {
569 return
570 }
571 d, ok := a.deckOf(w, r, p)
572 if !ok {
573 return
574 }
575 a.renderSurface(w, r, d, p, surfaceOptions{
576 token: r.URL.Query().Get("t"),
577 // The projector's own address, which is the address it is
578 // already on: nothing is disclosed by saying so.
579 base: "/p/" + p.Sqid,
580 session: p.Sqid,
581 stream: "/p/" + p.Sqid + "/stream",
582 //nolint:goconst // one URL, spelled where it is decided
583 fragment: "/p/" + p.Sqid + "?slide={n}&fragment=1",
584 })
585}
586
587// surfaceOptions is what differs between the projector and the
588// audience's follow view — which is very little, which is the point.
589type surfaceOptions struct {
590 // token, when it is the session's, makes the pager a pair of
591 // command forms rather than a pair of local links.
592 token string
593 // base is the capability this surface reads under: "/p/{sqid}" for
594 // a projector, "/s/{token}" for somebody following a share link.
595 // The pictures hang off it (assetBaseFor), so an <img> on this page
596 // is fetchable by exactly whoever is looking at it and by nobody
597 // else.
598 base string
599 // session is the sqid, and is deliberately not derived from p:
600 // putting it on the page is a disclosure, and the follow view does
601 // not make it (codex 5).
602 session string
603 // stream and fragment are the two URLs the script uses. fragment
604 // carries {n} where the slide number goes.
605 stream string
606 fragment string
607 // follow marks the audience's copy: a meta refresh, a way back to
608 // browsing, and never a control.
609 follow bool
610 backHref string
611 backLabel string
612}
613
614func (a *app) renderSurface(w http.ResponseWriter, r *http.Request, d Deck, p Presentation, opt surfaceOptions) {
615 h, view, err := a.openDeck(r.Context(), d)
616 if err != nil {
617 a.fail(w, r, "open deck", err)
618 return
619 }
620 defer h.Close(r.Context())
621
622 q := r.URL.Query()
623 page := surfacePage{
624 shell: presentShellFor(r, view.Meta.Title),
625 Title: view.Meta.Title,
626 Session: opt.session,
627 StreamHref: opt.stream,
628 FragmentHref: opt.fragment,
629 }
630 if len(view.Slides) == 0 {
631 page.EmptyTitle = rastrillo.T(r, "slides.viewer.empty.title")
632 page.EmptyBody = rastrillo.T(r, "slides.viewer.empty.body")
633 render(w, "present", page)
634 return
635 }
636
637 // The session's own slide is what this shows, unless the request
638 // named one: ?slide=N is how a scriptless reader pages, and how
639 // the script fetches the slide an event just named.
640 index := clampSlide(p.Slide, len(view.Slides))
641 live := true
642 if raw := q.Get("slide"); raw != "" {
643 n, ok := slideIndex(raw, len(view.Slides))
644 if !ok {
645 a.notFound(w, r)
646 return
647 }
648 index, live = n, false
649 }
650
651 // Neither the projector nor the audience has a session, so the
652 // pictures go under whichever capability this surface IS
653 // (assets.go): the presentation's for a projector, the share
654 // link's for a follower.
655 html, err := slidehtml.SlideWith(view, index, slidehtml.Desktop,
656 slidehtml.Options{AssetBase: assetBaseFor(opt.base)})
657 if err != nil {
658 a.fail(w, r, "render slide", err)
659 return
660 }
661 if q.Get("fragment") == "1" {
662 // The bare slide, for the script's swap. No layout, and no
663 // cache: it is one moment of a live session.
664 w.Header().Set("Content-Type", "text/html; charset=utf-8")
665 w.Header().Set("Cache-Control", "no-store")
666 io.WriteString(w, string(html))
667 return
668 }
669
670 page.HasSlide = true
671 page.Slide = html
672 page.Now = index + 1
673 page.Total = len(view.Slides)
674 page.Counter = rastrillo.Tf(r, "slides.present.progress", "n", page.Now, "total", page.Total)
675 page.ProgressLabel = rastrillo.T(r, "slides.present.progress.label")
676 page.PagerLabel = rastrillo.T(r, "slides.viewer.pager")
677 page.PrevLabel = rastrillo.T(r, "slides.present.prev")
678 page.NextLabel = rastrillo.T(r, "slides.present.next")
679 // Blacking out is the session's state, so it applies to the
680 // session's own slide and not to a page somebody scrolled off to.
681 page.Blackout = p.Blackout && live
682 page.BlackoutLabel = rastrillo.T(r, "slides.present.blacked")
683
684 switch {
685 case opt.follow:
686 page.Refresh = true
687 page.BackHref = opt.backHref
688 page.BackLabel = opt.backLabel
689 case remoteTokenOK(p, opt.token):
690 // Whoever holds the remote drives the session from here too:
691 // the pager posts commands, so the projector's own next button
692 // moves the talk rather than only this browser.
693 page.Token = opt.token
694 page.Controls = &controls{
695 Action: "/p/" + p.Sqid + "/command",
696 Token: opt.token,
697 Buttons: []commandButton{
698 {Op: "prev", Label: page.PrevLabel},
699 {Op: "next", Label: page.NextLabel},
700 },
701 }
702 page.NotesHref = "/p/" + p.Sqid + "/notes?t=" + url.QueryEscape(opt.token)
703 page.NotesLabel = rastrillo.T(r, "slides.present.notes.title")
704 default:
705 // A watcher may page through the deck on their own screen
706 // without moving anybody else's: ordinary links, no session
707 // write, and the next event puts them back on the presenter's
708 // slide.
709 if index > 0 {
710 page.PrevHref = opt.base + "?slide=" + strconv.Itoa(index)
711 }
712 if index+1 < len(view.Slides) {
713 page.NextHref = opt.base + "?slide=" + strconv.Itoa(index+2)
714 }
715 }
716 render(w, "present", page)
717}
718
719// clampSlide keeps a stored index inside a deck that may have lost
720// slides since the session started. A deck that shrank mid-talk is a
721// real thing an editor can do, and the surface showing the last slide
722// beats it showing a 500.
723func clampSlide(index, count int) int {
724 if index < 0 || count == 0 {
725 return 0
726 }
727 if index >= count {
728 return count - 1
729 }
730 return index
731}
732
733// --- GET /p/{session}/notes ---
734
735// presenterNotes is the presenter view: what is on screen, what is
736// next, the notes, the clock, and — once — how to pair a phone.
737//
738// Who may read it: the presenter's own signed-in session, or whoever
739// holds the remote token. Never the session sqid alone, which is the
740// address that goes on a projector.
741func (a *app) presenterNotes(w http.ResponseWriter, r *http.Request) {
742 p, ok := a.presentationFor(w, r)
743 if !ok {
744 return
745 }
746 token := r.URL.Query().Get("t")
747 holder := remoteTokenOK(p, token)
748 presenter := p.Actor != "" && a.presenterActor(r) == p.Actor
749 if !holder && !presenter {
750 a.notFound(w, r)
751 return
752 }
753 d, ok := a.deckOf(w, r, p)
754 if !ok {
755 return
756 }
757 h, view, err := a.openDeck(r.Context(), d)
758 if err != nil {
759 a.fail(w, r, "open deck", err)
760 return
761 }
762 defer h.Close(r.Context())
763
764 now := time.Now().UTC()
765 page := presenterPage{
766 shell: presentShellFor(r, rastrillo.T(r, "slides.present.notes.title"), view.Meta.Title),
767 Title: rastrillo.T(r, "slides.present.notes.title"),
768 DeckTitle: view.Meta.Title,
769 Session: p.Sqid,
770
771 CurrentLabel: rastrillo.T(r, "slides.present.current"),
772 NextLabel: rastrillo.T(r, "slides.present.next.label"),
773 NotesLabel: rastrillo.T(r, "slides.viewer.notes"),
774 NoNotes: rastrillo.T(r, "slides.notes.none"),
775
776 Blackout: p.Blackout,
777 BlackoutLabel: rastrillo.T(r, "slides.present.blacked"),
778
779 ClockLabel: rastrillo.T(r, "slides.present.clock"),
780 Clock: clockWords(now),
781 Started: rastrillo.Tf(r, "slides.present.started", "when", clockWords(p.StartedAt)),
782 Elapsed: rastrillo.Tf(r, "slides.present.elapsed", "n", int(now.Sub(p.StartedAt).Minutes())),
783 StartedAt: p.StartedAt.Unix(),
784 // T rather than Tf: the pattern with its {n} intact.
785 ElapsedTemplate: rastrillo.T(r, "slides.present.elapsed"),
786
787 DeckHref: "/d/" + d.Sqid,
788 DeckLabel: rastrillo.T(r, "slides.deck.back"),
789 ToolsLabel: rastrillo.T(r, "slides.viewer.tools"),
790 }
791 shown := presentedFor(r, view, p)
792 page.Now, page.Counter, page.Current = shown.Now, shown.Counter, shown.Current
793 page.Next, page.HasNext, page.Notes = shown.Next, shown.HasNext, shown.Notes
794
795 if holder {
796 page.Token = token
797 page.Controls = commandControls(r, p, token)
798 page.StageHref = "/p/" + p.Sqid + "?t=" + url.QueryEscape(token)
799 page.StageLabel = rastrillo.T(r, "slides.present.stage")
800 }
801 // Once, and only to the person presenting: the token is in this
802 // URL, so the pairing block is what that one request is FOR.
803 // Coming back to the page without it — from a bookmark, from the
804 // browser's history — shows the talk and no capability.
805 if holder && presenter {
806 remoteURL := a.cfg.Origin + "/r/" + p.Code + "?t=" + token
807 qr, err := qrSVG(remoteURL, rastrillo.Tf(r, "slides.present.pair.qr", "code", p.Code))
808 if err != nil {
809 a.fail(w, r, "render the pairing QR", err)
810 return
811 }
812 page.Pair = true
813 page.PairTitle = rastrillo.T(r, "slides.present.pair.title")
814 page.PairBody = rastrillo.T(r, "slides.present.pair.body")
815 page.CodeLabel = rastrillo.T(r, "slides.present.pair.code")
816 page.Code = p.Code
817 page.URLLabel = rastrillo.T(r, "slides.present.pair.url")
818 page.RemoteURL = remoteURL
819 page.QR = qr
820 }
821 render(w, "present_notes", page)
822}
823
824// clockWords is a wall clock a presenter can read at a glance. UTC and
825// said so: the server has no idea what time zone the room is in, and a
826// clock that is quietly an hour out is worse than one that names its
827// zone. static/present.js replaces it with the browser's own local
828// time when there is a script to do it.
829func clockWords(t time.Time) string {
830 return t.UTC().Format("15:04") + " UTC"
831}
832
833// presented is the two slides and the notes every presenting screen
834// shows: the one on the projector, the one coming next, and the notes
835// for the one that is up.
836//
837// The two slides are PICTURES — an <img> of the session's own SVG
838// thumbnail (thumbs.go) — and not renderings of the slide. Both
839// screens show them small beside the notes somebody is reading while
840// talking, and a whole live copy of a slide is a lot of DOM for a
841// picture: two of them on a phone, re-laid-out on every rotation, over
842// a hotel wifi. The projector at /p/{session} still gets the real
843// rendering, because that one is the slide.
844type presented struct {
845 // Now is the 1-based slide these were rendered for — the number the
846 // stream's events carry — and it goes on the page's script root as
847 // data-slide, so static/present.js knows what it is already showing.
848 // Without it the script took the page to be on slide 0, the first
849 // event "moved" it, and the page reloaded itself on every load
850 // (polish audit B3).
851 Now int
852 Counter string
853 Current template.HTML
854 Next template.HTML
855 HasNext bool
856 Notes template.HTML
857}
858
859// presentedFor builds them. A slide that will not draw leaves its half
860// empty rather than failing the page: the presenter view is what
861// somebody is reading WHILE talking, and half of it beats an error
862// page.
863func presentedFor(r *http.Request, view *deck.Deck, p Presentation) presented {
864 count := len(view.Slides)
865 if count == 0 {
866 return presented{}
867 }
868 index := clampSlide(p.Slide, count)
869 out := presented{
870 Now: index + 1,
871 Counter: rastrillo.Tf(r, "slides.present.progress", "n", index+1, "total", count),
872 Current: slideThumbImg(r, view, p, index),
873 }
874 if index+1 < count {
875 out.HasNext = true
876 out.Next = slideThumbImg(r, view, p, index+1)
877 }
878 if s := view.Slides[index]; len(s.Notes) > 0 {
879 out.Notes = slidehtml.Notes(s.Notes)
880 }
881 return out
882}
883
884// slideThumbImg is one small rendering: the session's thumbnail route,
885// with the slide's own label as the alt.
886//
887// The alt and the picture's own <title> say the same words on purpose.
888// A screen reader announces an image by its alt and never opens the
889// file to find the title inside it; a person who opens the SVG on its
890// own has only the title. Neither surface should be the one without a
891// name.
892//
893// No cache-buster on the URL: the route answers
894// `max-age=0, must-revalidate`, so a browser revalidates every time
895// and gets a 304 for a slide that has not changed. The editor's strip
896// carries a seq because there the src has to CHANGE for the browser to
897// ask at all.
898func slideThumbImg(r *http.Request, view *deck.Deck, p Presentation, index int) template.HTML {
899 s := view.Slides[index]
900 return template.HTML(fmt.Sprintf(
901 `<img slides-thumb-img src="/p/%s/slides/%s.svg" alt="%s" width="1920" height="1080">`,
902 template.HTMLEscapeString(p.Sqid),
903 template.HTMLEscapeString(s.ID),
904 template.HTMLEscapeString(slideLabel(r, s, index+1))))
905}
906
907// commandControls is the four-button block the presenter view and the
908// remote both carry. One function, so the two screens cannot drift on
909// what a button is called or what it does — including the blackout
910// label, which says which way it goes rather than lighting up.
911func commandControls(r *http.Request, p Presentation, token string) *controls {
912 blackout := rastrillo.T(r, "slides.present.blackout")
913 if p.Blackout {
914 blackout = rastrillo.T(r, "slides.present.unblackout")
915 }
916 return &controls{
917 Action: "/p/" + p.Sqid + "/command",
918 Token: token,
919 Buttons: []commandButton{
920 {Op: "prev", Label: rastrillo.T(r, "slides.present.prev")},
921 {Op: "next", Label: rastrillo.T(r, "slides.present.next")},
922 {Op: "blackout", Label: blackout},
923 {Op: "end", Label: rastrillo.T(r, "slides.present.end")},
924 },
925 }
926}
927
928// --- GET /p/{session}/remote ---
929
930// remote is the phone: the slide that is up, the one coming, the
931// notes, and four buttons big enough to hit without looking.
932//
933// The token is the whole permission, so a wrong one is a 404 like
934// every other capability in this app.
935func (a *app) remote(w http.ResponseWriter, r *http.Request) {
936 p, ok := a.presentationFor(w, r)
937 if !ok {
938 return
939 }
940 token := r.URL.Query().Get("t")
941 if !remoteTokenOK(p, token) {
942 a.notFound(w, r)
943 return
944 }
945 d, ok := a.deckOf(w, r, p)
946 if !ok {
947 return
948 }
949 h, view, err := a.openDeck(r.Context(), d)
950 if err != nil {
951 a.fail(w, r, "open deck", err)
952 return
953 }
954 defer h.Close(r.Context())
955
956 page := remotePage{
957 shell: presentShellFor(r, rastrillo.T(r, "slides.remote.title"), view.Meta.Title),
958 Title: rastrillo.T(r, "slides.remote.title"),
959 DeckTitle: view.Meta.Title,
960 Session: p.Sqid,
961 CurrentLabel: rastrillo.T(r, "slides.present.current"),
962 NextLabel: rastrillo.T(r, "slides.present.next.label"),
963 NotesLabel: rastrillo.T(r, "slides.viewer.notes"),
964 NoNotes: rastrillo.T(r, "slides.notes.none"),
965 Blackout: p.Blackout,
966 BlackoutLabel: rastrillo.T(r, "slides.present.blacked"),
967 Controls: commandControls(r, p, token),
968 PresenterHref: "/p/" + p.Sqid + "/notes?t=" + url.QueryEscape(token),
969 PresenterLabel: rastrillo.T(r, "slides.present.notes.title"),
970 ToolsLabel: rastrillo.T(r, "slides.viewer.tools"),
971 }
972 shown := presentedFor(r, view, p)
973 page.Now, page.Counter, page.Current = shown.Now, shown.Counter, shown.Current
974 page.Next, page.HasNext, page.Notes = shown.Next, shown.HasNext, shown.Notes
975 render(w, "remote", page)
976}
977
978// --- GET /r/{code} ---
979
980// remotePair is where a scanned QR and a typed code both land.
981//
982// With the token in the URL — the QR carries both halves — it is a
983// redirect to the remote and nothing else. Without it, it is a form
984// asking for the code and the key, because the code alone is not a
985// capability and this page must not pretend otherwise.
986func (a *app) remotePair(w http.ResponseWriter, r *http.Request) {
987 q := r.URL.Query()
988 code := strings.ToUpper(strings.TrimSpace(chi.URLParam(r, "code")))
989 token := q.Get("t")
990
991 // A code typed into the form arrives as a query on whatever code's
992 // page the form was rendered on, so the form's own answer is a
993 // redirect to the page for the code that was typed. That is what
994 // lets one route serve both the scan and the typing.
995 if typed := strings.ToUpper(strings.TrimSpace(q.Get("code"))); typed != "" && typed != code {
996 if !validCode(typed) {
997 a.notFound(w, r)
998 return
999 }
1000 to := "/r/" + typed
1001 if token != "" {
1002 to += "?t=" + url.QueryEscape(token)
1003 }
1004 http.Redirect(w, r, to, http.StatusSeeOther)
1005 return
1006 }
1007
1008 var p Presentation
1009 err := a.livePresentations(r).Where("code = ?", code).Order("started_at DESC").Take(&p).Error
1010 switch {
1011 case err == nil:
1012 case errors.Is(err, gorm.ErrRecordNotFound):
1013 // A dead code is a 404, never a 410: "this code was real
1014 // yesterday" is not something to tell somebody guessing.
1015 a.notFound(w, r)
1016 return
1017 default:
1018 a.fail(w, r, "resolve a pairing code", err)
1019 return
1020 }
1021
1022 if token != "" && remoteTokenOK(p, token) {
1023 http.Redirect(w, r, "/p/"+p.Sqid+"/remote?t="+url.QueryEscape(token), http.StatusSeeOther)
1024 return
1025 }
1026
1027 page := pairPage{
1028 shell: presentShellFor(r, rastrillo.T(r, "slides.remote.pair.title")),
1029 Title: rastrillo.T(r, "slides.remote.pair.title"),
1030 Body: rastrillo.T(r, "slides.remote.pair.body"),
1031 Action: "/r/" + code,
1032 Code: code,
1033 CodeLabel: rastrillo.T(r, "slides.remote.pair.code"),
1034 CodeHint: rastrillo.T(r, "slides.remote.pair.code.hint"),
1035 TokenLabel: rastrillo.T(r, "slides.remote.pair.token"),
1036 TokenHint: rastrillo.T(r, "slides.remote.pair.token.hint"),
1037 Submit: rastrillo.T(r, "slides.remote.pair.submit"),
1038 }
1039 if token != "" {
1040 // The code is live and the key is not its own: say so on the
1041 // form rather than answering 404, which would send somebody
1042 // mistyping a key round the houses.
1043 page.Error = rastrillo.T(r, "slides.remote.pair.error")
1044 w.WriteHeader(http.StatusUnprocessableEntity)
1045 }
1046 render(w, "remote_pair", page)
1047}
1048
1049// validCode keeps a typed code inside the alphabet before it becomes a
1050// URL: the redirect above builds a path out of it.
1051func validCode(code string) bool {
1052 if len(code) != codeLength {
1053 return false
1054 }
1055 for _, c := range code {
1056 if !strings.ContainsRune(codeAlphabet, c) {
1057 return false
1058 }
1059 }
1060 return true
1061}
1062
1063// --- GET /p/{session}/stream ---
1064
1065// The stream's timings, and the reasons, are Docs'
1066// (internal/docs/stream.go): a per-write deadline because rastrillo
1067// serve leaves WriteTimeout off for exactly this handler's sake, a
1068// heartbeat because it is what makes that deadline ever fire, and a
1069// TTL because platform hibernation is default-on and a projector left
1070// on overnight must not pin the instance awake.
1071const (
1072 streamWriteDeadline = 30 * time.Second
1073 streamHeartbeat = 15 * time.Second
1074 streamTTL = 5 * time.Minute
1075)
1076
1077// presentStream is the session's SSE feed: `event: slide` carrying the
1078// whole state as JSON, one event on connect, and a comment ping in
1079// between.
1080//
1081// Unlike Docs' stream, what travels here IS the state rather than an
1082// advisory cursor — a presentation has no "fetch what you missed"
1083// endpoint to make an advisory notification safe. That is why the hub
1084// drops a subscriber that falls behind instead of skipping events
1085// (internal/slides/present), and why the connect event matters: a
1086// dropped page reconnects and is immediately right again.
1087func (a *app) presentStream(w http.ResponseWriter, r *http.Request) {
1088 p, ok := a.presentationFor(w, r)
1089 if !ok {
1090 return
1091 }
1092 a.streamPresentation(w, r, p, nil)
1093}
1094
1095// streamPresentation is the SSE body, shared by the session's own
1096// stream and the share link's (codex 5). `alive`, when it is not nil,
1097// is re-asked before every write: it is how a follower's stream ends
1098// the moment the link they arrived with is revoked, rather than
1099// carrying on to the end of the talk because the session is still
1100// live. One indexed read per event and per 15-second ping, which is
1101// the price of a revocation that means something.
1102func (a *app) streamPresentation(w http.ResponseWriter, r *http.Request, p Presentation, alive func() bool) {
1103 fl, ok := w.(http.Flusher)
1104 if !ok {
1105 a.fail(w, r, "stream: the ResponseWriter does not flush", nil)
1106 return
1107 }
1108
1109 // Subscribe BEFORE re-reading the row, never after (Docs' note): a
1110 // command landing between the two must produce a redundant event,
1111 // which costs one repaint, where the other order would let it fall
1112 // into the gap and leave the projector a slide behind for the rest
1113 // of the talk.
1114 ch, stop := a.hub.Subscribe(p.Sqid)
1115 defer stop()
1116
1117 var current Presentation
1118 if err := a.db.G.WithContext(r.Context()).Take(&current, p.ID).Error; err != nil {
1119 a.fail(w, r, "read a presentation for the stream", err)
1120 return
1121 }
1122
1123 rc := http.NewResponseController(w)
1124 w.Header().Set("Content-Type", "text/event-stream")
1125 w.Header().Set("Cache-Control", "no-store")
1126 w.WriteHeader(http.StatusOK)
1127 fl.Flush()
1128
1129 write := func(text string) bool {
1130 if alive != nil && !alive() {
1131 return false
1132 }
1133 // The deadline error is ignored deliberately: a
1134 // ResponseWriter that cannot set deadlines still streams,
1135 // unbounded. A write error ends the handler, because it means
1136 // the client is gone.
1137 _ = rc.SetWriteDeadline(time.Now().Add(streamWriteDeadline))
1138 if _, err := io.WriteString(w, text); err != nil {
1139 return false
1140 }
1141 fl.Flush()
1142 return true
1143 }
1144 event := func(e present.Event) bool {
1145 body, err := json.Marshal(e)
1146 if err != nil {
1147 return false
1148 }
1149 return write("event: slide\ndata: " + string(body) + "\n\n")
1150 }
1151
1152 if !event(eventFor(current)) {
1153 return
1154 }
1155
1156 heartbeat := time.NewTimer(streamHeartbeat)
1157 defer heartbeat.Stop()
1158 ttl := time.NewTimer(streamTTL)
1159 defer ttl.Stop()
1160
1161 for {
1162 select {
1163 case <-r.Context().Done():
1164 // The client went away, or the server is draining. Either
1165 // way this returns, so a graceful shutdown's budget is not
1166 // held open by a projector.
1167 return
1168 case <-ttl.C:
1169 // A clean close: the browser reopens if it still wants
1170 // one, and a parked tab does not.
1171 return
1172 case e, ok := <-ch:
1173 if !ok {
1174 // Dropped for falling behind. Ending the response is
1175 // the right answer: EventSource reconnects, and the
1176 // connect event re-reads the truth from the row.
1177 return
1178 }
1179 if !event(e) {
1180 return
1181 }
1182 heartbeat.Reset(streamHeartbeat)
1183 case <-heartbeat.C:
1184 if !write(": ping\n\n") {
1185 return
1186 }
1187 heartbeat.Reset(streamHeartbeat)
1188 }
1189 }
1190}
1191
1192// --- POST /p/{session}/command ---
1193
1194// errPresentationEnded is the transaction's own refusal: the row was
1195// ended by somebody else between this request resolving it and the
1196// UPDATE. It becomes a 410, the same answer the GET would have given.
1197var errPresentationEnded = errors.New("slides: the presentation ended")
1198
1199// presentCommand is the one write every driving surface makes: next,
1200// prev, goto, blackout, end.
1201//
1202// The token is the whole permission — a form field for a browser, a
1203// bearer for anything else, which is the seam a native remote will use
1204// (spec §3.4: "a native remote later speaks exactly present, command
1205// and the stream"). Note that a non-browser caller must still send an
1206// Origin header: csrf.Protect covers this router app-wide, and being
1207// covered by it is worth more than sparing a native client one header.
1208//
1209// The write is a transaction with `ended_at IS NULL` in the WHERE, so
1210// a command racing the End button loses rather than reviving a talk
1211// that is over.
1212func (a *app) presentCommand(w http.ResponseWriter, r *http.Request) {
1213 p, ok := a.presentationFor(w, r)
1214 if !ok {
1215 return
1216 }
1217 token := r.PostFormValue("t")
1218 if token == "" {
1219 token = bearerToken(r)
1220 }
1221 if !remoteTokenOK(p, token) {
1222 a.notFound(w, r)
1223 return
1224 }
1225
1226 op := r.PostFormValue("op")
1227 // The slide count is what bounds next and goto, and it costs a
1228 // document open — so only the three ops that move the deck pay for
1229 // it. Blackout and end do not need to know how long the deck is.
1230 count := 0
1231 if op == "next" || op == "prev" || op == "goto" {
1232 d, ok := a.deckOf(w, r, p)
1233 if !ok {
1234 return
1235 }
1236 h, view, err := a.openDeck(r.Context(), d)
1237 if err != nil {
1238 a.fail(w, r, "open deck", err)
1239 return
1240 }
1241 count = len(view.Slides)
1242 h.Close(r.Context())
1243 if count == 0 {
1244 a.errorPage(w, r, http.StatusUnprocessableEntity, kindStatus, "")
1245 return
1246 }
1247 }
1248
1249 var updated Presentation
1250 err := a.db.G.WithContext(r.Context()).Transaction(func(tx *gorm.DB) error {
1251 var cur Presentation
1252 if err := tx.Where("id = ? AND ended_at IS NULL", p.ID).Take(&cur).Error; err != nil {
1253 if errors.Is(err, gorm.ErrRecordNotFound) {
1254 return errPresentationEnded
1255 }
1256 return err
1257 }
1258 changes, err := commandChanges(op, r.PostFormValue("slide"), cur, count)
1259 if err != nil {
1260 return err
1261 }
1262 // A map, not a struct: the explicit column allowlist SKILL.md
1263 // §4 asks for, so nothing a form carries can reach a column
1264 // this handler did not name.
1265 res := tx.Model(&Presentation{}).
1266 Where("id = ? AND ended_at IS NULL", p.ID).Updates(changes)
1267 if res.Error != nil {
1268 return res.Error
1269 }
1270 if res.RowsAffected == 0 {
1271 return errPresentationEnded
1272 }
1273 return tx.Take(&updated, p.ID).Error
1274 })
1275 switch {
1276 case err == nil:
1277 case errors.Is(err, errPresentationEnded):
1278 a.gone(w, r)
1279 return
1280 case errors.Is(err, errBadCommand):
1281 a.errorPage(w, r, http.StatusUnprocessableEntity, kindStatus, "")
1282 return
1283 default:
1284 a.fail(w, r, "apply a presentation command", err)
1285 return
1286 }
1287
1288 // Everybody watching, at once: the presenter's laptop, the phone,
1289 // and every follower.
1290 a.hub.Publish(p.Sqid, eventFor(updated))
1291 // The actor is the CAPABILITY, not the person who started the talk
1292 // (codex 5's sibling, codex 15). A remote token is transferable by
1293 // design — that is the feature: it goes on a phone, and the phone
1294 // may be somebody else's. So this write cannot name a person, and
1295 // naming p.Actor anyway made §6.2's "every write names its real
1296 // actor" false for exactly the writes nobody could vouch for.
1297 // `remote:<session>` says what actually authenticated, and the
1298 // issuer rides beside it so the trail still reaches somebody
1299 // answerable.
1300 a.logger.Info("slides: presentation command",
1301 "session", p.Sqid, "op", op, "slide", updated.Slide,
1302 "actor", remoteActor(p), "issuer", p.Actor)
1303
1304 // A browser form goes back to the page it came from — same-origin
1305 // only, checked against the origin the app resolved, so this can
1306 // never be an open redirect. Anything else gets a 204: there is no
1307 // page to return to.
1308 if ref := r.Header.Get("Referer"); ref != "" && a.sameOrigin(ref) {
1309 http.Redirect(w, r, ref, http.StatusSeeOther)
1310 return
1311 }
1312 w.WriteHeader(http.StatusNoContent)
1313}
1314
1315// remoteActor is spec §6.2's actor string for a presentation remote —
1316// the third kind, beside decks.go's `person:<id>` and agents.go's
1317// `agent:<id>`, and the only one that names no principal at all.
1318//
1319// It is deliberately anonymous (§6.2, codex 15). The remote token is a
1320// transferable capability: the presenter shows a QR to the room, or
1321// hands their phone to a colleague, and the server has no way to say
1322// who is holding it. Writing `person:<the presenter>` on those commands
1323// was a claim about identity the app could not support. This one is
1324// honest — "whoever held the remote for this session" — and the
1325// session's own Actor is logged beside it, so the person who issued the
1326// capability is still named.
1327func remoteActor(p Presentation) string { return "remote:" + p.Sqid }
1328
1329// errBadCommand is an op, or a goto's slide, that is not one this
1330// session can do.
1331var errBadCommand = errors.New("slides: not a command")
1332
1333// commandChanges is the whole decision a command makes, as the columns
1334// it writes. Split out from the transaction so it is one readable
1335// switch rather than a switch inside a closure inside a handler.
1336func commandChanges(op, slide string, cur Presentation, count int) (map[string]any, error) {
1337 switch op {
1338 case "next":
1339 return map[string]any{"slide": clampSlide(cur.Slide+1, count)}, nil
1340 case "prev":
1341 return map[string]any{"slide": clampSlide(cur.Slide-1, count)}, nil
1342 case "goto":
1343 n, err := strconv.Atoi(slide)
1344 if err != nil || n < 1 || n > count {
1345 return nil, errBadCommand
1346 }
1347 return map[string]any{"slide": n - 1}, nil
1348 case "blackout":
1349 // A toggle rather than a value, because the button that sends
1350 // it says which way it goes and the room is the source of
1351 // truth for whether the screen is black.
1352 return map[string]any{"blackout": !cur.Blackout}, nil
1353 case "end":
1354 return map[string]any{"ended_at": time.Now().UTC()}, nil
1355 default:
1356 return nil, errBadCommand
1357 }
1358}
1359
1360// bearerToken reads `Authorization: Bearer <token>`, the shape a
1361// native remote or an agent will use.
1362func bearerToken(r *http.Request) string {
1363 h := r.Header.Get("Authorization")
1364 const prefix = "Bearer "
1365 if len(h) <= len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) {
1366 return ""
1367 }
1368 return strings.TrimSpace(h[len(prefix):])
1369}
1370
1371// sameOrigin reports whether a URL is this app's own — the check
1372// behind the command's 303, and the reason it cannot be turned into an
1373// open redirect by a crafted Referer.
1374func (a *app) sameOrigin(raw string) bool {
1375 u, err := url.Parse(raw)
1376 if err != nil || u.Scheme == "" || u.Host == "" {
1377 return false
1378 }
1379 return u.Scheme+"://"+u.Host == a.cfg.Origin
1380}
1381
1382// --- audience follow, from a share link ---
1383
1384// liveSessionFor finds the session somebody is currently presenting
1385// this deck in, if there is one. The most recent, when a deck somehow
1386// has two: the one that started last is the one being given.
1387func (a *app) liveSessionFor(r *http.Request, deckID int64) (Presentation, bool) {
1388 var p Presentation
1389 err := a.livePresentations(r).Where("deck_id = ?", deckID).
1390 Order("started_at DESC").Take(&p).Error
1391 if err != nil {
1392 if !errors.Is(err, gorm.ErrRecordNotFound) {
1393 a.logger.Error("slides: look for a live presentation", "deck", deckID, "err", err)
1394 }
1395 return Presentation{}, false
1396 }
1397 return p, true
1398}
1399
1400// followPresenter renders the audience's copy of the surface: the
1401// presenter's own slide, read-only, with a way back to browsing.
1402//
1403// Read-only is enforced by there being nothing to press — no token
1404// reaches this page, so its script has nothing to send and its markup
1405// has no form. An audience that could drive the talk would not be an
1406// audience.
1407//
1408// And every URL on it is `base`, which is the share link (codex 5):
1409// the pictures, the fragments the script swaps in and the stream it
1410// subscribes to. The session sqid appears nowhere, so there is no
1411// address here that outlives the capability it came with.
1412func (a *app) followPresenter(w http.ResponseWriter, r *http.Request, d Deck, p Presentation, base string) {
1413 a.renderSurface(w, r, d, p, surfaceOptions{
1414 follow: true,
1415 base: base,
1416 stream: base + "/stream",
1417 fragment: base + "?follow=1&slide={n}&fragment=1",
1418 // No session: see surfacePage.Session.
1419 backHref: base,
1420 backLabel: rastrillo.T(r, "slides.follow.browse"),
1421 })
1422}
1423
1424// --- the QR ---
1425
1426// qrSVG draws the pairing QR as an inline SVG.
1427//
1428// Inline rather than an <img> with a data URI: it is markup the page
1429// already has, so it costs no second request and no base64 padding,
1430// and role="img" with a label naming the code is what makes it
1431// meaningful to somebody who cannot see it — the label says the code,
1432// which is the actual information the picture carries.
1433//
1434// Black on white, fixed, rather than currentColor: a QR is read by a
1435// camera looking for contrast, and a code that inverted itself in dark
1436// mode would scan on half the phones in the room.
1437func qrSVG(text, label string) (template.HTML, error) {
1438 q, err := qrcode.New(text, qrcode.Medium)
1439 if err != nil {
1440 return "", fmt.Errorf("slides: build a QR: %w", err)
1441 }
1442 bitmap := q.Bitmap()
1443 size := len(bitmap)
1444 if size == 0 {
1445 return "", errors.New("slides: empty QR")
1446 }
1447
1448 // One path for every dark module, rather than one <rect> each: the
1449 // same picture in about a fifth of the bytes.
1450 var path strings.Builder
1451 for y, row := range bitmap {
1452 for x, dark := range row {
1453 if dark {
1454 fmt.Fprintf(&path, "M%d %dh1v1h-1z", x, y)
1455 }
1456 }
1457 }
1458
1459 var svg strings.Builder
1460 fmt.Fprintf(&svg, `<svg slides-qr xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" width="180" height="180" role="img" aria-label="%s">`,
1461 size, size, template.HTMLEscapeString(label))
1462 fmt.Fprintf(&svg, `<rect width="%d" height="%d" fill="#ffffff"/>`, size, size)
1463 fmt.Fprintf(&svg, `<path fill="#000000" d="%s"/></svg>`, path.String())
1464 return template.HTML(svg.String()), nil
1465}
1466