feat: presentation sessions, presenter view, phone remote, audience follow
Presenting is a server-side session (spec §3.4, ruling 5): one row says which slide is on the screen, and the projector, the presenter's second window, the phone in their hand and every follower read and write that one row. internal/slides/present is the in-process fan-out that tells them all at once; a subscriber that falls behind is dropped and closed rather than skipped, because a presentation event is whole state and a stale one would leave a projector wrong for the rest of a talk. Three capabilities, deliberately unequal: the session sqid views, the 32-byte remote token drives, and the six-character pairing code names a live session without being a permission on its own. The speaker notes follow the strongest of the three and never the sqid, which is the address that goes on a projector. Every surface works with scripts off — four command forms on the phone, ?slide=N paging, a meta refresh on the audience's follow view — and static/present.js posts exactly what those forms post, so there is one protocol rather than two.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01563XGCgxcSZToEXwuTBsKE
go.mod+1 −0go.sum+2 −0internal/slides/app.go+29 −1internal/slides/handlers.go+16 −0internal/slides/locales/en.toml+54 −0internal/slides/migrations/0004_create_presentations.sql+8 −0internal/slides/migrations/schema.sql+8 −0internal/slides/models.go+53 −0internal/slides/present/hub.go+129 −0internal/slides/present/hub_test.go+118 −0internal/slides/presentation.go+1334 −0internal/slides/render.go+21 −0internal/slides/share.go+18 −0internal/slides/static/present.js+117 −0internal/slides/templates/errors.html+8 −1internal/slides/templates/present.html+91 −0internal/slides/templates/present_controls.html+32 −0internal/slides/templates/present_notes.html+142 −0internal/slides/templates/remote.html+78 −0internal/slides/templates/remote_pair.html+26 −0internal/slides/templates/viewer.html+15 −8internal/slides/viewer.go+25 −2internal/slidestest/present_test.go+485 −0
Intent
<task-notification> <task-id>a0476d49265ceb3b4</task-id> <tool-use-id>toolu_01QC88JjQb2kc2piVBND1f64</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/2420c44b-a27a-4e0d-bbe7-8b3ca0464c21/tasks/a0476d49265ceb3b4.output</output-file> <status>completed</status> <summary>Agent "Implement Task 5: viewer, share links" finished</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>Status: complete. `make ci` green, tree clean, nothing pushed. Commit: `d749e0e` — feat: paged viewer, notes page, read-only share links Tests: 10 tests in `internal/slidestest/viewer_test.go` + `share_test.go` (the brief's six named ones plus present-surface, owner-buttons, owner-only-sharing and double-revoke), all nine RED before implementation, all green after; `decks_test.go`'s sample-deck section count was repointed at the filmstrip since the viewer is now paged. Concerns: - `?present=1` is deliberately minimal (slide + prev/next inside a `slides-present` wrapper, app shell still around it); the Present button on the signed-in viewer is a disabled span for task 6 to wire to `/d/{sqid}/present`. - One hand-written labelled control on the share page: `ui`'s `field-text` has no `readonly`, which the once-shown URL field needs; markup mirrors the partial attribute for attribute. - New copy did not go through the `copy-review` gate (headless run, no user); all strings are keys under `slides.viewer.*`, `slides.notes.*`, `slides.share.*`. - `layout.html` and `account.html` gained a nested `page-head` block so the viewer can add `slides.css` to `<head>` without dropping the account-menu rule. Report: `/home/paulca/amadan.net/oficina/slides/.claude/worktrees/slice-1/.superpowers/sdd/2026-09-07-slice-1-live/task-5-report.md`</result> <usage><subagent_tokens>225653</subagent_tokens><tool_uses>104</tool_uses><duration_ms>1111414</duration_ms></usage> </task-notification>
<task-notification> <task-id>ae3dc8a60650add93</task-id> <tool-use-id>toolu_01TnFoFDgEsiwa2KC6BEKgrW</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/2420c44b-a27a-4e0d-bbe7-8b3ca0464c21/tasks/ae3dc8a60650add93.output</output-file> <status>completed</status> <summary>Agent "Review Task 5 (spec + quality)" finished</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>I have thoroughly reviewed the diff against the brief, spec §5/§8/§4.1, and the rastrillo skill. Here is the review. ### Spec Compliance All named interfaces and global constraints are present and implemented correctly, with no gaps found: - `GET /s/{token}` (`internal/slides/app.go:63`) is mounted outside the `RequireSession` group, resolves by SHA-256 hash (`share.go:311-321`), gives an identical 404 for missing vs. revoked (`share.go:317-320`), stores only the hash (`ShareLink.TokenHash`, `models.go`), shows the URL once via `?new=<token>` (`share.go:230`), increments `Views` atomically (`share.go:344-349`), and hides notes/owner buttons/settings (`viewer.go:230-236, 253-256`). `?slide=N`, `?vp=phone`, `?present=1` all work (verified in `share_test.go`'s `TestShareLinkShownOnceAndOpensViewer`). - `GET /d/{sqid}?slide=N` is 1-based, defaults to 1, and 404s out of range including non-numeric input (`viewer.go:317-327`, `slideIndex`). It goes through the `visibleDecks` seam via `deckFor` (`decks.go:411-422`). Notes render in `<details>` for owner/member only. Prev/next are `rst-btn` links with `rel="prev"/"next"`, disabled ends as plain text (`templates/viewer.html`). Counter, filmstrip (heading-or-"Slide N" labelling), and Settings/Share owner-gating are all correct. - `GET /d/{sqid}/notes` lists every slide's label and notes (`viewer.go:389-406`). - One `viewer.html` template, no fork — `Mode` field (`owner`/`member`/`link`) drives the branching; two builds (`viewer`, `viewer_link`) differ only in shell (`render.go:57-70`). - CSRF (`csrf.Protect` mounted app-wide, `app.go:49`), owner-only share management (`ownedDeckFor`), every write recording an actor (`mintShareLink`, revoke logged with actor) — all present. - `rst-*`/`slides-*` attribute discipline, catalog keys, WCAG (24×24 filmstrip targets, visible counter/status text, non-colour-only state) are followed throughout the templates. - rastrillo SKILL.md §3-4: writes are scoped through `ownedDeckFor`/`deckFor`; `revokeShareLink` scopes the update itself (`WHERE id = ? AND deck_id = ? AND revoked_at IS NULL`); no request body is ever bound to a model — both POSTs take zero user-supplied fields. Two deviations, both self-disclosed and reasonable: - The hand-written readonly field on `share.html` — `ui`'s `field-text` genuinely has no `readonly` support; the markup mirrors the partial attribute-for-attribute. - The nested `page-head` block in `layout.html`/`account.html` — verified the Go template resolution actually works for both the `account.html` shell and the `anon.html` shell (layout's default `head` block falls through to a default empty `page-head`, which `viewer.html`'s own `page-head` definition overrides in both parses since it's parsed last in both file sets). Nothing found missing or misunderstood against the brief or spec. ### Strengths - Named risks in the review brief all check out: hash-based (not plaintext) token comparison via indexed `WHERE token_hash = ?`; `Views` incremented with an atomic `views + 1` SQL update, not a read-modify-write, so no lost updates; the token never appears in any `a.fail`/`a.logger` call; `sharedDeck` (`share.go:307-351`) never touches the session. - Edge cases are handled deliberately rather than accidentally: a zero-slide deck renders an empty state rather than 404 (both signed-in and over a link); a deleted deck's share links are deleted in the same transaction (`decks.go`'s `deleteDeck`), and `sharedDeck` still defends against a dangling `DeckID` regardless; revoking is idempotent via the `revoked_at IS NULL` guard. - Test quality is high: `TestShareLinkShownOnceAndOpensViewer`'s "5 views" assertion is exactly traceable against the five requests made (including a 404 `?slide=2` that still counts, matching the "counted on resolution" design choice) — I hand-verified the count rather than trusting the assertion. - `TestShareLinkStoresOnlyHash` checks the concatenation of every column, not just the expected one, which is a materially stronger test than checking `token_hash` alone. - `decks_test.go`'s pre-existing `TestSampleDeckSeededOnFirstSignin` was correctly adapted to the new paged viewer (filmstrip link count + counter) rather than left checking a structure that no longer exists. ### Issues #### Critical None. #### Important None. #### Minor - `share.go` (352 lines) and `viewer.go` (406 lines) are sizable single files covering multiple routes each. Not excessive relative to the codebase's existing precedent (`decks.go` is 947 lines), and each is well-sectioned with `--- GET ... ---` banner comments, so this is a note rather than a defect. - The hand-rolled readonly field in `share.html` is a real, if small, deviation from "never hand-roll a labelled control" (rastrillo SKILL.md §8). It's justified and paper-trailed (missing `readonly` support upstream), but is worth an actual upstream request being filed rather than just a code comment, per the implementer's own "Concerns for the next agent" #4. ### Assessment **Task quality:** Approved **Reasoning:** Every interface named in the brief and spec §5/§8/§4.1 is implemented and traced correctly through the code, including the four named-risk checks (hash-based token comparison, non-racing view increment, no token in logs, `sharedDeck` never reading the session) and the four requested edge cases (zero-slide deck, `slide=abc`, revoked link's `?present=1`, link to a deleted deck). The rastrillo scoping/mass-assignment rules (§3-4) are followed precisely — both POST handlers take zero body fields, and the revoke path scopes the write itself, not just a prior read. The two self-disclosed deviations (hand-written readonly field, nested `page-head` block) are narrow, justified, and I independently verified the `page-head` template resolution actually works for both shells rather than taking the report's word for it. Test quality is strong, with assertions that would actually catch regressions (exact view counts, whole-row plaintext checks) rather than loose smoke tests. Nothing rises above Minor.</result> <usage><subagent_tokens>115647</subagent_tokens><tool_uses>32</tool_uses><duration_ms>186154</duration_ms></usage> </task-notification>
Attributed to this commit: 20.7 g CO₂e (2393474 tokens). This is an attribution, not a measurement — usage counted from this commit's own marker forward to the next one on worktree-slice-1, never backward from it. A turn that produced more than one commit books all of its tokens to the last one, and a concurrent session on this branch can add to the same figure. Some of it was priced using the shared default rate, because no model-specific factor row exists yet for at least one model involved. The Carbon view has the repo total, which is never derived from this attribution.
diff --git a/go.mod b/go.mod| index c92f833..20f1a76 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -6,6 +6,7 @@ require ( |
| amadan.net/rastrillo/rastrillo v0.26.0 |
| github.com/chromedp/chromedp v0.14.2 |
| github.com/go-chi/chi/v5 v5.3.2 |
| + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e |
| github.com/tetratelabs/wazero v1.12.0 |
| gorm.io/gorm v1.31.2 |
| ) |
diff --git a/go.sum b/go.sum| index 87e1671..e07de66 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -46,6 +46,8 @@ github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhA |
| github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= |
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= |
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= |
| +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= |
| +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= |
| github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= |
| github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= |
| golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= |
diff --git a/internal/slides/app.go b/internal/slides/app.go| index e0d2037..a25efc6 100644 |
| --- a/internal/slides/app.go |
| +++ b/internal/slides/app.go |
| @@ -12,6 +12,7 @@ import ( |
| "amadan.net/rastrillo/rastrillo/migrate" |
| "amadan.net/oficina/slides/internal/slides/core" |
| + "amadan.net/oficina/slides/internal/slides/present" |
| ) |
| // New wires the whole app: schema, sign-in, router, static files. It |
| @@ -68,6 +69,21 @@ func New(d *db.DB, cfg Config) (*http.ServeMux, *app, error) { |
| // covers it. |
| r.Get("/s/{token}", a.sharedDeck) |
| + // Presenting (presentation.go, spec §3.4). Outside the group for |
| + // the share link's reason and then some: the projector has no |
| + // session, the phone in the presenter's hand has no session, and |
| + // the audience following along has no session. Each screen carries |
| + // its own capability — the session sqid to watch, the remote token |
| + // to drive — and answers 404 to anything else. |
| + r.Get("/p/{session}", a.presentSurface) |
| + r.Get("/p/{session}/notes", a.presenterNotes) |
| + r.Get("/p/{session}/stream", a.presentStream) |
| + r.Get("/p/{session}/remote", a.remote) |
| + r.Post("/p/{session}/command", a.presentCommand) |
| + // The phone's way in: a scanned QR carries the code and the key, |
| + // a typed code gets a form asking for the key. |
| + r.Get("/r/{code}", a.remotePair) |
| + |
| // Everything else needs a real, signed-in person (SKILL.md §5). A |
| // signed-out GET/HEAD redirects to /signin; anything else 403s. |
| r.Group(func(r chi.Router) { |
| @@ -83,6 +99,10 @@ func New(d *db.DB, cfg Config) (*http.ServeMux, *app, error) { |
| // Every slide's notes on one page — the crib sheet somebody |
| // presents from with scripts off (spec §8). |
| r.Get("/d/{sqid}/notes", a.deckNotes) |
| + // Presenting starts here and nowhere else: anybody who can |
| + // open the deck can read it to a room, so this is deckFor's |
| + // audience rather than the owner's alone. |
| + r.Post("/d/{sqid}/present", a.startPresenting) |
| // Sharing is the owner's, all three of these (share.go says |
| // why a team member who can read the deck still cannot mint a |
| // link to it). |
| @@ -162,5 +182,13 @@ func newApp(d *db.DB, cfg Config) (*app, *rauth.Auth, error) { |
| return nil, nil, err |
| } |
| - return &app{db: d, logger: logger, cfg: cfg, allowed: list, core: rt}, authPlugin, nil |
| + return &app{ |
| + db: d, |
| + logger: logger, |
| + cfg: cfg, |
| + allowed: list, |
| + core: rt, |
| + auth: authPlugin, |
| + hub: present.New(), |
| + }, authPlugin, nil |
| } |
diff --git a/internal/slides/handlers.go b/internal/slides/handlers.go| index ee9ca5d..fb34a49 100644 |
| --- a/internal/slides/handlers.go |
| +++ b/internal/slides/handlers.go |
| @@ -4,9 +4,11 @@ import ( |
| "context" |
| "log/slog" |
| + rauth "amadan.net/rastrillo/rastrillo/auth" |
| "amadan.net/rastrillo/rastrillo/db" |
| "amadan.net/oficina/slides/internal/slides/core" |
| + "amadan.net/oficina/slides/internal/slides/present" |
| ) |
| // app holds what every handler needs: the database, the logger, the |
| @@ -28,6 +30,20 @@ type app struct { |
| // not, so there is exactly one of these per process and a Handle |
| // (one module instance) per deck opened. |
| core *core.Runtime |
| + // auth is the sign-in plugin, held for one thing the router |
| + // cannot do: resolving a session cookie on a screen mounted |
| + // OUTSIDE the signed-in group. RequireSession is what puts a |
| + // session on the request context, so past it sessions.Current |
| + // answers nothing — and the presenting screens (presentation.go) |
| + // are all capability-addressed, reachable with no session at all, |
| + // while still needing to know whether the person reading is the |
| + // presenter. rauth's SessionFrom is that direct lookup. |
| + auth *rauth.Auth |
| + // hub is the in-process fan-out behind every live presentation: |
| + // one command, every watching page told (internal/slides/present). |
| + // One per app, because a subscriber list nobody else can reach is |
| + // not a fan-out. |
| + hub *present.Hub |
| // onUpdate is told, after the transaction commits, that a deck |
| // moved to a new seq. Nil until task 6 mounts the hub that |
| // broadcasts to open browsers; every caller is nil-safe. |
diff --git a/internal/slides/locales/en.toml b/internal/slides/locales/en.toml| index 67d03b3..de08886 100644 |
| --- a/internal/slides/locales/en.toml |
| +++ b/internal/slides/locales/en.toml |
| @@ -175,3 +175,57 @@ slides.share.row.created = "Created {when}" |
| slides.share.row.views.none = "Never opened" |
| slides.share.row.views.one = "1 view" |
| slides.share.row.views = "{n} views" |
| + |
| +# slides.present.* — presenting (spec §3.4): the fullscreen surface, |
| +# the presenter view, and the commands all three driving surfaces send. |
| +# |
| +# The blackout has two keys rather than one label that changes colour: |
| +# what the button does next is the thing a person needs to know, and a |
| +# room that is dark is not a state to signal with a tint. |
| +slides.present.progress = "{n} / {total}" |
| +slides.present.progress.label = "Where you are in the deck" |
| +slides.present.next = "Next slide" |
| +slides.present.prev = "Previous slide" |
| +slides.present.next.label = "Coming next" |
| +slides.present.current = "On screen now" |
| +slides.present.blackout = "Black the screen" |
| +slides.present.unblackout = "Show the screen" |
| +slides.present.blacked = "The screen is blacked out." |
| +slides.present.end = "End the presentation" |
| +slides.present.notes.title = "Presenter view" |
| +slides.present.stage = "Full screen" |
| +slides.present.clock = "Time now" |
| +slides.present.started = "Started {when}" |
| +slides.present.elapsed = "Running {n} min" |
| + |
| +# A session that is over — ended, or past its twelve hours. A 410, and |
| +# worded as one: the talk finished, which is a different thing from an |
| +# address that was never real. |
| +slides.present.over.title = "That presentation is over" |
| +slides.present.over.body = "The talk it was showing has ended. Ask whoever presented it for the deck, or open it again from your own list." |
| + |
| +# Pairing a phone. Shown once, at the moment of pairing, because only |
| +# a fingerprint of the key is stored — the share link's bargain, and |
| +# the reason the body says what pressing Present again does. |
| +slides.present.pair.title = "Use your phone as the remote" |
| +slides.present.pair.body = "Scan this, or open the address and type the code and key. It is shown once: press Present again to pair a different phone, which retires this one." |
| +slides.present.pair.code = "Pairing code" |
| +slides.present.pair.url = "Remote address" |
| +slides.present.pair.qr = "QR code for the remote, pairing code {code}" |
| + |
| +# slides.remote.* — the phone, and the page that pairs it. |
| +slides.remote.title = "Remote" |
| +slides.remote.pair.title = "Pair this phone" |
| +slides.remote.pair.body = "Type the code from the presenter's screen and the key beside it. Scanning the QR fills both in." |
| +slides.remote.pair.code = "Pairing code" |
| +slides.remote.pair.code.hint = "Six characters, from the presenter's screen." |
| +slides.remote.pair.token = "Remote key" |
| +slides.remote.pair.token.hint = "The long string beside the code. The QR carries it for you." |
| +slides.remote.pair.submit = "Open the remote" |
| +slides.remote.pair.error = "That code and key do not go together. Check both, or scan the QR." |
| + |
| +# slides.follow.* — the audience, watching a live talk through a share |
| +# link. Following is read-only: it moves with the presenter and never |
| +# moves them. |
| +slides.follow.offer = "Follow the presenter" |
| +slides.follow.browse = "Browse on your own" |
diff --git a/internal/slides/migrations/0004_create_presentations.sql b/internal/slides/migrations/0004_create_presentations.sql| new file mode 100644 |
| index 0000000..2dc8dc3 |
| --- /dev/null |
| +++ b/internal/slides/migrations/0004_create_presentations.sql |
| @@ -0,0 +1,8 @@ |
| +CREATE TABLE `presentations` (`id` integer PRIMARY KEY AUTOINCREMENT,`sqid` text,`deck_id` integer,`actor` text,`slide` integer,`blackout` numeric,`code` text,`remote_token_hash` text,`started_at` datetime,`ended_at` datetime); |
| + |
| +CREATE INDEX `idx_presentations_code` ON `presentations`(`code`); |
| + |
| +CREATE INDEX `idx_presentations_deck_id` ON `presentations`(`deck_id`); |
| + |
| +CREATE UNIQUE INDEX `idx_presentations_sqid` ON `presentations`(`sqid`); |
| + |
diff --git a/internal/slides/migrations/schema.sql b/internal/slides/migrations/schema.sql| index 2db2a17..336f15d 100644 |
| --- a/internal/slides/migrations/schema.sql |
| +++ b/internal/slides/migrations/schema.sql |
| @@ -7,6 +7,8 @@ CREATE TABLE `deck_updates` (`id` integer PRIMARY KEY AUTOINCREMENT,`deck_id` in |
| CREATE TABLE `decks` (`id` integer PRIMARY KEY AUTOINCREMENT,`sqid` text,`title` text,`owner_id` integer,`rung` text,`theme` text,`seq` integer,`created_at` datetime,`updated_at` datetime); |
| +CREATE TABLE `presentations` (`id` integer PRIMARY KEY AUTOINCREMENT,`sqid` text,`deck_id` integer,`actor` text,`slide` integer,`blackout` numeric,`code` text,`remote_token_hash` text,`started_at` datetime,`ended_at` datetime); |
| + |
| CREATE TABLE `share_links` (`id` integer PRIMARY KEY AUTOINCREMENT,`deck_id` integer,`token_hash` text,`actor` text,`views` integer,`created_at` datetime,`revoked_at` datetime); |
| CREATE TABLE `users` (`id` integer PRIMARY KEY AUTOINCREMENT,`email` text,`name` text,`colour` text,`created_at` datetime); |
| @@ -21,6 +23,12 @@ CREATE INDEX `idx_decks_owner_id` ON `decks`(`owner_id`); |
| CREATE UNIQUE INDEX `idx_decks_sqid` ON `decks`(`sqid`); |
| +CREATE INDEX `idx_presentations_code` ON `presentations`(`code`); |
| + |
| +CREATE INDEX `idx_presentations_deck_id` ON `presentations`(`deck_id`); |
| + |
| +CREATE UNIQUE INDEX `idx_presentations_sqid` ON `presentations`(`sqid`); |
| + |
| CREATE INDEX `idx_share_links_deck_id` ON `share_links`(`deck_id`); |
| CREATE UNIQUE INDEX `idx_share_links_token_hash` ON `share_links`(`token_hash`); |
diff --git a/internal/slides/models.go b/internal/slides/models.go| index 8e3bb11..8d90b11 100644 |
| --- a/internal/slides/models.go |
| +++ b/internal/slides/models.go |
| @@ -13,6 +13,7 @@ var Models = []any{ |
| &DeckUpdate{}, |
| &DeckGrant{}, |
| &ShareLink{}, |
| + &Presentation{}, |
| } |
| // User is a person who has signed in here — spec §6.1's |
| @@ -162,3 +163,55 @@ type ShareLink struct { |
| CreatedAt time.Time |
| RevokedAt *time.Time |
| } |
| + |
| +// Presentation is one live talk: which deck, who is presenting, which |
| +// slide is on the screen, and the two capabilities that reach it — |
| +// spec §3.4's session row, and ruling 5's "presenting is a server-side |
| +// presentation session". |
| +// |
| +// It is the one record the presenter view, the phone remote and every |
| +// following audience member all read and write, which is what makes |
| +// them agree: there is no second place where "the deck is on slide |
| +// four" is written down, so there is nothing for them to disagree |
| +// about. |
| +// |
| +// Sqid is the session's public name and a VIEWING capability: anybody |
| +// who has it may watch the fullscreen surface, exactly as a share link |
| +// works. It is not a driving capability — that is RemoteTokenHash. |
| +// |
| +// Code is the six characters a person reads aloud or types into a |
| +// phone, from an alphabet with no O/0 and no I/1 for the same reason |
| +// the deck sqid's has none. It is unique among LIVE sessions only, and |
| +// enforced in Go rather than by an index, because a code that could |
| +// never be reused would make a long-lived instance run out of the |
| +// short, sayable ones. It is not a capability on its own: pairing |
| +// needs the code AND the token (presentation.go's remotePair). |
| +// |
| +// RemoteTokenHash is the SHA-256 hex of the remote's capability, the |
| +// share link's construction and the share link's reason (share.go): |
| +// only the hash is stored, so the URL can be shown exactly once, and |
| +// resolving one is a constant-time compare rather than a lookup. |
| +// |
| +// Slide is a 0-based index into the deck's slides. Every URL and every |
| +// stream event carries the 1-based number a person sees instead |
| +// (presentation.go's eventFor) — the two are one conversion in one |
| +// place, and this column is the index because that is what indexes |
| +// into the document. |
| +// |
| +// EndedAt rather than a delete, and StartedAt read as a 12-hour |
| +// deadline: a session is over when it is ended or when it is old, and |
| +// both answers are 410 rather than 404, because a talk that finished |
| +// is a different thing from a link that was never real. |
| +type Presentation struct { |
| + ID int64 |
| + Sqid string `gorm:"uniqueIndex"` |
| + DeckID int64 `gorm:"index"` |
| + // Actor is who is presenting, in DeckUpdate.Actor's form. |
| + Actor string |
| + Slide int |
| + Blackout bool |
| + Code string `gorm:"index"` |
| + RemoteTokenHash string |
| + StartedAt time.Time |
| + EndedAt *time.Time |
| +} |
diff --git a/internal/slides/present/hub.go b/internal/slides/present/hub.go| new file mode 100644 |
| index 0000000..6b02635 |
| --- /dev/null |
| +++ b/internal/slides/present/hub.go |
| @@ -0,0 +1,129 @@ |
| +// Package present is the fan-out behind a live presentation: one |
| +// session moved, told to every page watching it. |
| +// |
| +// It is Docs' hub (its internal/docs/hub.go) with one difference worth |
| +// stating, because it is the difference that decides everything else |
| +// here: Docs' notification is ADVISORY — a seq that means "there is |
| +// something at least this new", so a subscriber that misses ten is |
| +// still correct after fetching once. A presentation event is not |
| +// advisory in the same way: `{slide, blackout, ended}` is the whole |
| +// state, so what a subscriber must never do is apply a STALE one. |
| +// |
| +// Hence the two decisions below. The channel is buffered eight rather |
| +// than one, because a burst of arrow-key presses is a real thing and |
| +// eight covers it without the sender ever waiting. And a subscriber |
| +// that falls behind that is DROPPED — unsubscribed, its channel closed |
| +// — rather than having events silently skipped: the handler holding it |
| +// sees the close, ends the response, and the browser reconnects, which |
| +// re-reads the session's real state from the row on connect (the |
| +// stream handler's one event on connect). A skipped event, by |
| +// contrast, would leave a projector showing slide 4 for the rest of a |
| +// talk with nothing to notice it. |
| +// |
| +// Scope, like Docs': one process. Slides is one instance per team, so |
| +// there is no second process to reach; this type is the seam that |
| +// changes on the day there is. |
| +package present |
| + |
| +import "sync" |
| + |
| +// Event is a presentation's whole visible state at one moment — never |
| +// a delta, so a subscriber that applies one is exactly right rather |
| +// than approximately. |
| +// |
| +// The JSON tags are the wire format of the stream's `event: slide` |
| +// data (presentation.go), and static/present.js reads these three |
| +// names. Changing one is changing the protocol. |
| +type Event struct { |
| + // Slide is the 0-based index of the slide being shown. |
| + Slide int `json:"slide"` |
| + // Blackout is whether the screen is blacked out. Carried |
| + // separately from the slide because coming back from a blackout |
| + // must not move the deck. |
| + Blackout bool `json:"blackout"` |
| + // Ended says the session is over. A page that hears it stops |
| + // following rather than sitting on a slide that will never move |
| + // again. |
| + Ended bool `json:"ended"` |
| +} |
| + |
| +// subscriberBuffer is how many events a subscriber may fall behind by |
| +// before it is dropped. Eight is a burst of arrow presses; a page that |
| +// has not read eight is not reading at all. |
| +const subscriberBuffer = 8 |
| + |
| +// Hub is the per-process fan-out, keyed by presentation sqid. |
| +type Hub struct { |
| + mu sync.Mutex |
| + subs map[string]map[chan Event]struct{} |
| +} |
| + |
| +// New builds an empty hub. One per app (handlers.go). |
| +func New() *Hub { |
| + return &Hub{subs: make(map[string]map[chan Event]struct{})} |
| +} |
| + |
| +// Subscribe registers interest in one session and returns the channel |
| +// to wait on plus the function that unregisters it. |
| +// |
| +// The returned cancel is idempotent — the stream handler defers it and |
| +// may also have reason to stop early — and it never closes the |
| +// channel. Only Publish closes one, and only when it is dropping the |
| +// subscriber, so "the channel is closed" carries exactly one meaning |
| +// for a reader: you fell behind, reconnect. A cancel that closed too |
| +// would make that signal ambiguous at the one moment it matters. |
| +func (h *Hub) Subscribe(key string) (<-chan Event, func()) { |
| + ch := make(chan Event, subscriberBuffer) |
| + |
| + h.mu.Lock() |
| + if h.subs[key] == nil { |
| + h.subs[key] = make(map[chan Event]struct{}) |
| + } |
| + h.subs[key][ch] = struct{}{} |
| + h.mu.Unlock() |
| + |
| + var once sync.Once |
| + return ch, func() { |
| + once.Do(func() { |
| + h.mu.Lock() |
| + defer h.mu.Unlock() |
| + h.remove(key, ch) |
| + }) |
| + } |
| +} |
| + |
| +// Publish sends e to everyone watching key. |
| +// |
| +// It runs on whichever request goroutine just moved the session (a |
| +// phone's POST, the presenter's keyboard), so it must never block: a |
| +// browser on a wedged connection would otherwise hold a stranger's |
| +// command open. A subscriber whose buffer is full is therefore removed |
| +// and its channel closed, inside the same lock that found it, so no |
| +// send can race the close. |
| +func (h *Hub) Publish(key string, e Event) { |
| + h.mu.Lock() |
| + defer h.mu.Unlock() |
| + |
| + for ch := range h.subs[key] { |
| + select { |
| + case ch <- e: |
| + default: |
| + h.remove(key, ch) |
| + close(ch) |
| + } |
| + } |
| +} |
| + |
| +// remove takes ch out of key's set and drops the set with its last |
| +// member — otherwise this map keeps one empty entry per session ever |
| +// presented for the life of the process. Callers hold h.mu. |
| +func (h *Hub) remove(key string, ch chan Event) { |
| + subs, ok := h.subs[key] |
| + if !ok { |
| + return |
| + } |
| + delete(subs, ch) |
| + if len(subs) == 0 { |
| + delete(h.subs, key) |
| + } |
| +} |
diff --git a/internal/slides/present/hub_test.go b/internal/slides/present/hub_test.go| new file mode 100644 |
| index 0000000..4234447 |
| --- /dev/null |
| +++ b/internal/slides/present/hub_test.go |
| @@ -0,0 +1,118 @@ |
| +package present_test |
| + |
| +import ( |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/oficina/slides/internal/slides/present" |
| +) |
| + |
| +// recv reads one event, or fails: every assertion here is about |
| +// something arriving, and a test that hangs waiting for it tells the |
| +// person running it nothing. |
| +func recv(t *testing.T, ch <-chan present.Event, what string) present.Event { |
| + t.Helper() |
| + select { |
| + case e, ok := <-ch: |
| + if !ok { |
| + t.Fatalf("%s: the channel was closed, want an event", what) |
| + } |
| + return e |
| + case <-time.After(2 * time.Second): |
| + t.Fatalf("%s: no event within two seconds", what) |
| + } |
| + return present.Event{} |
| +} |
| + |
| +// TestPublishReachesEverySubscriber: a session's event reaches every |
| +// page watching that session — the presenter's laptop, the phone in |
| +// their hand, and every follower — and nobody watching another one. |
| +func TestPublishReachesEverySubscriber(t *testing.T) { |
| + h := present.New() |
| + |
| + laptop, stopLaptop := h.Subscribe("talk") |
| + defer stopLaptop() |
| + phone, stopPhone := h.Subscribe("talk") |
| + defer stopPhone() |
| + elsewhere, stopElsewhere := h.Subscribe("other-talk") |
| + defer stopElsewhere() |
| + |
| + h.Publish("talk", present.Event{Slide: 3, Blackout: true}) |
| + |
| + if e := recv(t, laptop, "the laptop"); e.Slide != 3 || !e.Blackout { |
| + t.Errorf("the laptop got %+v, want slide 3 blacked out", e) |
| + } |
| + if e := recv(t, phone, "the phone"); e.Slide != 3 || !e.Blackout { |
| + t.Errorf("the phone got %+v, want slide 3 blacked out", e) |
| + } |
| + select { |
| + case e := <-elsewhere: |
| + t.Errorf("a subscriber to another session got %+v", e) |
| + default: |
| + } |
| + |
| + // Cancelling really unsubscribes: the next event reaches the phone |
| + // and not the laptop. |
| + stopLaptop() |
| + h.Publish("talk", present.Event{Slide: 4}) |
| + if e := recv(t, phone, "the phone after the laptop left"); e.Slide != 4 { |
| + t.Errorf("the phone got %+v, want slide 4", e) |
| + } |
| + select { |
| + case e, ok := <-laptop: |
| + if ok { |
| + t.Errorf("a cancelled subscriber got %+v", e) |
| + } |
| + default: |
| + } |
| + |
| + // Cancelling twice is a no-op, not a panic: the stream handler |
| + // defers its stop and may also have reason to stop early. |
| + stopLaptop() |
| +} |
| + |
| +// TestSlowSubscriberIsDropped: a page that has stopped reading — a |
| +// laptop asleep with the tab open — must never hold up the phone in |
| +// somebody's hand. Past the buffer it is dropped, and its channel is |
| +// closed so the handler holding it returns instead of blocking on a |
| +// stream that will never move again. |
| +func TestSlowSubscriberIsDropped(t *testing.T) { |
| + h := present.New() |
| + |
| + slow, stopSlow := h.Subscribe("talk") |
| + defer stopSlow() |
| + quick, stopQuick := h.Subscribe("talk") |
| + defer stopQuick() |
| + |
| + for i := 0; i < 12; i++ { |
| + h.Publish("talk", present.Event{Slide: i}) |
| + // The quick subscriber keeps up, so only the slow one is ever |
| + // behind: this test is about one subscriber's back pressure, |
| + // not about both. |
| + if e := recv(t, quick, "the quick subscriber"); e.Slide != i { |
| + t.Fatalf("the quick subscriber got %+v, want slide %d", e, i) |
| + } |
| + } |
| + |
| + // The buffer it did get is intact — dropping is not a rewrite of |
| + // what was already delivered. |
| + for i := 0; i < 8; i++ { |
| + if e := recv(t, slow, "the slow subscriber's buffer"); e.Slide != i { |
| + t.Fatalf("the slow subscriber's buffered event %d is %+v", i, e) |
| + } |
| + } |
| + select { |
| + case _, ok := <-slow: |
| + if ok { |
| + t.Fatal("the slow subscriber is still delivered to past its buffer") |
| + } |
| + case <-time.After(2 * time.Second): |
| + t.Fatal("the slow subscriber was neither delivered to nor closed") |
| + } |
| + |
| + // And the session is still live for everyone who kept up. |
| + h.Publish("talk", present.Event{Slide: 99, Ended: true}) |
| + if e := recv(t, quick, "the quick subscriber at the end"); e.Slide != 99 || !e.Ended { |
| + t.Errorf("the quick subscriber got %+v, want the ending event", e) |
| + } |
| +} |
diff --git a/internal/slides/presentation.go b/internal/slides/presentation.go| new file mode 100644 |
| index 0000000..645eccd |
| --- /dev/null |
| +++ b/internal/slides/presentation.go |
| @@ -0,0 +1,1334 @@ |
| +// presentation.go is presenting: the session row, the fullscreen |
| +// surface, the presenter view, the phone remote and the audience's |
| +// follow. |
| +// |
| +// The one idea, spec §3.4 and ruling 5: presenting is a SERVER-SIDE |
| +// SESSION. There is one row that says which slide is on the screen, |
| +// and the projector, the presenter's second window, the phone in their |
| +// hand and every follower all read and write that same row. Nothing |
| +// here is a peer-to-peer arrangement between browsers, so there is no |
| +// state that can disagree with itself; the SSE stream |
| +// (internal/slides/present) is a notification that the row moved, not |
| +// the place the row lives. |
| +// |
| +// Three capabilities, deliberately different in strength: |
| +// |
| +// - The session SQID is a VIEWING capability, exactly like a share |
| +// link: anybody who has the address may watch the fullscreen |
| +// surface. It is what makes "put this on the projector" a URL. |
| +// - The REMOTE TOKEN is the driving capability, and the only thing |
| +// that may move the deck. It is 32 bytes, stored as a hash, and |
| +// shown once — the share link's construction and the share link's |
| +// reason (share.go). |
| +// - The PAIRING CODE is neither on its own. It is six sayable |
| +// characters that name a live session so a phone can find it; the |
| +// token still has to arrive with it. A code alone being enough |
| +// would make every talk in the building drivable by whoever |
| +// guessed six characters. |
| +// |
| +// The speaker notes follow the strongest of the three: the presenter's |
| +// own session, or the remote token. Never the sqid, because the sqid |
| +// is what goes on a projector. |
| +package slides |
| + |
| +import ( |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "crypto/subtle" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "html/template" |
| + "io" |
| + "net/http" |
| + "net/url" |
| + "strconv" |
| + "strings" |
| + "time" |
| + |
| + "github.com/go-chi/chi/v5" |
| + "github.com/skip2/go-qrcode" |
| + "gorm.io/gorm" |
| + |
| + "amadan.net/rastrillo/rastrillo" |
| + |
| + "amadan.net/oficina/slides/internal/slides/deck" |
| + "amadan.net/oficina/slides/internal/slides/present" |
| + slidehtml "amadan.net/oficina/slides/internal/slides/render" |
| +) |
| + |
| +// --- what a session is, and how long it lasts --- |
| + |
| +// presentTTL is how long a session stays live without being ended. |
| +// Spec §3.4: "ended explicitly or after 12 hours". A talk nobody |
| +// closed must not still be driveable tomorrow morning by whoever still |
| +// has the phone URL in a browser tab. |
| +const presentTTL = 12 * time.Hour |
| + |
| +// codeAlphabet is the pairing code's: no O or 0, no I or 1, because |
| +// this is the one string in the app a person reads off a projector and |
| +// types on a phone. Upper case for the same reason — it is read aloud. |
| +const codeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" |
| + |
| +// codeLength is six characters: 32^6 is a billion, which is not a |
| +// keyspace anybody brute-forces through a web form, and the code is |
| +// not a capability on its own anyway (this file's own comment). |
| +const codeLength = 6 |
| + |
| +// remoteTokenBytes is the remote capability's randomness — the share |
| +// link's 32 bytes, for the share link's reason (share.go). |
| +const remoteTokenBytes = 32 |
| + |
| +// maxCodeAttempts bounds the retry loop when a freshly minted code is |
| +// already in use by another live session. Collisions are rare and real: |
| +// unique among live sessions is a small set, but it is not empty. |
| +const maxCodeAttempts = 8 |
| + |
| +// newRemoteToken mints the remote's capability: base64url, nothing to |
| +// escape in a URL and nothing to trim. |
| +func newRemoteToken() (string, error) { |
| + raw := make([]byte, remoteTokenBytes) |
| + if _, err := rand.Read(raw); err != nil { |
| + return "", fmt.Errorf("slides: generate remote token: %w", err) |
| + } |
| + return base64.RawURLEncoding.EncodeToString(raw), nil |
| +} |
| + |
| +// hashRemoteToken is the stored form. The same construction as |
| +// hashShareToken and written out again rather than shared, because the |
| +// two are separate protocol decisions that happen to agree today: a |
| +// change to how share links are stored must not silently change what |
| +// every live remote's stored hash means. |
| +func hashRemoteToken(token string) string { |
| + sum := sha256.Sum256([]byte(token)) |
| + return hex.EncodeToString(sum[:]) |
| +} |
| + |
| +// remoteTokenOK compares the presented token with the stored hash in |
| +// constant time. Hashes, not tokens: hashing first means the compare |
| +// is over two fixed-length strings, so not even the length of the |
| +// stored secret leaks through timing. |
| +// |
| +// An empty token is refused before anything else. Without this, a |
| +// session whose hash column was somehow empty would be driveable by |
| +// anyone posting no token at all. |
| +func remoteTokenOK(p Presentation, token string) bool { |
| + if token == "" || p.RemoteTokenHash == "" { |
| + return false |
| + } |
| + return subtle.ConstantTimeCompare([]byte(hashRemoteToken(token)), []byte(p.RemoteTokenHash)) == 1 |
| +} |
| + |
| +// newCode mints a pairing code. |
| +func newCode() (string, error) { |
| + raw := make([]byte, codeLength) |
| + if _, err := rand.Read(raw); err != nil { |
| + return "", fmt.Errorf("slides: generate pairing code: %w", err) |
| + } |
| + out := make([]byte, codeLength) |
| + for i, b := range raw { |
| + // The modulo bias decks.go's newSqid documents applies here |
| + // too, and matters even less: the code is not a secret, it is |
| + // a name a person types. |
| + out[i] = codeAlphabet[int(b)%len(codeAlphabet)] |
| + } |
| + return string(out), nil |
| +} |
| + |
| +// livePresentations scopes a query to the sessions that are still |
| +// running: not ended, and not older than the TTL. Every read that |
| +// looks for a session to USE goes through it; presentationFor is the |
| +// one place that reads outside it, because telling somebody their talk |
| +// is over needs the row that says so. |
| +func (a *app) livePresentations(r *http.Request) *gorm.DB { |
| + return a.db.G.WithContext(r.Context()).Model(&Presentation{}). |
| + Where("ended_at IS NULL AND started_at > ?", time.Now().UTC().Add(-presentTTL)) |
| +} |
| + |
| +// presentationOver reports whether p has finished, either way it can. |
| +func presentationOver(p Presentation) bool { |
| + return p.EndedAt != nil || time.Since(p.StartedAt) > presentTTL |
| +} |
| + |
| +// eventFor is the one conversion between the column and the wire. |
| +// |
| +// Presentation.Slide is a 0-based index into the document; every URL |
| +// in this app and every stream event carries the 1-based number a |
| +// person sees (?slide=3 is the third slide). Both conventions are |
| +// right in their own place and the seam between them is here, so no |
| +// other function in the app has to hold both in its head. |
| +func eventFor(p Presentation) present.Event { |
| + return present.Event{ |
| + Slide: p.Slide + 1, |
| + Blackout: p.Blackout, |
| + Ended: p.EndedAt != nil, |
| + } |
| +} |
| + |
| +// --- resolving a session from a request --- |
| + |
| +// presentationFor resolves the {session} sqid, answering for itself: |
| +// 404 for a session that does not exist, 410 for one that is over. |
| +// |
| +// The two answers say different things on purpose. A talk that |
| +// finished is GONE — somebody following a link to yesterday's session |
| +// should be told it ended rather than left wondering whether they |
| +// typed the address wrong. A sqid that never existed is a 404, and a |
| +// dead pairing code is a 404 too (remotePair), because neither should |
| +// confirm anything to a stranger who is guessing. |
| +func (a *app) presentationFor(w http.ResponseWriter, r *http.Request) (Presentation, bool) { |
| + var p Presentation |
| + err := a.db.G.WithContext(r.Context()). |
| + Where("sqid = ?", chi.URLParam(r, "session")).Take(&p).Error |
| + switch { |
| + case err == nil: |
| + case errors.Is(err, gorm.ErrRecordNotFound): |
| + a.notFound(w, r) |
| + return Presentation{}, false |
| + default: |
| + a.fail(w, r, "load presentation", err) |
| + return Presentation{}, false |
| + } |
| + if presentationOver(p) { |
| + a.gone(w, r) |
| + return Presentation{}, false |
| + } |
| + return p, true |
| +} |
| + |
| +// gone renders the 410: the app's own error page, with words of its |
| +// own, because the framework catalog words five statuses and this is |
| +// not one of them (ui/partials/error-page.html). |
| +func (a *app) gone(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusGone) |
| + render(w, "errors", map[string]any{ |
| + "Status": http.StatusGone, |
| + "Title": rastrillo.T(r, "slides.present.over.title"), |
| + "Body": rastrillo.T(r, "slides.present.over.body"), |
| + }) |
| +} |
| + |
| +// deckOf loads the deck a session is presenting. No visibility check: |
| +// the session sqid IS the capability that reached this handler, the |
| +// same way a share link is (share.go's sharedDeck). |
| +func (a *app) deckOf(w http.ResponseWriter, r *http.Request, p Presentation) (Deck, bool) { |
| + var d Deck |
| + err := a.db.G.WithContext(r.Context()).Take(&d, p.DeckID).Error |
| + switch { |
| + case err == nil: |
| + return d, true |
| + case errors.Is(err, gorm.ErrRecordNotFound): |
| + a.notFound(w, r) |
| + default: |
| + a.fail(w, r, "load a presented deck", err) |
| + } |
| + return Deck{}, false |
| +} |
| + |
| +// presenterActor is who is signed in on this request, as an actor |
| +// string, or "" for nobody. |
| +// |
| +// It reads the session cookie DIRECTLY (rauth's SessionFrom) rather |
| +// than through sessions.Current, because every screen in this file is |
| +// mounted outside the signed-in group — RequireSession is what stashes |
| +// a session on the request context, so past it sessions.Current |
| +// answers nothing at all. Nobody being signed in is the ordinary case |
| +// here (a phone, a projector), not a failure. |
| +func (a *app) presenterActor(r *http.Request) string { |
| + if a.auth == nil { |
| + return "" |
| + } |
| + id, ok := a.auth.SessionFrom(r) |
| + if !ok || id.Address == "" { |
| + return "" |
| + } |
| + var u User |
| + if err := a.db.G.WithContext(r.Context()). |
| + Where("email = ?", normalizeAddress(id.Address)).Take(&u).Error; err != nil { |
| + return "" |
| + } |
| + return actorFor(u) |
| +} |
| + |
| +// --- POST /d/{sqid}/present --- |
| + |
| +// startPresenting opens a session for a deck, or picks up the one this |
| +// person already has open on it, and lands them on the presenter view |
| +// with the remote's capability in the URL — once. |
| +// |
| +// Reachable by anybody who can open the deck (visibleDecks), not the |
| +// owner alone: presenting is reading out loud, and a team member who |
| +// can read a deck can read it to a room. |
| +// |
| +// One live session per deck per presenter (spec §3.4). Pressing |
| +// Present again reuses the row and mints a FRESH remote token, which |
| +// is the one surprising thing in this handler and is deliberate: only |
| +// the hash is stored, so the old token cannot be shown again, and a |
| +// presenter view with no working buttons would be worse than a phone |
| +// that has to be re-paired. The pairing block says so. |
| +func (a *app) startPresenting(w http.ResponseWriter, r *http.Request) { |
| + u, ok := a.viewer(w, r) |
| + if !ok { |
| + return |
| + } |
| + d, ok := a.deckFor(w, r, u) |
| + if !ok { |
| + return |
| + } |
| + |
| + token, err := newRemoteToken() |
| + if err != nil { |
| + a.fail(w, r, "mint a remote token", err) |
| + return |
| + } |
| + actor := actorFor(u) |
| + |
| + var p Presentation |
| + err = a.livePresentations(r).Where("deck_id = ? AND actor = ?", d.ID, actor). |
| + Order("started_at DESC").Take(&p).Error |
| + switch { |
| + case err == nil: |
| + // Re-pairing an open session: the row keeps its sqid, its |
| + // slide and its code, so every page already watching it stays |
| + // watching the same talk. |
| + if err := a.db.G.WithContext(r.Context()).Model(&Presentation{}). |
| + Where("id = ? AND ended_at IS NULL", p.ID). |
| + Update("remote_token_hash", hashRemoteToken(token)).Error; err != nil { |
| + a.fail(w, r, "re-pair a presentation", err) |
| + return |
| + } |
| + case errors.Is(err, gorm.ErrRecordNotFound): |
| + p, err = a.createPresentation(r, d, actor, token) |
| + if err != nil { |
| + a.fail(w, r, "start a presentation", err) |
| + return |
| + } |
| + default: |
| + a.fail(w, r, "look for a live presentation", err) |
| + return |
| + } |
| + |
| + http.Redirect(w, r, "/p/"+p.Sqid+"/notes?t="+token, http.StatusSeeOther) |
| +} |
| + |
| +// createPresentation writes the row, retrying on a code already in use |
| +// by another live session and on the vanishingly unlikely sqid |
| +// collision the unique index would refuse. |
| +func (a *app) createPresentation(r *http.Request, d Deck, actor, token string) (Presentation, error) { |
| + for attempt := 0; attempt < maxCodeAttempts; attempt++ { |
| + sqid, err := newSqid() |
| + if err != nil { |
| + return Presentation{}, err |
| + } |
| + code, err := newCode() |
| + if err != nil { |
| + return Presentation{}, err |
| + } |
| + var taken int64 |
| + if err := a.livePresentations(r).Where("code = ?", code).Count(&taken).Error; err != nil { |
| + return Presentation{}, err |
| + } |
| + if taken > 0 { |
| + continue |
| + } |
| + p := Presentation{ |
| + Sqid: sqid, |
| + DeckID: d.ID, |
| + Actor: actor, |
| + Code: code, |
| + RemoteTokenHash: hashRemoteToken(token), |
| + StartedAt: time.Now().UTC(), |
| + } |
| + err = a.db.G.WithContext(r.Context()).Create(&p).Error |
| + if err == nil { |
| + return p, nil |
| + } |
| + if !isUniqueViolation(err) { |
| + return Presentation{}, err |
| + } |
| + } |
| + return Presentation{}, errors.New("slides: no free pairing code after several attempts") |
| +} |
| + |
| +// isUniqueViolation is deliberately string-shaped: the driver's error |
| +// is not typed, and the alternative — retrying on every error — would |
| +// turn a real failure into a silent loop. |
| +func isUniqueViolation(err error) bool { |
| + return err != nil && strings.Contains(strings.ToUpper(err.Error()), "UNIQUE") |
| +} |
| + |
| +// --- the pages --- |
| + |
| +// presentShell is the account slot for the four capability screens. |
| +// They are all reachable with no session at all — a projector, a |
| +// phone, a stranger holding a follow link — so they carry the |
| +// share-link shell (templates/anon.html) rather than the account menu. |
| +type presentShell struct { |
| + SignInHref string |
| + SignInLabel string |
| +} |
| + |
| +func presentShellFor(r *http.Request) presentShell { |
| + return presentShell{ |
| + SignInHref: signinPath, |
| + SignInLabel: rastrillo.T(r, "slides.signin.title"), |
| + } |
| +} |
| + |
| +// commandButton is one control: an op, and what it says. Each renders |
| +// as its own <form>, which is what makes the remote work with scripts |
| +// off (spec §8). |
| +type commandButton struct { |
| + Op string |
| + Label string |
| + Slide string |
| +} |
| + |
| +// controls is the shared button block (templates/present_controls.html). |
| +// A pointer on a page's view data, so a page with nobody entitled to |
| +// drive simply has none rather than rendering dead buttons. |
| +type controls struct { |
| + Action string |
| + Token string |
| + Buttons []commandButton |
| +} |
| + |
| +// surfacePage is the fullscreen surface: GET /p/{session}, and the |
| +// audience's follow view, which is the same page read-only. |
| +type surfacePage struct { |
| + presentShell |
| + Title string |
| + Session string |
| + // Token is present only for a request entitled to drive, and it is |
| + // what the script reads to send a keystroke. A page without it is |
| + // read-only in the markup AND in the script. |
| + Token string |
| + |
| + HasSlide bool |
| + Slide template.HTML |
| + Now int |
| + Total int |
| + Counter string |
| + |
| + Blackout bool |
| + BlackoutLabel string |
| + |
| + ProgressLabel string |
| + PagerLabel string |
| + PrevLabel string |
| + NextLabel string |
| + PrevHref string |
| + NextHref string |
| + Controls *controls |
| + |
| + NotesHref string |
| + NotesLabel string |
| + |
| + // Refresh is the follow view's scriptless heartbeat: a meta |
| + // refresh, so an audience with no JavaScript still moves with the |
| + // presenter. Off on the presenter's own surface, where a refresh |
| + // mid-sentence would be worse than a stale slide. |
| + Refresh bool |
| + BackHref string |
| + BackLabel string |
| + |
| + EmptyTitle string |
| + EmptyBody string |
| +} |
| + |
| +// presenterPage is GET /p/{session}/notes: the second screen. |
| +type presenterPage struct { |
| + presentShell |
| + Title string |
| + DeckTitle string |
| + Session string |
| + Token string |
| + |
| + Counter string |
| + CurrentLabel string |
| + Current template.HTML |
| + NextLabel string |
| + Next template.HTML |
| + HasNext bool |
| + |
| + NotesLabel string |
| + Notes template.HTML |
| + NoNotes string |
| + |
| + Blackout bool |
| + BlackoutLabel string |
| + |
| + ClockLabel string |
| + Clock string |
| + Started string |
| + Elapsed string |
| + StartedAt int64 |
| + // ElapsedTemplate is the catalog's own pattern, unsubstituted, so |
| + // static/present.js can keep the elapsed line ticking without a |
| + // sentence of English being written in a .js file. |
| + ElapsedTemplate string |
| + |
| + // The pairing block, rendered once: only for the presenter's own |
| + // session, and only on the one request that carries the token. |
| + Pair bool |
| + PairTitle string |
| + PairBody string |
| + CodeLabel string |
| + Code string |
| + URLLabel string |
| + RemoteURL string |
| + QR template.HTML |
| + Controls *controls |
| + StageHref string |
| + StageLabel string |
| + DeckHref string |
| + DeckLabel string |
| + ToolsLabel string |
| +} |
| + |
| +// remotePage is GET /p/{session}/remote: the phone. |
| +type remotePage struct { |
| + presentShell |
| + Title string |
| + DeckTitle string |
| + Session string |
| + |
| + Counter string |
| + CurrentLabel string |
| + Current template.HTML |
| + NextLabel string |
| + Next template.HTML |
| + HasNext bool |
| + |
| + NotesLabel string |
| + Notes template.HTML |
| + NoNotes string |
| + |
| + Blackout bool |
| + BlackoutLabel string |
| + |
| + Controls *controls |
| + PresenterHref string |
| + PresenterLabel string |
| + ToolsLabel string |
| +} |
| + |
| +// pairPage is GET /r/{code}: the way in for a code somebody typed. |
| +type pairPage struct { |
| + presentShell |
| + Title string |
| + Body string |
| + Action string |
| + Code string |
| + CodeLabel string |
| + CodeHint string |
| + TokenLabel string |
| + TokenHint string |
| + Submit string |
| + Error string |
| +} |
| + |
| +// --- GET /p/{session} --- |
| + |
| +// presentSurface is the fullscreen surface — the projector. |
| +// |
| +// Three requests land here and it answers all three from one path: |
| +// the page itself, the same page with ?slide=N for a scriptless |
| +// reader, and ?fragment=1 for the script, which swaps the slide |
| +// without reloading. The fragment is the SAME render as the page's |
| +// own, deliberately: two renderers for one slide is two ways for the |
| +// projector to be wrong. |
| +func (a *app) presentSurface(w http.ResponseWriter, r *http.Request) { |
| + p, ok := a.presentationFor(w, r) |
| + if !ok { |
| + return |
| + } |
| + d, ok := a.deckOf(w, r, p) |
| + if !ok { |
| + return |
| + } |
| + a.renderSurface(w, r, d, p, surfaceOptions{ |
| + token: r.URL.Query().Get("t"), |
| + }) |
| +} |
| + |
| +// surfaceOptions is what differs between the projector and the |
| +// audience's follow view — which is very little, which is the point. |
| +type surfaceOptions struct { |
| + // token, when it is the session's, makes the pager a pair of |
| + // command forms rather than a pair of local links. |
| + token string |
| + // follow marks the audience's copy: a meta refresh, a way back to |
| + // browsing, and never a control. |
| + follow bool |
| + backHref string |
| + backLabel string |
| +} |
| + |
| +func (a *app) renderSurface(w http.ResponseWriter, r *http.Request, d Deck, p Presentation, opt surfaceOptions) { |
| + h, view, err := a.openDeck(r.Context(), d) |
| + if err != nil { |
| + a.fail(w, r, "open deck", err) |
| + return |
| + } |
| + defer h.Close(r.Context()) |
| + |
| + q := r.URL.Query() |
| + page := surfacePage{ |
| + presentShell: presentShellFor(r), |
| + Title: view.Meta.Title, |
| + Session: p.Sqid, |
| + } |
| + if len(view.Slides) == 0 { |
| + page.EmptyTitle = rastrillo.T(r, "slides.viewer.empty.title") |
| + page.EmptyBody = rastrillo.T(r, "slides.viewer.empty.body") |
| + render(w, "present", page) |
| + return |
| + } |
| + |
| + // The session's own slide is what this shows, unless the request |
| + // named one: ?slide=N is how a scriptless reader pages, and how |
| + // the script fetches the slide an event just named. |
| + index := clampSlide(p.Slide, len(view.Slides)) |
| + live := true |
| + if raw := q.Get("slide"); raw != "" { |
| + n, ok := slideIndex(raw, len(view.Slides)) |
| + if !ok { |
| + a.notFound(w, r) |
| + return |
| + } |
| + index, live = n, false |
| + } |
| + |
| + html, err := slidehtml.Slide(view, index, slidehtml.Desktop) |
| + if err != nil { |
| + a.fail(w, r, "render slide", err) |
| + return |
| + } |
| + if q.Get("fragment") == "1" { |
| + // The bare slide, for the script's swap. No layout, and no |
| + // cache: it is one moment of a live session. |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + w.Header().Set("Cache-Control", "no-store") |
| + io.WriteString(w, string(html)) |
| + return |
| + } |
| + |
| + page.HasSlide = true |
| + page.Slide = html |
| + page.Now = index + 1 |
| + page.Total = len(view.Slides) |
| + page.Counter = rastrillo.Tf(r, "slides.present.progress", "n", page.Now, "total", page.Total) |
| + page.ProgressLabel = rastrillo.T(r, "slides.present.progress.label") |
| + page.PagerLabel = rastrillo.T(r, "slides.viewer.pager") |
| + page.PrevLabel = rastrillo.T(r, "slides.present.prev") |
| + page.NextLabel = rastrillo.T(r, "slides.present.next") |
| + // Blacking out is the session's state, so it applies to the |
| + // session's own slide and not to a page somebody scrolled off to. |
| + page.Blackout = p.Blackout && live |
| + page.BlackoutLabel = rastrillo.T(r, "slides.present.blacked") |
| + |
| + switch { |
| + case opt.follow: |
| + page.Refresh = true |
| + page.BackHref = opt.backHref |
| + page.BackLabel = opt.backLabel |
| + case remoteTokenOK(p, opt.token): |
| + // Whoever holds the remote drives the session from here too: |
| + // the pager posts commands, so the projector's own next button |
| + // moves the talk rather than only this browser. |
| + page.Token = opt.token |
| + page.Controls = &controls{ |
| + Action: "/p/" + p.Sqid + "/command", |
| + Token: opt.token, |
| + Buttons: []commandButton{ |
| + {Op: "prev", Label: page.PrevLabel}, |
| + {Op: "next", Label: page.NextLabel}, |
| + }, |
| + } |
| + page.NotesHref = "/p/" + p.Sqid + "/notes?t=" + url.QueryEscape(opt.token) |
| + page.NotesLabel = rastrillo.T(r, "slides.present.notes.title") |
| + default: |
| + // A watcher may page through the deck on their own screen |
| + // without moving anybody else's: ordinary links, no session |
| + // write, and the next event puts them back on the presenter's |
| + // slide. |
| + if index > 0 { |
| + page.PrevHref = "/p/" + p.Sqid + "?slide=" + strconv.Itoa(index) |
| + } |
| + if index+1 < len(view.Slides) { |
| + page.NextHref = "/p/" + p.Sqid + "?slide=" + strconv.Itoa(index+2) |
| + } |
| + } |
| + render(w, "present", page) |
| +} |
| + |
| +// clampSlide keeps a stored index inside a deck that may have lost |
| +// slides since the session started. A deck that shrank mid-talk is a |
| +// real thing an editor can do, and the surface showing the last slide |
| +// beats it showing a 500. |
| +func clampSlide(index, count int) int { |
| + if index < 0 || count == 0 { |
| + return 0 |
| + } |
| + if index >= count { |
| + return count - 1 |
| + } |
| + return index |
| +} |
| + |
| +// --- GET /p/{session}/notes --- |
| + |
| +// presenterNotes is the presenter view: what is on screen, what is |
| +// next, the notes, the clock, and — once — how to pair a phone. |
| +// |
| +// Who may read it: the presenter's own signed-in session, or whoever |
| +// holds the remote token. Never the session sqid alone, which is the |
| +// address that goes on a projector. |
| +func (a *app) presenterNotes(w http.ResponseWriter, r *http.Request) { |
| + p, ok := a.presentationFor(w, r) |
| + if !ok { |
| + return |
| + } |
| + token := r.URL.Query().Get("t") |
| + holder := remoteTokenOK(p, token) |
| + presenter := p.Actor != "" && a.presenterActor(r) == p.Actor |
| + if !holder && !presenter { |
| + a.notFound(w, r) |
| + return |
| + } |
| + d, ok := a.deckOf(w, r, p) |
| + if !ok { |
| + return |
| + } |
| + h, view, err := a.openDeck(r.Context(), d) |
| + if err != nil { |
| + a.fail(w, r, "open deck", err) |
| + return |
| + } |
| + defer h.Close(r.Context()) |
| + |
| + now := time.Now().UTC() |
| + page := presenterPage{ |
| + presentShell: presentShellFor(r), |
| + Title: rastrillo.T(r, "slides.present.notes.title"), |
| + DeckTitle: view.Meta.Title, |
| + Session: p.Sqid, |
| + |
| + CurrentLabel: rastrillo.T(r, "slides.present.current"), |
| + NextLabel: rastrillo.T(r, "slides.present.next.label"), |
| + NotesLabel: rastrillo.T(r, "slides.viewer.notes"), |
| + NoNotes: rastrillo.T(r, "slides.notes.none"), |
| + |
| + Blackout: p.Blackout, |
| + BlackoutLabel: rastrillo.T(r, "slides.present.blacked"), |
| + |
| + ClockLabel: rastrillo.T(r, "slides.present.clock"), |
| + Clock: clockWords(now), |
| + Started: rastrillo.Tf(r, "slides.present.started", "when", clockWords(p.StartedAt)), |
| + Elapsed: rastrillo.Tf(r, "slides.present.elapsed", "n", int(now.Sub(p.StartedAt).Minutes())), |
| + StartedAt: p.StartedAt.Unix(), |
| + // T rather than Tf: the pattern with its {n} intact. |
| + ElapsedTemplate: rastrillo.T(r, "slides.present.elapsed"), |
| + |
| + DeckHref: "/d/" + d.Sqid, |
| + DeckLabel: rastrillo.T(r, "slides.deck.back"), |
| + ToolsLabel: rastrillo.T(r, "slides.viewer.tools"), |
| + } |
| + shown := presentedFor(r, view, p, slidehtml.Desktop) |
| + page.Counter, page.Current = shown.Counter, shown.Current |
| + page.Next, page.HasNext, page.Notes = shown.Next, shown.HasNext, shown.Notes |
| + |
| + if holder { |
| + page.Token = token |
| + page.Controls = commandControls(r, p, token) |
| + page.StageHref = "/p/" + p.Sqid + "?t=" + url.QueryEscape(token) |
| + page.StageLabel = rastrillo.T(r, "slides.present.stage") |
| + } |
| + // Once, and only to the person presenting: the token is in this |
| + // URL, so the pairing block is what that one request is FOR. |
| + // Coming back to the page without it — from a bookmark, from the |
| + // browser's history — shows the talk and no capability. |
| + if holder && presenter { |
| + remoteURL := a.cfg.Origin + "/r/" + p.Code + "?t=" + token |
| + qr, err := qrSVG(remoteURL, rastrillo.Tf(r, "slides.present.pair.qr", "code", p.Code)) |
| + if err != nil { |
| + a.fail(w, r, "render the pairing QR", err) |
| + return |
| + } |
| + page.Pair = true |
| + page.PairTitle = rastrillo.T(r, "slides.present.pair.title") |
| + page.PairBody = rastrillo.T(r, "slides.present.pair.body") |
| + page.CodeLabel = rastrillo.T(r, "slides.present.pair.code") |
| + page.Code = p.Code |
| + page.URLLabel = rastrillo.T(r, "slides.present.pair.url") |
| + page.RemoteURL = remoteURL |
| + page.QR = qr |
| + } |
| + render(w, "present_notes", page) |
| +} |
| + |
| +// clockWords is a wall clock a presenter can read at a glance. UTC and |
| +// said so: the server has no idea what time zone the room is in, and a |
| +// clock that is quietly an hour out is worse than one that names its |
| +// zone. static/present.js replaces it with the browser's own local |
| +// time when there is a script to do it. |
| +func clockWords(t time.Time) string { |
| + return t.UTC().Format("15:04") + " UTC" |
| +} |
| + |
| +// presented is the two slides and the notes every presenting screen |
| +// shows: the one on the projector, the one coming next, and the notes |
| +// for the one that is up. |
| +type presented struct { |
| + Counter string |
| + Current template.HTML |
| + Next template.HTML |
| + HasNext bool |
| + Notes template.HTML |
| +} |
| + |
| +// presentedFor renders them for one viewport. A slide that will not |
| +// render leaves its half empty rather than failing the page: the |
| +// presenter view is what somebody is reading WHILE talking, and half |
| +// of it beats an error page. |
| +func presentedFor(r *http.Request, view *deck.Deck, p Presentation, vp slidehtml.Viewport) presented { |
| + count := len(view.Slides) |
| + if count == 0 { |
| + return presented{} |
| + } |
| + index := clampSlide(p.Slide, count) |
| + out := presented{ |
| + Counter: rastrillo.Tf(r, "slides.present.progress", "n", index+1, "total", count), |
| + } |
| + if html, err := slidehtml.Slide(view, index, vp); err == nil { |
| + out.Current = html |
| + } |
| + if index+1 < count { |
| + out.HasNext = true |
| + if html, err := slidehtml.Slide(view, index+1, vp); err == nil { |
| + out.Next = html |
| + } |
| + } |
| + if s := view.Slides[index]; len(s.Notes) > 0 { |
| + out.Notes = slidehtml.Notes(s.Notes) |
| + } |
| + return out |
| +} |
| + |
| +// commandControls is the four-button block the presenter view and the |
| +// remote both carry. One function, so the two screens cannot drift on |
| +// what a button is called or what it does — including the blackout |
| +// label, which says which way it goes rather than lighting up. |
| +func commandControls(r *http.Request, p Presentation, token string) *controls { |
| + blackout := rastrillo.T(r, "slides.present.blackout") |
| + if p.Blackout { |
| + blackout = rastrillo.T(r, "slides.present.unblackout") |
| + } |
| + return &controls{ |
| + Action: "/p/" + p.Sqid + "/command", |
| + Token: token, |
| + Buttons: []commandButton{ |
| + {Op: "prev", Label: rastrillo.T(r, "slides.present.prev")}, |
| + {Op: "next", Label: rastrillo.T(r, "slides.present.next")}, |
| + {Op: "blackout", Label: blackout}, |
| + {Op: "end", Label: rastrillo.T(r, "slides.present.end")}, |
| + }, |
| + } |
| +} |
| + |
| +// --- GET /p/{session}/remote --- |
| + |
| +// remote is the phone: the slide that is up, the one coming, the |
| +// notes, and four buttons big enough to hit without looking. |
| +// |
| +// The token is the whole permission, so a wrong one is a 404 like |
| +// every other capability in this app. |
| +func (a *app) remote(w http.ResponseWriter, r *http.Request) { |
| + p, ok := a.presentationFor(w, r) |
| + if !ok { |
| + return |
| + } |
| + token := r.URL.Query().Get("t") |
| + if !remoteTokenOK(p, token) { |
| + a.notFound(w, r) |
| + return |
| + } |
| + d, ok := a.deckOf(w, r, p) |
| + if !ok { |
| + return |
| + } |
| + h, view, err := a.openDeck(r.Context(), d) |
| + if err != nil { |
| + a.fail(w, r, "open deck", err) |
| + return |
| + } |
| + defer h.Close(r.Context()) |
| + |
| + page := remotePage{ |
| + presentShell: presentShellFor(r), |
| + Title: rastrillo.T(r, "slides.remote.title"), |
| + DeckTitle: view.Meta.Title, |
| + Session: p.Sqid, |
| + CurrentLabel: rastrillo.T(r, "slides.present.current"), |
| + NextLabel: rastrillo.T(r, "slides.present.next.label"), |
| + NotesLabel: rastrillo.T(r, "slides.viewer.notes"), |
| + NoNotes: rastrillo.T(r, "slides.notes.none"), |
| + Blackout: p.Blackout, |
| + BlackoutLabel: rastrillo.T(r, "slides.present.blacked"), |
| + Controls: commandControls(r, p, token), |
| + PresenterHref: "/p/" + p.Sqid + "/notes?t=" + url.QueryEscape(token), |
| + PresenterLabel: rastrillo.T(r, "slides.present.notes.title"), |
| + ToolsLabel: rastrillo.T(r, "slides.viewer.tools"), |
| + } |
| + // The phone's own rendering, not a shrunk desktop one: the same |
| + // reflow the viewer's phone preview uses (spec §2.2). |
| + shown := presentedFor(r, view, p, slidehtml.Phone) |
| + page.Counter, page.Current = shown.Counter, shown.Current |
| + page.Next, page.HasNext, page.Notes = shown.Next, shown.HasNext, shown.Notes |
| + render(w, "remote", page) |
| +} |
| + |
| +// --- GET /r/{code} --- |
| + |
| +// remotePair is where a scanned QR and a typed code both land. |
| +// |
| +// With the token in the URL — the QR carries both halves — it is a |
| +// redirect to the remote and nothing else. Without it, it is a form |
| +// asking for the code and the key, because the code alone is not a |
| +// capability and this page must not pretend otherwise. |
| +func (a *app) remotePair(w http.ResponseWriter, r *http.Request) { |
| + q := r.URL.Query() |
| + code := strings.ToUpper(strings.TrimSpace(chi.URLParam(r, "code"))) |
| + token := q.Get("t") |
| + |
| + // A code typed into the form arrives as a query on whatever code's |
| + // page the form was rendered on, so the form's own answer is a |
| + // redirect to the page for the code that was typed. That is what |
| + // lets one route serve both the scan and the typing. |
| + if typed := strings.ToUpper(strings.TrimSpace(q.Get("code"))); typed != "" && typed != code { |
| + if !validCode(typed) { |
| + a.notFound(w, r) |
| + return |
| + } |
| + to := "/r/" + typed |
| + if token != "" { |
| + to += "?t=" + url.QueryEscape(token) |
| + } |
| + http.Redirect(w, r, to, http.StatusSeeOther) |
| + return |
| + } |
| + |
| + var p Presentation |
| + err := a.livePresentations(r).Where("code = ?", code).Order("started_at DESC").Take(&p).Error |
| + switch { |
| + case err == nil: |
| + case errors.Is(err, gorm.ErrRecordNotFound): |
| + // A dead code is a 404, never a 410: "this code was real |
| + // yesterday" is not something to tell somebody guessing. |
| + a.notFound(w, r) |
| + return |
| + default: |
| + a.fail(w, r, "resolve a pairing code", err) |
| + return |
| + } |
| + |
| + if token != "" && remoteTokenOK(p, token) { |
| + http.Redirect(w, r, "/p/"+p.Sqid+"/remote?t="+url.QueryEscape(token), http.StatusSeeOther) |
| + return |
| + } |
| + |
| + page := pairPage{ |
| + presentShell: presentShellFor(r), |
| + Title: rastrillo.T(r, "slides.remote.pair.title"), |
| + Body: rastrillo.T(r, "slides.remote.pair.body"), |
| + Action: "/r/" + code, |
| + Code: code, |
| + CodeLabel: rastrillo.T(r, "slides.remote.pair.code"), |
| + CodeHint: rastrillo.T(r, "slides.remote.pair.code.hint"), |
| + TokenLabel: rastrillo.T(r, "slides.remote.pair.token"), |
| + TokenHint: rastrillo.T(r, "slides.remote.pair.token.hint"), |
| + Submit: rastrillo.T(r, "slides.remote.pair.submit"), |
| + } |
| + if token != "" { |
| + // The code is live and the key is not its own: say so on the |
| + // form rather than answering 404, which would send somebody |
| + // mistyping a key round the houses. |
| + page.Error = rastrillo.T(r, "slides.remote.pair.error") |
| + w.WriteHeader(http.StatusUnprocessableEntity) |
| + } |
| + render(w, "remote_pair", page) |
| +} |
| + |
| +// validCode keeps a typed code inside the alphabet before it becomes a |
| +// URL: the redirect above builds a path out of it. |
| +func validCode(code string) bool { |
| + if len(code) != codeLength { |
| + return false |
| + } |
| + for _, c := range code { |
| + if !strings.ContainsRune(codeAlphabet, c) { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| + |
| +// --- GET /p/{session}/stream --- |
| + |
| +// The stream's timings, and the reasons, are Docs' |
| +// (internal/docs/stream.go): a per-write deadline because rastrillo |
| +// serve leaves WriteTimeout off for exactly this handler's sake, a |
| +// heartbeat because it is what makes that deadline ever fire, and a |
| +// TTL because platform hibernation is default-on and a projector left |
| +// on overnight must not pin the instance awake. |
| +const ( |
| + streamWriteDeadline = 30 * time.Second |
| + streamHeartbeat = 15 * time.Second |
| + streamTTL = 5 * time.Minute |
| +) |
| + |
| +// presentStream is the session's SSE feed: `event: slide` carrying the |
| +// whole state as JSON, one event on connect, and a comment ping in |
| +// between. |
| +// |
| +// Unlike Docs' stream, what travels here IS the state rather than an |
| +// advisory cursor — a presentation has no "fetch what you missed" |
| +// endpoint to make an advisory notification safe. That is why the hub |
| +// drops a subscriber that falls behind instead of skipping events |
| +// (internal/slides/present), and why the connect event matters: a |
| +// dropped page reconnects and is immediately right again. |
| +func (a *app) presentStream(w http.ResponseWriter, r *http.Request) { |
| + p, ok := a.presentationFor(w, r) |
| + if !ok { |
| + return |
| + } |
| + fl, ok := w.(http.Flusher) |
| + if !ok { |
| + a.fail(w, r, "stream: the ResponseWriter does not flush", nil) |
| + return |
| + } |
| + |
| + // Subscribe BEFORE re-reading the row, never after (Docs' note): a |
| + // command landing between the two must produce a redundant event, |
| + // which costs one repaint, where the other order would let it fall |
| + // into the gap and leave the projector a slide behind for the rest |
| + // of the talk. |
| + ch, stop := a.hub.Subscribe(p.Sqid) |
| + defer stop() |
| + |
| + var current Presentation |
| + if err := a.db.G.WithContext(r.Context()).Take(¤t, p.ID).Error; err != nil { |
| + a.fail(w, r, "read a presentation for the stream", err) |
| + return |
| + } |
| + |
| + rc := http.NewResponseController(w) |
| + w.Header().Set("Content-Type", "text/event-stream") |
| + w.Header().Set("Cache-Control", "no-store") |
| + w.WriteHeader(http.StatusOK) |
| + fl.Flush() |
| + |
| + write := func(text string) bool { |
| + // The deadline error is ignored deliberately: a |
| + // ResponseWriter that cannot set deadlines still streams, |
| + // unbounded. A write error ends the handler, because it means |
| + // the client is gone. |
| + _ = rc.SetWriteDeadline(time.Now().Add(streamWriteDeadline)) |
| + if _, err := io.WriteString(w, text); err != nil { |
| + return false |
| + } |
| + fl.Flush() |
| + return true |
| + } |
| + event := func(e present.Event) bool { |
| + body, err := json.Marshal(e) |
| + if err != nil { |
| + return false |
| + } |
| + return write("event: slide\ndata: " + string(body) + "\n\n") |
| + } |
| + |
| + if !event(eventFor(current)) { |
| + return |
| + } |
| + |
| + heartbeat := time.NewTimer(streamHeartbeat) |
| + defer heartbeat.Stop() |
| + ttl := time.NewTimer(streamTTL) |
| + defer ttl.Stop() |
| + |
| + for { |
| + select { |
| + case <-r.Context().Done(): |
| + // The client went away, or the server is draining. Either |
| + // way this returns, so a graceful shutdown's budget is not |
| + // held open by a projector. |
| + return |
| + case <-ttl.C: |
| + // A clean close: the browser reopens if it still wants |
| + // one, and a parked tab does not. |
| + return |
| + case e, ok := <-ch: |
| + if !ok { |
| + // Dropped for falling behind. Ending the response is |
| + // the right answer: EventSource reconnects, and the |
| + // connect event re-reads the truth from the row. |
| + return |
| + } |
| + if !event(e) { |
| + return |
| + } |
| + heartbeat.Reset(streamHeartbeat) |
| + case <-heartbeat.C: |
| + if !write(": ping\n\n") { |
| + return |
| + } |
| + heartbeat.Reset(streamHeartbeat) |
| + } |
| + } |
| +} |
| + |
| +// --- POST /p/{session}/command --- |
| + |
| +// errPresentationEnded is the transaction's own refusal: the row was |
| +// ended by somebody else between this request resolving it and the |
| +// UPDATE. It becomes a 410, the same answer the GET would have given. |
| +var errPresentationEnded = errors.New("slides: the presentation ended") |
| + |
| +// presentCommand is the one write every driving surface makes: next, |
| +// prev, goto, blackout, end. |
| +// |
| +// The token is the whole permission — a form field for a browser, a |
| +// bearer for anything else, which is the seam a native remote will use |
| +// (spec §3.4: "a native remote later speaks exactly present, command |
| +// and the stream"). Note that a non-browser caller must still send an |
| +// Origin header: csrf.Protect covers this router app-wide, and being |
| +// covered by it is worth more than sparing a native client one header. |
| +// |
| +// The write is a transaction with `ended_at IS NULL` in the WHERE, so |
| +// a command racing the End button loses rather than reviving a talk |
| +// that is over. |
| +func (a *app) presentCommand(w http.ResponseWriter, r *http.Request) { |
| + p, ok := a.presentationFor(w, r) |
| + if !ok { |
| + return |
| + } |
| + token := r.PostFormValue("t") |
| + if token == "" { |
| + token = bearerToken(r) |
| + } |
| + if !remoteTokenOK(p, token) { |
| + a.notFound(w, r) |
| + return |
| + } |
| + |
| + op := r.PostFormValue("op") |
| + // The slide count is what bounds next and goto, and it costs a |
| + // document open — so only the three ops that move the deck pay for |
| + // it. Blackout and end do not need to know how long the deck is. |
| + count := 0 |
| + if op == "next" || op == "prev" || op == "goto" { |
| + d, ok := a.deckOf(w, r, p) |
| + if !ok { |
| + return |
| + } |
| + h, view, err := a.openDeck(r.Context(), d) |
| + if err != nil { |
| + a.fail(w, r, "open deck", err) |
| + return |
| + } |
| + count = len(view.Slides) |
| + h.Close(r.Context()) |
| + if count == 0 { |
| + ErrorPage(w, r, http.StatusUnprocessableEntity, "") |
| + return |
| + } |
| + } |
| + |
| + var updated Presentation |
| + err := a.db.G.WithContext(r.Context()).Transaction(func(tx *gorm.DB) error { |
| + var cur Presentation |
| + if err := tx.Where("id = ? AND ended_at IS NULL", p.ID).Take(&cur).Error; err != nil { |
| + if errors.Is(err, gorm.ErrRecordNotFound) { |
| + return errPresentationEnded |
| + } |
| + return err |
| + } |
| + changes, err := commandChanges(op, r.PostFormValue("slide"), cur, count) |
| + if err != nil { |
| + return err |
| + } |
| + // A map, not a struct: the explicit column allowlist SKILL.md |
| + // §4 asks for, so nothing a form carries can reach a column |
| + // this handler did not name. |
| + res := tx.Model(&Presentation{}). |
| + Where("id = ? AND ended_at IS NULL", p.ID).Updates(changes) |
| + if res.Error != nil { |
| + return res.Error |
| + } |
| + if res.RowsAffected == 0 { |
| + return errPresentationEnded |
| + } |
| + return tx.Take(&updated, p.ID).Error |
| + }) |
| + switch { |
| + case err == nil: |
| + case errors.Is(err, errPresentationEnded): |
| + a.gone(w, r) |
| + return |
| + case errors.Is(err, errBadCommand): |
| + ErrorPage(w, r, http.StatusUnprocessableEntity, "") |
| + return |
| + default: |
| + a.fail(w, r, "apply a presentation command", err) |
| + return |
| + } |
| + |
| + // Everybody watching, at once: the presenter's laptop, the phone, |
| + // and every follower. |
| + a.hub.Publish(p.Sqid, eventFor(updated)) |
| + a.logger.Info("slides: presentation command", |
| + "session", p.Sqid, "op", op, "slide", updated.Slide, "actor", p.Actor) |
| + |
| + // A browser form goes back to the page it came from — same-origin |
| + // only, checked against the origin the app resolved, so this can |
| + // never be an open redirect. Anything else gets a 204: there is no |
| + // page to return to. |
| + if ref := r.Header.Get("Referer"); ref != "" && a.sameOrigin(ref) { |
| + http.Redirect(w, r, ref, http.StatusSeeOther) |
| + return |
| + } |
| + w.WriteHeader(http.StatusNoContent) |
| +} |
| + |
| +// errBadCommand is an op, or a goto's slide, that is not one this |
| +// session can do. |
| +var errBadCommand = errors.New("slides: not a command") |
| + |
| +// commandChanges is the whole decision a command makes, as the columns |
| +// it writes. Split out from the transaction so it is one readable |
| +// switch rather than a switch inside a closure inside a handler. |
| +func commandChanges(op, slide string, cur Presentation, count int) (map[string]any, error) { |
| + switch op { |
| + case "next": |
| + return map[string]any{"slide": clampSlide(cur.Slide+1, count)}, nil |
| + case "prev": |
| + return map[string]any{"slide": clampSlide(cur.Slide-1, count)}, nil |
| + case "goto": |
| + n, err := strconv.Atoi(slide) |
| + if err != nil || n < 1 || n > count { |
| + return nil, errBadCommand |
| + } |
| + return map[string]any{"slide": n - 1}, nil |
| + case "blackout": |
| + // A toggle rather than a value, because the button that sends |
| + // it says which way it goes and the room is the source of |
| + // truth for whether the screen is black. |
| + return map[string]any{"blackout": !cur.Blackout}, nil |
| + case "end": |
| + return map[string]any{"ended_at": time.Now().UTC()}, nil |
| + default: |
| + return nil, errBadCommand |
| + } |
| +} |
| + |
| +// bearerToken reads `Authorization: Bearer <token>`, the shape a |
| +// native remote or an agent will use. |
| +func bearerToken(r *http.Request) string { |
| + h := r.Header.Get("Authorization") |
| + const prefix = "Bearer " |
| + if len(h) <= len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) { |
| + return "" |
| + } |
| + return strings.TrimSpace(h[len(prefix):]) |
| +} |
| + |
| +// sameOrigin reports whether a URL is this app's own — the check |
| +// behind the command's 303, and the reason it cannot be turned into an |
| +// open redirect by a crafted Referer. |
| +func (a *app) sameOrigin(raw string) bool { |
| + u, err := url.Parse(raw) |
| + if err != nil || u.Scheme == "" || u.Host == "" { |
| + return false |
| + } |
| + return u.Scheme+"://"+u.Host == a.cfg.Origin |
| +} |
| + |
| +// --- audience follow, from a share link --- |
| + |
| +// liveSessionFor finds the session somebody is currently presenting |
| +// this deck in, if there is one. The most recent, when a deck somehow |
| +// has two: the one that started last is the one being given. |
| +func (a *app) liveSessionFor(r *http.Request, deckID int64) (Presentation, bool) { |
| + var p Presentation |
| + err := a.livePresentations(r).Where("deck_id = ?", deckID). |
| + Order("started_at DESC").Take(&p).Error |
| + if err != nil { |
| + if !errors.Is(err, gorm.ErrRecordNotFound) { |
| + a.logger.Error("slides: look for a live presentation", "deck", deckID, "err", err) |
| + } |
| + return Presentation{}, false |
| + } |
| + return p, true |
| +} |
| + |
| +// followPresenter renders the audience's copy of the surface: the |
| +// presenter's own slide, read-only, with a way back to browsing. |
| +// |
| +// Read-only is enforced by there being nothing to press — no token |
| +// reaches this page, so its script has nothing to send and its markup |
| +// has no form. An audience that could drive the talk would not be an |
| +// audience. |
| +func (a *app) followPresenter(w http.ResponseWriter, r *http.Request, d Deck, p Presentation, base string) { |
| + a.renderSurface(w, r, d, p, surfaceOptions{ |
| + follow: true, |
| + backHref: base, |
| + backLabel: rastrillo.T(r, "slides.follow.browse"), |
| + }) |
| +} |
| + |
| +// --- the QR --- |
| + |
| +// qrSVG draws the pairing QR as an inline SVG. |
| +// |
| +// Inline rather than an <img> with a data URI: it is markup the page |
| +// already has, so it costs no second request and no base64 padding, |
| +// and role="img" with a label naming the code is what makes it |
| +// meaningful to somebody who cannot see it — the label says the code, |
| +// which is the actual information the picture carries. |
| +// |
| +// Black on white, fixed, rather than currentColor: a QR is read by a |
| +// camera looking for contrast, and a code that inverted itself in dark |
| +// mode would scan on half the phones in the room. |
| +func qrSVG(text, label string) (template.HTML, error) { |
| + q, err := qrcode.New(text, qrcode.Medium) |
| + if err != nil { |
| + return "", fmt.Errorf("slides: build a QR: %w", err) |
| + } |
| + bitmap := q.Bitmap() |
| + size := len(bitmap) |
| + if size == 0 { |
| + return "", errors.New("slides: empty QR") |
| + } |
| + |
| + // One path for every dark module, rather than one <rect> each: the |
| + // same picture in about a fifth of the bytes. |
| + var path strings.Builder |
| + for y, row := range bitmap { |
| + for x, dark := range row { |
| + if dark { |
| + fmt.Fprintf(&path, "M%d %dh1v1h-1z", x, y) |
| + } |
| + } |
| + } |
| + |
| + var svg strings.Builder |
| + 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">`, |
| + size, size, template.HTMLEscapeString(label)) |
| + fmt.Fprintf(&svg, `<rect width="%d" height="%d" fill="#ffffff"/>`, size, size) |
| + fmt.Fprintf(&svg, `<path fill="#000000" d="%s"/></svg>`, path.String()) |
| + return template.HTML(svg.String()), nil |
| +} |
diff --git a/internal/slides/render.go b/internal/slides/render.go| index a5d6f82..1c69689 100644 |
| --- a/internal/slides/render.go |
| +++ b/internal/slides/render.go |
| @@ -69,6 +69,24 @@ var signedInPages = []string{"decks", "viewer", "deck_notes", "deck_new", "deck_ |
| // an empty account menu is worse than none. |
| var linkPages = map[string]string{"viewer_link": "viewer"} |
| +// capabilityPages are the presenting screens (presentation.go): the |
| +// fullscreen surface, the presenter view, the phone remote and the |
| +// pairing page. Every one of them is addressed by a capability rather |
| +// than by a session — a projector, a phone and an audience have no |
| +// account here — so they carry templates/anon.html's shell like the |
| +// share-link viewer does. |
| +// |
| +// A map of extra files rather than a list of names, because three of |
| +// the four share one partial: present_controls.html defines the |
| +// command buttons once, so the remote and the presenter view cannot |
| +// drift on what a button is called or where it posts. |
| +var capabilityPages = map[string][]string{ |
| + "present": {"templates/anon.html", "templates/present_controls.html"}, |
| + "present_notes": {"templates/anon.html", "templates/present_controls.html"}, |
| + "remote": {"templates/anon.html", "templates/present_controls.html"}, |
| + "remote_pair": {"templates/anon.html"}, |
| +} |
| + |
| func init() { |
| for _, name := range publicPages { |
| pages[name] = buildPage(name) |
| @@ -79,6 +97,9 @@ func init() { |
| for name, file := range linkPages { |
| pages[name] = buildPage(file, "templates/anon.html") |
| } |
| + for name, shared := range capabilityPages { |
| + pages[name] = buildPage(name, shared...) |
| + } |
| } |
| // buildPage parses one page: ui's partials, then the layout, then any |
diff --git a/internal/slides/share.go b/internal/slides/share.go| index 72ed19f..7e6eaa0 100644 |
| --- a/internal/slides/share.go |
| +++ b/internal/slides/share.go |
| @@ -348,5 +348,23 @@ func (a *app) sharedDeck(w http.ResponseWriter, r *http.Request) { |
| a.logger.Error("slides: count a share link view", "link", link.ID, "err", err) |
| } |
| + // Following the presenter (spec §3.4). The share link is the whole |
| + // capability here too: the audience never learns the session's own |
| + // address, never mind the remote's, so ?follow=1 is a flag on the |
| + // link they already hold rather than a second URL to hand out. |
| + // |
| + // Nothing to follow is a redirect back to the deck, not an error: |
| + // the talk finishing while somebody was watching is the ordinary |
| + // end of a session, and the honest answer is the deck they can |
| + // still read. |
| + if r.URL.Query().Get("follow") == "1" { |
| + if p, live := a.liveSessionFor(r, d.ID); live { |
| + a.followPresenter(w, r, d, p, "/s/"+token) |
| + return |
| + } |
| + http.Redirect(w, r, "/s/"+token, http.StatusSeeOther) |
| + return |
| + } |
| + |
| a.renderViewer(w, r, d, modeLink, "/s/"+token, shell{}) |
| } |
diff --git a/internal/slides/static/present.js b/internal/slides/static/present.js| new file mode 100644 |
| index 0000000..9df0e17 |
| --- /dev/null |
| +++ b/internal/slides/static/present.js |
| @@ -0,0 +1,117 @@ |
| +// present.js — the live half of presenting: a keyboard, a swipe, and |
| +// a stream that keeps every screen on the same slide. |
| +// |
| +// An ENHANCEMENT and nothing else. Every page that loads it — the |
| +// fullscreen surface, the presenter view, the phone remote, the |
| +// audience's follow view — is complete without it: the buttons are |
| +// forms, the pager is links, the follow view has a meta refresh. |
| +// |
| +// It sends exactly what those forms send (op, t, slide, POSTed to |
| +// /p/{session}/command), so there is one protocol rather than two, and |
| +// only on a page rendered for whoever holds the remote: the token |
| +// arrives as data-remote-token, and a page without one cannot send. |
| +(function () { |
| + var root = document.querySelector('[data-session]'); |
| + if (!root) return; |
| + var session = root.getAttribute('data-session'); |
| + var token = root.getAttribute('data-remote-token') || ''; |
| + var stage = document.querySelector('[slides-stage]'); |
| + var note = document.querySelector('[slides-blackout]'); |
| + |
| + function command(op, slide) { |
| + if (!token) return; |
| + var body = new URLSearchParams({ op: op, t: token }); |
| + if (slide) body.set('slide', slide); |
| + fetch('/p/' + session + '/command', { |
| + method: 'POST', |
| + body: body, |
| + credentials: 'same-origin', |
| + headers: { 'Content-Type': 'application/x-www-form-urlencoded' } |
| + }).catch(function () {}); |
| + } |
| + |
| + document.addEventListener('keydown', function (e) { |
| + if (e.metaKey || e.ctrlKey || e.altKey) return; |
| + var tag = e.target && e.target.tagName; |
| + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; |
| + switch (e.key) { |
| + case 'ArrowRight': case ' ': case 'PageDown': |
| + command('next'); |
| + break; |
| + case 'ArrowLeft': case 'PageUp': |
| + command('prev'); |
| + break; |
| + case 'b': case 'B': |
| + command('blackout'); |
| + break; |
| + case 'n': case 'N': |
| + var notes = root.getAttribute('data-notes'); |
| + if (notes) window.open(notes, 'slides-notes'); |
| + break; |
| + case 'Escape': |
| + if (document.fullscreenElement) document.exitFullscreen(); |
| + return; |
| + default: return; |
| + } |
| + e.preventDefault(); |
| + }); |
| + |
| + var startX = null; |
| + root.addEventListener('touchstart', function (e) { |
| + startX = e.changedTouches[0].clientX; |
| + }, { passive: true }); |
| + root.addEventListener('touchend', function (e) { |
| + if (startX === null) return; |
| + var dx = e.changedTouches[0].clientX - startX; |
| + startX = null; |
| + // Enough to be a swipe rather than a tap that moved. |
| + if (Math.abs(dx) < 60) return; |
| + command(dx < 0 ? 'next' : 'prev'); |
| + }, { passive: true }); |
| + |
| + // apply is the whole of what an event does. The state is complete — |
| + // never a delta — so applying one is exactly right rather than |
| + // approximately, and a page that missed some is right again on the |
| + // next one. |
| + function apply(state) { |
| + if (state.ended) { window.location.reload(); return; } |
| + root.setAttribute('data-blackout', state.blackout ? 'true' : 'false'); |
| + if (note) note.hidden = !state.blackout; |
| + if (String(state.slide) === root.getAttribute('data-slide')) return; |
| + root.setAttribute('data-slide', String(state.slide)); |
| + if (!stage) { |
| + // The presenter view and the remote show two slides and the |
| + // notes; re-rendering all that in here would be a second |
| + // renderer to keep in step with the Go one. |
| + window.location.reload(); |
| + return; |
| + } |
| + fetch('/p/' + session + '?slide=' + state.slide + '&fragment=1', { credentials: 'same-origin' }) |
| + .then(function (r) { return r.ok ? r.text() : null; }) |
| + .then(function (html) { if (html !== null) stage.innerHTML = html; }) |
| + .catch(function () {}); |
| + } |
| + |
| + var events = new EventSource('/p/' + session + '/stream'); |
| + events.addEventListener('slide', function (e) { |
| + try { apply(JSON.parse(e.data)); } catch (err) { /* not ours */ } |
| + }); |
| + |
| + // The clock and elapsed line are server-rendered in UTC — the server |
| + // has no idea what time zone the room is in. Here they become local |
| + // and tick, in the catalog's own words: the pattern comes down as an |
| + // attribute rather than being written in this file. |
| + var clock = document.querySelector('[data-clock]'); |
| + var elapsed = document.querySelector('[data-elapsed-since]'); |
| + function tick() { |
| + if (clock) clock.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); |
| + if (!elapsed) return; |
| + var mins = Math.floor((Date.now() / 1000 - Number(elapsed.getAttribute('data-elapsed-since'))) / 60); |
| + var pattern = elapsed.getAttribute('data-elapsed-template'); |
| + if (pattern) elapsed.textContent = pattern.replace('{n}', String(mins)); |
| + } |
| + if (clock || elapsed) { |
| + tick(); |
| + setInterval(tick, 15000); |
| + } |
| +})(); |
diff --git a/internal/slides/templates/errors.html b/internal/slides/templates/errors.html| index 73a0e8c..591c9e6 100644 |
| --- a/internal/slides/templates/errors.html |
| +++ b/internal/slides/templates/errors.html |
| @@ -6,4 +6,11 @@ |
| editing this file, or hand it Title/Body/HomeHref/BackHref to |
| override the partial's own catalog defaults; see |
| ui/partials/error-page.html for what it accepts. */}} |
| -{{define "content"}}{{template "error-page" dict "Status" .Status "Ref" .Ref}}{{end}} |
| +{{/* Title and Body are optional overrides, and they are what lets a |
| + status the framework catalog does not word carry words of its own: |
| + the 410 for a presentation that is over (presentation.go's gone) |
| + is the first, and it would otherwise render the generic pair. A |
| + caller that passes neither is unchanged — the partial falls back |
| + to the catalog exactly as it did. */}} |
| +{{define "content"}}{{template "error-page" dict |
| + "Status" .Status "Ref" .Ref "Title" .Title "Body" .Body}}{{end}} |
diff --git a/internal/slides/templates/present.html b/internal/slides/templates/present.html| new file mode 100644 |
| index 0000000..ddad6a5 |
| --- /dev/null |
| +++ b/internal/slides/templates/present.html |
| @@ -0,0 +1,91 @@ |
| +{{/* present — GET /p/{session}, the fullscreen surface, and the |
| + audience's follow view (GET /s/{token}?follow=1), which is the |
| + same page with nothing to press. |
| + |
| + One template for both because they ARE the same screen: a slide |
| + filling the viewport, where in the deck it is, and a way to move — |
| + forms for whoever holds the remote, links for whoever is only |
| + watching, nothing at all for an audience following along. |
| + |
| + Nothing here needs the script. With it, static/present.js swaps |
| + the slide when the session moves; without it, the follow view has |
| + a meta refresh and the presenter has next and previous. */}} |
| +{{define "title"}}{{.Title}}{{end}} |
| +{{define "page-head"}}<link rel="stylesheet" href="{{asset "static/slides.css"}}"> |
| +{{if .Refresh}}{{/* The scriptless heartbeat: an audience with no |
| + JavaScript still moves with the presenter, five seconds behind. |
| + Only on the follow view — a refresh on the presenter's own screen |
| + would blink the projector mid-sentence. */}} |
| +<meta http-equiv="refresh" content="5">{{end}} |
| +<script defer src="{{asset "static/present.js"}}"></script> |
| +<style> |
| + /* The stage fills what it is given and keeps the slide's aspect; |
| + everything else on this page is one line of chrome under it. */ |
| + [slides-surface] { |
| + display: flex; |
| + flex-direction: column; |
| + gap: var(--rst-sp-3); |
| + } |
| + [slides-stage] { |
| + margin: 0; |
| + } |
| + [slides-bar] { |
| + align-items: center; |
| + display: flex; |
| + flex-wrap: wrap; |
| + gap: var(--rst-sp-3); |
| + } |
| + [slides-bar] form { |
| + display: inline; |
| + margin: 0; |
| + } |
| + [slides-counter] { |
| + color: var(--rst-text-muted); |
| + margin: 0; |
| + } |
| + [slides-progress] { |
| + block-size: var(--rst-sp-1); |
| + inline-size: min(100%, 24rem); |
| + } |
| + /* Blacking out hides the slide and says so in words. Never colour |
| + alone: the sentence below the stage is what tells somebody using a |
| + screen reader — or looking at a projector that is simply off — |
| + that this is deliberate. */ |
| + [slides-surface][data-blackout="true"] [slides-stage] { |
| + visibility: hidden; |
| + } |
| + [slides-blackout] { |
| + font-weight: 600; |
| + margin: 0; |
| + } |
| +</style>{{end}} |
| +{{define "content"}} |
| +<div slides-surface |
| + data-session="{{.Session}}" |
| + data-slide="{{.Now}}" |
| + data-blackout="{{if .Blackout}}true{{else}}false{{end}}" |
| + {{- if .Token}} data-remote-token="{{.Token}}"{{end}} |
| + {{- if .NotesHref}} data-notes="{{.NotesHref}}"{{end}}> |
| +{{if .HasSlide}} |
| + <div slides-stage>{{.Slide}}</div> |
| + <p slides-blackout{{if not .Blackout}} hidden{{end}}>{{.BlackoutLabel}}</p> |
| + <div slides-bar> |
| + {{if .Controls}}{{template "present-controls" .Controls}}{{end}} |
| + {{if .PrevHref}}<a rst-btn href="{{.PrevHref}}" rel="prev">{{.PrevLabel}}</a>{{end}} |
| + {{if .NextHref}}<a rst-btn href="{{.NextHref}}" rel="next">{{.NextLabel}}</a>{{end}} |
| + {{/* "3 / 12" alone is terse read aloud, so the counter carries what |
| + the number means in a line only a screen reader gets. The bar |
| + beside it is the same fact as a picture, and is hidden from |
| + assistive tech rather than announced twice. */}} |
| + <p slides-counter><span class="rst-sr-only">{{.ProgressLabel}}</span> {{.Counter}}</p> |
| + <progress slides-progress value="{{.Now}}" max="{{.Total}}" aria-hidden="true"></progress> |
| + {{if .NotesHref}}<a rst-btn href="{{.NotesHref}}">{{.NotesLabel}}</a>{{end}} |
| + {{if .BackHref}}<a rst-btn href="{{.BackHref}}">{{.BackLabel}}</a>{{end}} |
| + </div> |
| +{{else}} |
| + <div rst-box> |
| + {{template "empty-state" dict "Title" .EmptyTitle "Body" .EmptyBody}} |
| + </div> |
| +{{end}} |
| +</div> |
| +{{end}} |
diff --git a/internal/slides/templates/present_controls.html b/internal/slides/templates/present_controls.html| new file mode 100644 |
| index 0000000..fa1a8ad |
| --- /dev/null |
| +++ b/internal/slides/templates/present_controls.html |
| @@ -0,0 +1,32 @@ |
| +{{/* present-controls — the command buttons, shared by the presenter |
| + view, the phone remote and the presenter's own fullscreen surface. |
| + |
| + Every button is its own <form method="post">, and that is the |
| + whole point: with scripts off the remote still drives the talk |
| + (spec §8), because a form and a redirect need nothing but a |
| + browser. static/present.js posts the same fields with fetch when |
| + it is there, so the two paths send identical requests and there is |
| + no second protocol to keep in step. |
| + |
| + The capability rides as a hidden field rather than living in the |
| + form's action: an action carrying a token would put it in the |
| + address bar of every page the redirect lands on. |
| + |
| + Keys (the whole `controls` struct, presentation.go): |
| + Action string — /p/{session}/command |
| + Token string — the remote capability |
| + Buttons []{Op, Label, Slide} — one form each; Slide only for a |
| + goto, which nothing renders yet and which the handler |
| + accepts so a native remote has it. */}} |
| +{{define "present-controls"}} |
| +{{- range .Buttons}} |
| +<form method="post" action="{{$.Action}}"> |
| + <input type="hidden" name="op" value="{{.Op}}"> |
| + {{- if .Slide}} |
| + <input type="hidden" name="slide" value="{{.Slide}}"> |
| + {{- end}} |
| + <input type="hidden" name="t" value="{{$.Token}}"> |
| + <button rst-btn type="submit" slides-op="{{.Op}}">{{.Label}}</button> |
| +</form> |
| +{{- end}} |
| +{{end}} |
diff --git a/internal/slides/templates/present_notes.html b/internal/slides/templates/present_notes.html| new file mode 100644 |
| index 0000000..528e756 |
| --- /dev/null |
| +++ b/internal/slides/templates/present_notes.html |
| @@ -0,0 +1,142 @@ |
| +{{/* present_notes — GET /p/{session}/notes, the presenter view. |
| + |
| + The screen somebody actually presents from: what the room is |
| + looking at, what is coming next, the notes for the slide that is |
| + up, a clock, and the four controls. Meant for a second window on a |
| + laptop or for the presenter's own phone. |
| + |
| + The pairing block appears exactly once — on the one request that |
| + carries the remote capability, and only for the person presenting |
| + (presentation.go says why). Coming back to this URL later shows |
| + the talk and no secret, because there is no secret left to show: |
| + only the hash was stored. */}} |
| +{{define "title"}}{{.Title}}{{end}} |
| +{{define "page-head"}}<link rel="stylesheet" href="{{asset "static/slides.css"}}"> |
| +<script defer src="{{asset "static/present.js"}}"></script> |
| +<style> |
| + /* Two slides side by side above the notes, and one column the |
| + moment there is not room for two — the notes are the part being |
| + read, so they get the width. */ |
| + [slides-preview] { |
| + display: grid; |
| + gap: var(--rst-sp-4); |
| + grid-template-columns: 2fr 1fr; |
| + } |
| + @media (max-width: 60rem) { |
| + [slides-preview] { |
| + grid-template-columns: 1fr; |
| + } |
| + } |
| + [slides-clock] { |
| + align-items: baseline; |
| + display: flex; |
| + flex-wrap: wrap; |
| + gap: var(--rst-sp-3); |
| + } |
| + [slides-clock-now] { |
| + font-size: var(--rst-text-2xl); |
| + font-variant-numeric: tabular-nums; |
| + margin: 0; |
| + } |
| + [slides-clock] p { |
| + color: var(--rst-text-muted); |
| + margin: 0; |
| + } |
| + [slides-commands] { |
| + display: flex; |
| + flex-wrap: wrap; |
| + gap: var(--rst-sp-2); |
| + } |
| + [slides-commands] form { |
| + margin: 0; |
| + } |
| + [slides-commands] button { |
| + min-height: 44px; |
| + } |
| + [slides-tools] { |
| + display: flex; |
| + flex-wrap: wrap; |
| + gap: var(--rst-sp-2); |
| + } |
| + [slides-code] { |
| + font-size: var(--rst-text-2xl); |
| + letter-spacing: 0.25em; |
| + margin: 0; |
| + } |
| + [slides-pair] { |
| + display: flex; |
| + flex-wrap: wrap; |
| + gap: var(--rst-sp-4); |
| + } |
| + [slides-pair-fields] { |
| + flex: 1 1 18rem; |
| + } |
| +</style>{{end}} |
| +{{define "content"}} |
| +{{template "page-header" dict "Title" .Title "Sub" .DeckTitle}} |
| + |
| +<div slides-clock> |
| + <p slides-clock-now data-clock>{{.Clock}}</p> |
| + <p>{{.Started}}</p> |
| + <p data-elapsed-since="{{.StartedAt}}" data-elapsed-template="{{.ElapsedTemplate}}">{{.Elapsed}}</p> |
| + <p slides-counter>{{.Counter}}</p> |
| +</div> |
| + |
| +{{if .Blackout}} |
| +{{/* State in words, never in colour alone: the presenter's own screen |
| + is the one place that has to say the room is looking at nothing. */}} |
| +{{template "status-pill" dict "Tone" "neutral" "Label" .BlackoutLabel}} |
| +{{end}} |
| + |
| +{{if .Controls}} |
| +<div slides-commands>{{template "present-controls" .Controls}}</div> |
| +{{end}} |
| + |
| +{{/* The script's root: the session it is watching, and — only when |
| + this request carried it — the capability its keyboard sends with. |
| + A presenter view opened without the token watches and cannot |
| + drive, which is exactly what the buttons above do too. */}} |
| +<div slides-preview data-session="{{.Session}}" |
| + data-blackout="{{if .Blackout}}true{{else}}false{{end}}" |
| + {{- if .Token}} data-remote-token="{{.Token}}"{{end}}> |
| + <div> |
| + <h2>{{.CurrentLabel}}</h2> |
| + {{.Current}} |
| + </div> |
| + <div> |
| + <h2>{{.NextLabel}}</h2> |
| + {{if .HasNext}}{{.Next}}{{end}} |
| + </div> |
| +</div> |
| + |
| +<div rst-box> |
| + <h2>{{.NotesLabel}}</h2> |
| + {{if .Notes}}{{.Notes}}{{else}}<p>{{.NoNotes}}</p>{{end}} |
| +</div> |
| + |
| +{{if .Pair}} |
| +<div rst-box> |
| + {{template "callout" dict "Tone" "positive" "Title" .PairTitle "Body" .PairBody}} |
| + <div slides-pair> |
| + {{.QR}} |
| + <div slides-pair-fields> |
| + <p slides-code>{{.Code}}</p> |
| + {{/* Hand-written for one attribute, share.html's reason: readonly |
| + is not in field-text's contract, and the field is here to be |
| + selected and copied. */}} |
| + <div rst-field> |
| + <label rst-field-label for="remote-url">{{.URLLabel}}</label> |
| + <input rst-input id="remote-url" name="remote-url" type="text" |
| + value="{{.RemoteURL}}" readonly aria-describedby="remote-url-hint"> |
| + <small rst-field-hint id="remote-url-hint">{{.CodeLabel}} {{.Code}}</small> |
| + </div> |
| + </div> |
| + </div> |
| +</div> |
| +{{end}} |
| + |
| +<nav slides-tools aria-label="{{.ToolsLabel}}"> |
| + {{if .StageHref}}<a rst-btn href="{{.StageHref}}">{{.StageLabel}}</a>{{end}} |
| + <a rst-btn href="{{.DeckHref}}">{{.DeckLabel}}</a> |
| +</nav> |
| +{{end}} |
diff --git a/internal/slides/templates/remote.html b/internal/slides/templates/remote.html| new file mode 100644 |
| index 0000000..0b2ac5b |
| --- /dev/null |
| +++ b/internal/slides/templates/remote.html |
| @@ -0,0 +1,78 @@ |
| +{{/* remote — GET /p/{session}/remote?t=, the phone in the presenter's |
| + hand. |
| + |
| + Four buttons, each its own form, each big enough to hit without |
| + looking down (spec §3.4, §8). Above them: the slide the room is |
| + looking at, the one coming next, and the notes for the one that is |
| + up — rendered at the phone viewport, because that is the viewport |
| + this page is on. |
| + |
| + The script makes it feel live (a swipe, and the slide updating |
| + when somebody else moves it). Nothing here needs it: with scripts |
| + off each button posts and comes straight back. */}} |
| +{{define "title"}}{{.Title}}{{end}} |
| +{{define "page-head"}}<link rel="stylesheet" href="{{asset "static/slides.css"}}"> |
| +<script defer src="{{asset "static/present.js"}}"></script> |
| +<style> |
| + /* The buttons are the screen. 56px is well past the 24 by 24 floor |
| + because this one is pressed in the dark, mid-sentence, by somebody |
| + looking at a room rather than at their hand. */ |
| + [slides-remote-commands] { |
| + display: grid; |
| + gap: var(--rst-sp-3); |
| + grid-template-columns: 1fr 1fr; |
| + } |
| + [slides-remote-commands] form { |
| + margin: 0; |
| + } |
| + [slides-remote-commands] button { |
| + inline-size: 100%; |
| + min-height: 56px; |
| + } |
| + [slides-remote-preview] { |
| + display: grid; |
| + gap: var(--rst-sp-3); |
| + grid-template-columns: 2fr 1fr; |
| + } |
| + [slides-tools] { |
| + display: flex; |
| + flex-wrap: wrap; |
| + gap: var(--rst-sp-2); |
| + } |
| + [slides-counter] { |
| + color: var(--rst-text-muted); |
| + margin: 0; |
| + } |
| +</style>{{end}} |
| +{{define "content"}} |
| +{{template "page-header" dict "Title" .Title "Sub" .DeckTitle}} |
| + |
| +<div slides-remote data-session="{{.Session}}" data-remote-token="{{.Controls.Token}}" |
| + data-blackout="{{if .Blackout}}true{{else}}false{{end}}"> |
| + <div slides-remote-preview> |
| + <div> |
| + <h2>{{.CurrentLabel}}</h2> |
| + {{.Current}} |
| + </div> |
| + <div> |
| + <h2>{{.NextLabel}}</h2> |
| + {{if .HasNext}}{{.Next}}{{end}} |
| + </div> |
| + </div> |
| + <p slides-counter>{{.Counter}}</p> |
| + {{if .Blackout}} |
| + {{template "status-pill" dict "Tone" "neutral" "Label" .BlackoutLabel}} |
| + {{end}} |
| + |
| + <div slides-remote-commands>{{template "present-controls" .Controls}}</div> |
| + |
| + <div rst-box> |
| + <h2>{{.NotesLabel}}</h2> |
| + {{if .Notes}}{{.Notes}}{{else}}<p>{{.NoNotes}}</p>{{end}} |
| + </div> |
| + |
| + <nav slides-tools aria-label="{{.ToolsLabel}}"> |
| + <a rst-btn href="{{.PresenterHref}}">{{.PresenterLabel}}</a> |
| + </nav> |
| +</div> |
| +{{end}} |
diff --git a/internal/slides/templates/remote_pair.html b/internal/slides/templates/remote_pair.html| new file mode 100644 |
| index 0000000..eb59e5a |
| --- /dev/null |
| +++ b/internal/slides/templates/remote_pair.html |
| @@ -0,0 +1,26 @@ |
| +{{/* remote_pair — GET /r/{code}, pairing a phone by hand. |
| + |
| + The QR is the shortcut, never the only route: it carries the code |
| + and the key together, and this page is what somebody gets when |
| + they type the address instead of scanning, or when the key they |
| + pasted does not match. |
| + |
| + A GET form on purpose. Pairing changes nothing on the server — it |
| + resolves a code and a key to a URL — so the answer is a redirect |
| + to the remote, and a person who bookmarks that has bookmarked |
| + their remote rather than a form submission they cannot repeat. */}} |
| +{{define "title"}}{{.Title}}{{end}} |
| +{{define "content"}} |
| +{{template "page-header" dict "Title" .Title "Sub" .Body}} |
| +<div rst-box> |
| + <form rst-form method="get" action="{{.Action}}"> |
| + {{template "field-text" dict |
| + "Name" "code" "Label" .CodeLabel "Value" .Code |
| + "Hint" .CodeHint "Required" true "Primary" true}} |
| + {{template "field-text" dict |
| + "Name" "t" "Label" .TokenLabel "Hint" .TokenHint |
| + "Required" true "Error" .Error}} |
| + {{template "form-foot" dict "Submit" .Submit}} |
| + </form> |
| +</div> |
| +{{end}} |
diff --git a/internal/slides/templates/viewer.html b/internal/slides/templates/viewer.html| index f65d5b8..911b87a 100644 |
| --- a/internal/slides/templates/viewer.html |
| +++ b/internal/slides/templates/viewer.html |
| @@ -85,18 +85,25 @@ |
| <div slides-viewer data-vp="{{.VP}}"> |
| {{if .BackHref}}{{template "back-nav" dict "Href" .BackHref "Label" .BackLabel}}{{end}} |
| {{template "page-header" dict "Title" .Title}} |
| -{{if or .SettingsHref .ShareHref .NotesHref .PresentHref .PresentLabel}} |
| +{{if or .SettingsHref .ShareHref .NotesHref .PresentHref .PresentAction .FollowHref}} |
| <nav slides-tools aria-label="{{.ToolsLabel}}"> |
| {{if .SettingsHref}}<a rst-btn href="{{.SettingsHref}}">{{.SettingsLabel}}</a>{{end}} |
| {{if .ShareHref}}<a rst-btn href="{{.ShareHref}}">{{.ShareLabel}}</a>{{end}} |
| {{if .NotesHref}}<a rst-btn href="{{.NotesHref}}">{{.NotesPageLabel}}</a>{{end}} |
| - {{/* Present is a real link over a share link (spec §5: the same |
| - capability with ?present=1) and a placeholder on the signed-in |
| - screens until task 6 builds the live surface. Rendered as a |
| - disabled span rather than hidden, so the button a person will |
| - look for is where it is going to be. */}} |
| - {{if .PresentHref}}<a rst-btn href="{{.PresentHref}}">{{.PresentLabel}}</a> |
| - {{else if .PresentLabel}}<span rst-btn aria-disabled="true">{{.PresentLabel}}</span>{{end}} |
| + {{/* Present is a FORM on the signed-in screens, because pressing it |
| + writes: it opens a session and mints the remote's capability |
| + (presentation.go). Over a share link it stays a link to the same |
| + capability with ?present=1 (spec §5) — a reader may present the |
| + deck on their own screen, and may not start a session on |
| + somebody else's deck. */}} |
| + {{if .PresentAction}} |
| + <form method="post" action="{{.PresentAction}}"> |
| + <button rst-btn="primary" type="submit">{{.PresentLabel}}</button> |
| + </form> |
| + {{else if .PresentHref}}<a rst-btn href="{{.PresentHref}}">{{.PresentLabel}}</a>{{end}} |
| + {{/* Somebody is presenting this deck right now, and this is the |
| + offer to watch along (spec §3.4). Only ever on a share link. */}} |
| + {{if .FollowHref}}<a rst-btn="primary" href="{{.FollowHref}}">{{.FollowLabel}}</a>{{end}} |
| </nav> |
| {{end}} |
diff --git a/internal/slides/viewer.go b/internal/slides/viewer.go| index d646bc5..2549383 100644 |
| --- a/internal/slides/viewer.go |
| +++ b/internal/slides/viewer.go |
| @@ -94,8 +94,18 @@ type viewerPage struct { |
| ShareLabel string |
| NotesHref string |
| NotesPageLabel string |
| - PresentHref string |
| - PresentLabel string |
| + // PresentAction is the form that opens a presentation session |
| + // (presentation.go). A form and not a link, because pressing it |
| + // WRITES: it mints a session and a remote capability. |
| + PresentAction string |
| + PresentHref string |
| + PresentLabel string |
| + |
| + // FollowHref is offered on a share link while somebody is |
| + // presenting this deck — spec §3.4's "follow the presenter". Empty |
| + // the rest of the time, which is nearly always. |
| + FollowHref string |
| + FollowLabel string |
| BackHref string |
| BackLabel string |
| @@ -176,11 +186,24 @@ func (a *app) renderViewer(w http.ResponseWriter, r *http.Request, d Deck, mode, |
| page.ShareLabel = rastrillo.T(r, "slides.viewer.share") |
| page.NotesHref = base + "/notes" |
| page.NotesPageLabel = rastrillo.T(r, "slides.viewer.notes.page") |
| + page.PresentAction = base + "/present" |
| page.PresentLabel = rastrillo.T(r, "slides.viewer.present") |
| case modeMember: |
| page.NotesHref = base + "/notes" |
| page.NotesPageLabel = rastrillo.T(r, "slides.viewer.notes.page") |
| + // A member presents too: reading a deck to a room is reading |
| + // it (presentation.go's startPresenting). |
| + page.PresentAction = base + "/present" |
| page.PresentLabel = rastrillo.T(r, "slides.viewer.present") |
| + case modeLink: |
| + // Spec §3.4: a share link opened while somebody is presenting |
| + // offers to follow along. The lookup is here and only here, |
| + // because this is the one mode that can be reached by an |
| + // audience — a signed-in reader has the deck's own screens. |
| + if _, live := a.liveSessionFor(r, d.ID); live { |
| + page.FollowHref = base + "?follow=1" |
| + page.FollowLabel = rastrillo.T(r, "slides.follow.offer") |
| + } |
| } |
| if len(view.Slides) == 0 { |
diff --git a/internal/slidestest/present_test.go b/internal/slidestest/present_test.go| new file mode 100644 |
| index 0000000..fe8013d |
| --- /dev/null |
| +++ b/internal/slidestest/present_test.go |
| @@ -0,0 +1,485 @@ |
| +package slidestest |
| + |
| +import ( |
| + "bufio" |
| + "context" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "regexp" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// presentLocation is what POST /d/{sqid}/present redirects to: the |
| +// presenter view for the new session, carrying the remote's capability |
| +// once. Both halves are captured because every later request in these |
| +// tests is one or the other — the session sqid is the address, and the |
| +// token is the permission. |
| +var presentLocation = regexp.MustCompile( |
| + `^/p/([23456789abcdefghjkmnpqrstuvwxyz]{10})/notes\?t=([A-Za-z0-9_-]{43})$`) |
| + |
| +// pairingCode finds the six-character code on the presenter view. The |
| +// alphabet is the unambiguous one (no O, no 0, no I, no 1), because a |
| +// code is read aloud and typed on a phone. |
| +// |
| +// Anchored to the element that displays it rather than loose in the |
| +// page: the remote token on the same page is base64url, and a run of |
| +// six of ITS characters inside this alphabet is a coin flip away — |
| +// an unanchored pattern would find the token about half the time. |
| +var pairingCode = regexp.MustCompile( |
| + `slides-code>([ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6})<`) |
| + |
| +// codeOn reads the pairing code off a presenter view. |
| +func codeOn(t *testing.T, page string) string { |
| + t.Helper() |
| + m := pairingCode.FindStringSubmatch(page) |
| + if m == nil { |
| + t.Fatalf("no pairing code on the presenter view:\n%s", page) |
| + } |
| + return m[1] |
| +} |
| + |
| +// startPresenting presses Present on a deck and returns the session's |
| +// sqid and the remote token it was shown once. |
| +func startPresenting(t *testing.T, a *testApp, deckPath string, cookies []*http.Cookie) (string, string) { |
| + t.Helper() |
| + rec := post(t, a, deckPath+"/present", nil, cookies...) |
| + if rec.Code != http.StatusSeeOther { |
| + t.Fatalf("POST %s/present: status %d, want 303\n%s", deckPath, rec.Code, rec.Body) |
| + } |
| + loc := rec.Header().Get("Location") |
| + m := presentLocation.FindStringSubmatch(loc) |
| + if m == nil { |
| + t.Fatalf("POST %s/present: Location = %q, want /p/{session}/notes?t={token}", deckPath, loc) |
| + } |
| + return m[1], m[2] |
| +} |
| + |
| +// command posts one remote command as the phone does: a form, with the |
| +// capability as a field. referer is sent when it is not empty, because |
| +// where the answer goes — 303 back, or 204 to a caller with no page to |
| +// return to — is part of what these tests are about. |
| +func command(t *testing.T, a *testApp, session, op, token, referer string, extra url.Values) *httptest.ResponseRecorder { |
| + t.Helper() |
| + form := url.Values{"op": {op}, "t": {token}} |
| + for k, vs := range extra { |
| + form[k] = vs |
| + } |
| + req := httptest.NewRequest(http.MethodPost, "/p/"+session+"/command", |
| + strings.NewReader(form.Encode())) |
| + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + req.Header.Set("Origin", testOrigin) |
| + req.Header.Set("Sec-Fetch-Site", "same-origin") |
| + if referer != "" { |
| + req.Header.Set("Referer", referer) |
| + } |
| + rec := httptest.NewRecorder() |
| + a.ServeHTTP(rec, req) |
| + return rec |
| +} |
| + |
| +// TestPresentCreatesSessionWithCodeAndRemoteUrl: the Present button is |
| +// a form, pressing it opens a session, and the page it lands on shows |
| +// the pairing code, the remote's address and its QR — once, to the |
| +// presenter, and to nobody else. |
| +func TestPresentCreatesSessionWithCodeAndRemoteUrl(t *testing.T) { |
| + a := newApp(t) |
| + cookies := signedIn(t, a, allowedAddress) |
| + path := sampleDeck(t, a, cookies) |
| + |
| + viewer := mustGet(t, a, path, http.StatusOK, cookies).Body.String() |
| + if !strings.Contains(viewer, `action="`+path+`/present"`) { |
| + t.Errorf("the viewer offers no Present form:\n%s", viewer) |
| + } |
| + |
| + session, token := startPresenting(t, a, path, cookies) |
| + |
| + notes := mustGet(t, a, "/p/"+session+"/notes?t="+token, http.StatusOK, cookies).Body.String() |
| + code := codeOn(t, notes) |
| + remoteURL := testOrigin + "/r/" + code + "?t=" + token |
| + if !strings.Contains(notes, remoteURL) { |
| + t.Errorf("the presenter view does not show the remote address %q:\n%s", remoteURL, notes) |
| + } |
| + if !strings.Contains(notes, "slides-qr") || !strings.Contains(notes, code+`"`) { |
| + t.Errorf("the presenter view carries no QR naming the code:\n%s", notes) |
| + } |
| + // The presenter view is the presenter's own screen, so it carries |
| + // the notes the projector must not. |
| + if !strings.Contains(notes, "Speaker notes") && !strings.Contains(notes, "Presenter view") { |
| + t.Errorf("the presenter view does not name itself:\n%s", notes) |
| + } |
| + |
| + // Without the capability in the URL there is nothing to copy: the |
| + // pairing block is shown once, at the moment of pairing. |
| + again := mustGet(t, a, "/p/"+session+"/notes", http.StatusOK, cookies).Body.String() |
| + if strings.Contains(again, token) { |
| + t.Errorf("coming back to the presenter view shows the remote token again:\n%s", again) |
| + } |
| + if strings.Contains(again, "slides-qr") { |
| + t.Errorf("coming back to the presenter view shows the QR again:\n%s", again) |
| + } |
| + |
| + // The phone holds the capability and not a session: it reads the |
| + // notes and never the pairing block. |
| + phone := mustGet(t, a, "/p/"+session+"/notes?t="+token, http.StatusOK, nil).Body.String() |
| + if !strings.Contains(phone, "Speaker notes") && !strings.Contains(phone, "Presenter view") { |
| + t.Errorf("the token holder cannot read the presenter view:\n%s", phone) |
| + } |
| + if strings.Contains(phone, remoteURL) { |
| + t.Errorf("the token holder is shown the pairing block:\n%s", phone) |
| + } |
| + |
| + // A stranger with neither gets nothing. |
| + mustGet(t, a, "/p/"+session+"/notes", http.StatusNotFound, nil) |
| + |
| + // Pressing Present again reuses the same session rather than |
| + // opening a second one on the same deck for the same person. |
| + second, _ := startPresenting(t, a, path, cookies) |
| + if second != session { |
| + t.Errorf("pressing Present twice opened a second session: %q then %q", session, second) |
| + } |
| + |
| + // The fullscreen surface is the sqid's own capability: anybody who |
| + // has the address can watch, with no token and no session. |
| + surface := mustGet(t, a, "/p/"+session, http.StatusOK, nil).Body.String() |
| + if !strings.Contains(surface, "<section slides-slide") { |
| + t.Errorf("the fullscreen surface renders no slide:\n%s", surface) |
| + } |
| + if !strings.Contains(surface, "1 / 8") { |
| + t.Errorf("the fullscreen surface does not say where in the deck it is:\n%s", surface) |
| + } |
| + if !strings.Contains(surface, "<progress") { |
| + t.Errorf("the fullscreen surface has no progress element:\n%s", surface) |
| + } |
| + if strings.Contains(surface, "Speaker notes") { |
| + t.Errorf("the projector surface carries the speaker notes:\n%s", surface) |
| + } |
| +} |
| + |
| +// TestRemoteCommandAdvancesSlideAndPublishes: a phone posts "next" and |
| +// every page watching the session hears about it — which is the whole |
| +// mechanism, so it is asserted through the real stream rather than |
| +// through the row. |
| +func TestRemoteCommandAdvancesSlideAndPublishes(t *testing.T) { |
| + a := newApp(t) |
| + cookies := signedIn(t, a, allowedAddress) |
| + path := sampleDeck(t, a, cookies) |
| + session, token := startPresenting(t, a, path, cookies) |
| + |
| + srv := httptest.NewServer(a) |
| + defer srv.Close() |
| + |
| + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
| + defer cancel() |
| + req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/p/"+session+"/stream", nil) |
| + if err != nil { |
| + t.Fatalf("build the stream request: %v", err) |
| + } |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatalf("open the stream: %v", err) |
| + } |
| + defer resp.Body.Close() |
| + if got := resp.Header.Get("Content-Type"); got != "text/event-stream" { |
| + t.Fatalf("the stream's Content-Type is %q, want text/event-stream", got) |
| + } |
| + events := bufio.NewReader(resp.Body) |
| + |
| + // One event on connect, so a page that opened the stream after the |
| + // deck moved is right rather than merely hopeful. |
| + if data := nextSlideEvent(t, events); !strings.Contains(data, `"slide":1`) { |
| + t.Errorf("the connect event is %q, want slide 1", data) |
| + } |
| + |
| + rec := command(t, a, session, "next", token, "", nil) |
| + if rec.Code != http.StatusNoContent { |
| + t.Fatalf("POST command with no page to return to: status %d, want 204\n%s", rec.Code, rec.Body) |
| + } |
| + if data := nextSlideEvent(t, events); !strings.Contains(data, `"slide":2`) { |
| + t.Errorf("after next the event is %q, want slide 2", data) |
| + } |
| + |
| + // A browser form submission goes back where it came from. |
| + back := testOrigin + "/p/" + session + "/remote?t=" + token |
| + rec = command(t, a, session, "next", token, back, nil) |
| + if rec.Code != http.StatusSeeOther { |
| + t.Fatalf("POST command from a page: status %d, want 303\n%s", rec.Code, rec.Body) |
| + } |
| + if loc := rec.Header().Get("Location"); loc != back { |
| + t.Errorf("the command redirected to %q, want %q", loc, back) |
| + } |
| + if data := nextSlideEvent(t, events); !strings.Contains(data, `"slide":3`) { |
| + t.Errorf("after a second next the event is %q, want slide 3", data) |
| + } |
| + |
| + // Blackout is its own state, and it does not move the deck. |
| + rec = command(t, a, session, "blackout", token, "", nil) |
| + if rec.Code != http.StatusNoContent { |
| + t.Fatalf("POST blackout: status %d, want 204\n%s", rec.Code, rec.Body) |
| + } |
| + data := nextSlideEvent(t, events) |
| + if !strings.Contains(data, `"slide":3`) || !strings.Contains(data, `"blackout":true`) { |
| + t.Errorf("after blackout the event is %q, want slide 3 blacked out", data) |
| + } |
| + |
| + // goto names a slide; prev walks back; neither runs off an end. |
| + command(t, a, session, "goto", token, "", url.Values{"slide": {"8"}}) |
| + if data := nextSlideEvent(t, events); !strings.Contains(data, `"slide":8`) { |
| + t.Errorf("after goto 8 the event is %q, want slide 8", data) |
| + } |
| + command(t, a, session, "next", token, "", nil) |
| + if data := nextSlideEvent(t, events); !strings.Contains(data, `"slide":8`) { |
| + t.Errorf("next past the last slide moved to %q, want to stay on slide 8", data) |
| + } |
| + |
| + // And the surface everyone is watching really shows it. |
| + surface := mustGet(t, a, "/p/"+session, http.StatusOK, nil).Body.String() |
| + if !strings.Contains(surface, "8 / 8") { |
| + t.Errorf("the surface does not show the session's own slide:\n%s", surface) |
| + } |
| + if !strings.Contains(surface, `data-blackout="true"`) { |
| + t.Errorf("the surface does not carry the blackout state:\n%s", surface) |
| + } |
| + if !strings.Contains(surface, "blacked out") { |
| + t.Errorf("the blackout is carried by colour alone, with no words:\n%s", surface) |
| + } |
| + |
| + // The fragment is the same slide with no page around it — what the |
| + // script swaps in, and never a nested layout. |
| + fragment := mustGet(t, a, "/p/"+session+"?slide=2&fragment=1", http.StatusOK, nil).Body.String() |
| + if !strings.HasPrefix(strings.TrimSpace(fragment), "<section slides-slide") { |
| + t.Errorf("the fragment is not a bare slide:\n%s", fragment) |
| + } |
| + if strings.Contains(fragment, "<html") { |
| + t.Errorf("the fragment carries the whole page:\n%s", fragment) |
| + } |
| +} |
| + |
| +// nextSlideEvent reads events until a `slide` one arrives, returning |
| +// its data line. Pings are skipped; the request's own context is the |
| +// timeout, so a stream that never speaks fails the test rather than |
| +// hanging it. |
| +func nextSlideEvent(t *testing.T, r *bufio.Reader) string { |
| + t.Helper() |
| + var event string |
| + for { |
| + line, err := r.ReadString('\n') |
| + if err != nil { |
| + t.Fatalf("reading the stream: %v", err) |
| + } |
| + line = strings.TrimRight(line, "\r\n") |
| + switch { |
| + case strings.HasPrefix(line, "event: "): |
| + event = strings.TrimPrefix(line, "event: ") |
| + case strings.HasPrefix(line, "data: "): |
| + if event == "slide" { |
| + return strings.TrimPrefix(line, "data: ") |
| + } |
| + } |
| + } |
| +} |
| + |
| +// TestWrongRemoteTokenIs404: the token is the whole permission to move |
| +// somebody else's deck, and a wrong one is not a 403 — a 403 would |
| +// confirm the session is real to whoever is guessing. |
| +func TestWrongRemoteTokenIs404(t *testing.T) { |
| + a := newApp(t) |
| + cookies := signedIn(t, a, allowedAddress) |
| + path := sampleDeck(t, a, cookies) |
| + session, token := startPresenting(t, a, path, cookies) |
| + |
| + wrong := strings.Repeat("a", len(token)) |
| + if rec := command(t, a, session, "next", wrong, "", nil); rec.Code != http.StatusNotFound { |
| + t.Errorf("a command with the wrong token: status %d, want 404", rec.Code) |
| + } |
| + if rec := command(t, a, session, "next", "", "", nil); rec.Code != http.StatusNotFound { |
| + t.Errorf("a command with no token: status %d, want 404", rec.Code) |
| + } |
| + mustGet(t, a, "/p/"+session+"/remote?t="+wrong, http.StatusNotFound, nil) |
| + mustGet(t, a, "/p/"+session+"/remote", http.StatusNotFound, nil) |
| + // Signed in as the presenter is not the same as holding the remote: |
| + // the capability is what moves the deck. |
| + if rec := command(t, a, session, "next", wrong, "", nil); rec.Code != http.StatusNotFound { |
| + t.Errorf("a command with the wrong token from the presenter: status %d, want 404", rec.Code) |
| + } |
| + |
| + // And nothing moved. |
| + surface := mustGet(t, a, "/p/"+session, http.StatusOK, nil).Body.String() |
| + if !strings.Contains(surface, "1 / 8") { |
| + t.Errorf("a refused command moved the deck anyway:\n%s", surface) |
| + } |
| + |
| + // An unknown session is a 404 too, with the right token or without. |
| + mustGet(t, a, "/p/2222222222", http.StatusNotFound, nil) |
| + if rec := command(t, a, "2222222222", "next", token, "", nil); rec.Code != http.StatusNotFound { |
| + t.Errorf("a command against an unknown session: status %d, want 404", rec.Code) |
| + } |
| +} |
| + |
| +// TestEndedSessionIs410: a talk that is over is gone, not missing. The |
| +// distinction is the point — somebody following a link to last week's |
| +// session should be told it finished, and a dead pairing code should |
| +// tell a stranger nothing at all. |
| +func TestEndedSessionIs410(t *testing.T) { |
| + a := newApp(t) |
| + cookies := signedIn(t, a, allowedAddress) |
| + path := sampleDeck(t, a, cookies) |
| + session, token := startPresenting(t, a, path, cookies) |
| + |
| + notes := mustGet(t, a, "/p/"+session+"/notes?t="+token, http.StatusOK, cookies).Body.String() |
| + code := codeOn(t, notes) |
| + mustGet(t, a, "/r/"+code+"?t="+token, http.StatusSeeOther, nil) |
| + |
| + if rec := command(t, a, session, "end", token, "", nil); rec.Code != http.StatusNoContent { |
| + t.Fatalf("ending the session: status %d, want 204\n%s", rec.Code, rec.Body) |
| + } |
| + |
| + for _, target := range []string{ |
| + "/p/" + session, |
| + "/p/" + session + "/notes?t=" + token, |
| + "/p/" + session + "/remote?t=" + token, |
| + } { |
| + rec := get(t, a, target, cookies...) |
| + if rec.Code != http.StatusGone { |
| + t.Errorf("GET %s after the session ended: status %d, want 410", target, rec.Code) |
| + } |
| + if !strings.Contains(rec.Body.String(), "over") { |
| + t.Errorf("GET %s says nothing about the session being over:\n%s", target, rec.Body) |
| + } |
| + } |
| + if rec := command(t, a, session, "next", token, "", nil); rec.Code != http.StatusGone { |
| + t.Errorf("a command against an ended session: status %d, want 410", rec.Code) |
| + } |
| + // A dead code is a 404: a gone would tell whoever typed it that the |
| + // code was once real. |
| + mustGet(t, a, "/r/"+code+"?t="+token, http.StatusNotFound, nil) |
| + mustGet(t, a, "/r/"+code, http.StatusNotFound, nil) |
| +} |
| + |
| +// TestScriptlessRemoteButtonsAreForms: the remote is four buttons, and |
| +// with scripts off each one is a form that posts and comes back. This |
| +// is §8's floor for the one screen most likely to be used on a phone |
| +// with something blocking the script. |
| +func TestScriptlessRemoteButtonsAreForms(t *testing.T) { |
| + a := newApp(t) |
| + cookies := signedIn(t, a, allowedAddress) |
| + path := sampleDeck(t, a, cookies) |
| + session, token := startPresenting(t, a, path, cookies) |
| + |
| + remote := mustGet(t, a, "/p/"+session+"/remote?t="+token, http.StatusOK, nil).Body.String() |
| + if n := strings.Count(remote, `<form method="post"`); n != 4 { |
| + t.Errorf("the remote has %d forms, want 4 (previous, next, blackout, end):\n%s", n, remote) |
| + } |
| + for _, op := range []string{"prev", "next", "blackout", "end"} { |
| + if !strings.Contains(remote, `value="`+op+`"`) { |
| + t.Errorf("the remote has no %s button:\n%s", op, remote) |
| + } |
| + } |
| + if !strings.Contains(remote, `action="/p/`+session+`/command"`) { |
| + t.Errorf("the remote's forms do not post commands:\n%s", remote) |
| + } |
| + if !strings.Contains(remote, `value="`+token+`"`) { |
| + t.Errorf("the remote's forms carry no capability:\n%s", remote) |
| + } |
| + // The phone shows what is on screen now and what is coming, so the |
| + // presenter is not reading the room off a projector behind them. |
| + if !strings.Contains(remote, "<section slides-slide") { |
| + t.Errorf("the remote renders no slide:\n%s", remote) |
| + } |
| + |
| + // Blacking out changes what the button says, not only how it looks. |
| + if !strings.Contains(remote, "Black the screen") { |
| + t.Errorf("the remote's blackout button is unlabelled:\n%s", remote) |
| + } |
| + command(t, a, session, "blackout", token, "", nil) |
| + blacked := mustGet(t, a, "/p/"+session+"/remote?t="+token, http.StatusOK, nil).Body.String() |
| + if !strings.Contains(blacked, "Show the screen") { |
| + t.Errorf("the blackout button does not change its words when the screen is black:\n%s", blacked) |
| + } |
| + |
| + // The pair page is the way in for a typed code, and it is a form |
| + // too — the QR is the shortcut, never the only route. |
| + notes := mustGet(t, a, "/p/"+session+"/notes?t="+token, http.StatusOK, cookies).Body.String() |
| + code := codeOn(t, notes) |
| + pair := mustGet(t, a, "/r/"+code, http.StatusOK, nil).Body.String() |
| + if !strings.Contains(pair, "<form") { |
| + t.Errorf("the pair page has no form:\n%s", pair) |
| + } |
| + if !strings.Contains(pair, code) { |
| + t.Errorf("the pair page does not carry the code that reached it:\n%s", pair) |
| + } |
| + // The QR carries both halves, so scanning it lands on the remote. |
| + rec := get(t, a, "/r/"+code+"?t="+token) |
| + if rec.Code != http.StatusSeeOther { |
| + t.Fatalf("GET /r/{code}?t=: status %d, want 303\n%s", rec.Code, rec.Body) |
| + } |
| + if loc := rec.Header().Get("Location"); loc != "/p/"+session+"/remote?t="+token { |
| + t.Errorf("the pair redirect goes to %q, want the remote", loc) |
| + } |
| +} |
| + |
| +// TestFollowOfferedOnlyWhileLive: a share link offers to follow the |
| +// presenter while somebody is presenting, and goes back to being an |
| +// ordinary read-only deck when nobody is. |
| +func TestFollowOfferedOnlyWhileLive(t *testing.T) { |
| + a := newApp(t) |
| + cookies := signedIn(t, a, allowedAddress) |
| + path := sampleDeck(t, a, cookies) |
| + token := mintShareLink(t, a, path, cookies) |
| + share := "/s/" + token |
| + |
| + quiet := mustGet(t, a, share, http.StatusOK, nil).Body.String() |
| + if strings.Contains(quiet, "Follow the presenter") { |
| + t.Errorf("a share link offers to follow with nobody presenting:\n%s", quiet) |
| + } |
| + // Nothing to follow: the flag is not a way to reach a session that |
| + // does not exist. |
| + rec := get(t, a, share+"?follow=1") |
| + if rec.Code != http.StatusSeeOther { |
| + t.Errorf("?follow=1 with nobody presenting: status %d, want 303", rec.Code) |
| + } |
| + |
| + session, remoteToken := startPresenting(t, a, path, cookies) |
| + command(t, a, session, "next", remoteToken, "", nil) |
| + |
| + offered := mustGet(t, a, share, http.StatusOK, nil).Body.String() |
| + if !strings.Contains(offered, "Follow the presenter") { |
| + t.Errorf("a live session is not offered on the share link:\n%s", offered) |
| + } |
| + if !strings.Contains(offered, share+"?follow=1") { |
| + t.Errorf("the follow offer does not link to the follow surface:\n%s", offered) |
| + } |
| + |
| + follow := mustGet(t, a, share+"?follow=1", http.StatusOK, nil).Body.String() |
| + if !strings.Contains(follow, "2 / 8") { |
| + t.Errorf("following does not show the presenter's own slide:\n%s", follow) |
| + } |
| + if !strings.Contains(follow, `http-equiv="refresh"`) { |
| + t.Errorf("the follow surface does not refresh with scripts off:\n%s", follow) |
| + } |
| + if !strings.Contains(follow, share+`"`) { |
| + t.Errorf("the follow surface offers no way back to browsing:\n%s", follow) |
| + } |
| + // Read-only means read-only: an audience cannot move the deck. |
| + if strings.Contains(follow, "<form") { |
| + t.Errorf("the follow surface carries a control:\n%s", follow) |
| + } |
| + if strings.Contains(follow, remoteToken) { |
| + t.Errorf("the follow surface leaks the remote capability:\n%s", follow) |
| + } |
| + // And it never leaks the notes, which is what a share link is not. |
| + if strings.Contains(follow, "Speaker notes") { |
| + t.Errorf("the follow surface carries the speaker notes:\n%s", follow) |
| + } |
| + |
| + command(t, a, session, "end", remoteToken, "", nil) |
| + after := mustGet(t, a, share, http.StatusOK, nil).Body.String() |
| + if strings.Contains(after, "Follow the presenter") { |
| + t.Errorf("the follow offer outlives the session:\n%s", after) |
| + } |
| + if rec := get(t, a, share+"?follow=1"); rec.Code != http.StatusSeeOther { |
| + t.Errorf("following an ended session: status %d, want 303 back to the deck", rec.Code) |
| + } |
| +} |