| 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. |
| 42 | package slides |
| 43 | |
| 44 | import ( |
| 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. |
| 78 | const 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. |
| 83 | const 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). |
| 88 | const 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). |
| 92 | const 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. |
| 97 | const maxCodeAttempts = 8 |
| 98 | |
| 99 | // newRemoteToken mints the remote's capability: base64url, nothing to |
| 100 | // escape in a URL and nothing to trim. |
| 101 | func 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. |
| 114 | func 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. |
| 127 | func 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. |
| 135 | func 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. |
| 155 | func (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. |
| 161 | func 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. |
| 172 | func 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. |
| 191 | func (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). |
| 214 | func (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). |
| 221 | func (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. |
| 244 | func (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. |
| 284 | func (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. |
| 332 | func (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". |
| 381 | func 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). |
| 393 | type 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. |
| 402 | type 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. |
| 410 | type 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. |
| 463 | type 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. |
| 514 | type 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. |
| 542 | type 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. |
| 566 | func (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. |
| 589 | type 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 | |
| 614 | func (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. |
| 723 | func 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. |
| 741 | func (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. |
| 829 | func 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. |
| 844 | type 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. |
| 863 | func 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. |
| 898 | func 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. |
| 911 | func 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. |
| 935 | func (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. |
| 986 | func (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. |
| 1051 | func 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. |
| 1071 | const ( |
| 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. |
| 1087 | func (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. |
| 1102 | func (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(¤t, 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. |
| 1197 | var 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. |
| 1212 | func (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. |
| 1327 | func 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. |
| 1331 | var 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. |
| 1336 | func 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. |
| 1362 | func 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. |
| 1374 | func (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. |
| 1387 | func (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. |
| 1412 | func (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. |
| 1437 | func 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 | |