oficina / slides Public

feat: paged viewer, notes page, read-only share links

GET /d/{sqid}?slide=N is the whole viewer: one slide per page,
previous and next as ordinary links, the counter as visible text, the
speaker notes in a <details> below, and a filmstrip naming every slide
by its own first heading. ?vp=phone renders the phone viewport and
travels with every link on the page, so a preview cannot be paged out
of by accident. ?slide= off the end of the deck is a 404, not a clamp.

GET /d/{sqid}/notes is every slide's notes on one page — the crib
sheet a person presents from with scripts off (spec §8).

Share links are spec §5's read-only capability, calendar's feed
pattern: 32 random bytes as base64url, only the SHA-256 hex stored, the
URL shown once at the moment of minting because there is nothing left
to show afterwards, revocable, with a view count. GET /s/{token} is
mounted outside the session group and answers the same 404 for a token
that never existed and one that was revoked.

The share-link viewer and the signed-in viewer are ONE template with a
mode: the day they become two files is the day one of them leaks the
speaker notes. The two builds differ only in their shell — an account
menu signed in, a sign-in link over a link (templates/anon.html).

layout.html gains a page-head block nested inside head, because head
already has an owner (account.html's account-menu rule) and a page
defining its own would silently drop it. The viewer links slides.css
through it, on the two screens that render a slide and nowhere else.

Replaces the placeholder deck screen (templates/deck.html, decks.go's
showDeck) that task 3 left as a stand-in.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01563XGCgxcSZToEXwuTBsKE
claude agent, operated by paul@keymail.dev pushed by paul@keymail.dev d749e0e3d3a6a85abc0baf664374a6848f3910a5 parent 2b99f42
19 files changed, +1640 −129
  • internal/slides/app.go +17 −0
  • internal/slides/decks.go +7 −80
  • internal/slides/locales/en.toml +60 −1
  • internal/slides/migrations/0003_create_share_links.sql +6 −0
  • internal/slides/migrations/schema.sql +6 −0
  • internal/slides/models.go +36 −0
  • internal/slides/render.go +16 −1
  • internal/slides/share.go +352 −0
  • internal/slides/templates/account.html +4 −2
  • internal/slides/templates/anon.html +16 −0
  • internal/slides/templates/deck.html +0 −41
  • internal/slides/templates/deck_notes.html +27 −0
  • internal/slides/templates/layout.html +11 −2
  • internal/slides/templates/share.html +67 −0
  • internal/slides/templates/viewer.html +135 −0
  • internal/slides/viewer.go +406 −0
  • internal/slidestest/decks_test.go +10 −2
  • internal/slidestest/share_test.go +245 −0
  • internal/slidestest/viewer_test.go +219 −0

Intent

Human

<task-notification> <task-id>aeea7a5d7011183cf</task-id> <tool-use-id>toolu_01W8N14vAcxEkLeZh1phBoFQ</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/2420c44b-a27a-4e0d-bbe7-8b3ca0464c21/tasks/aeea7a5d7011183cf.output</output-file> <status>completed</status> <summary>Agent "Task 4 fix round 1 (CSS)" 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. Commit 2b99f42 "fix: renderer review round 1 — phone reflow resets every template grid". `go test ./internal/slides/render/` passes (19 tests incl. new `TestPhoneBreakpointCollapsesEveryTemplateToOneColumn`, verified it fails without the CSS fix); `make ci`'s render/deck/core/slides packages are green, unrelated `internal/slidestest` failures pre-exist on the other agent's untracked test files (reproduced identically with my changes stashed). Concern: none for my scope — `internal/slidestest` failures should be flagged to the other agent/orchestrator, not mine to fix.</result> <usage><subagent_tokens>80271</subagent_tokens><tool_uses>42</tool_uses><duration_ms>210308</duration_ms></usage> </task-notification>

Human

<task-notification> <task-id>aa415a0c9c0767ef8</task-id> <tool-use-id>toolu_01H9gLfcvHchKeDf18c2jA9D</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/2420c44b-a27a-4e0d-bbe7-8b3ca0464c21/tasks/aa415a0c9c0767ef8.output</output-file> <status>completed</status> <summary>Agent "Re-review Task 4 fix round 1" 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>No `@layer` or `!important` anywhere in the stylesheet, so plain specificity + source order governs the cascade — confirmed the analysis above holds without any layer complications. Test passes. ### Findings - Container-query reset for `two-column` (and every flow template): ADDRESSED — `internal/slides/static/slides.css:514-517` adds `[slides-slide][data-layout="flow"] { grid-template-columns: 1fr; grid-template-areas: none; }` inside `@container slide (max-width: 600px)`. Cascade check: this selector and the desktop offender at `slides.css:173` (`[slides-slide][data-template="two-column"]`) are both two-attribute-selector compound selectors, equal specificity (0,2,0); no `@layer` or `!important` anywhere in the file (verified — zero matches), so the tie is broken by source order alone, and the container-query rule sits later (line 514 vs. 173), so it wins whenever the container query is active. This is correct, not incidental. - Coverage of every flow template: ADDRESSED — verified by reading `slides.css:150-247`: `title` (155), `title-body` (168), `section` (185), `image-full` (204), `quote` (231), `blank` (244) all set only `grid-template-areas`, no `grid-template-columns` override, so they inherit the base single-column `grid-template-columns: 1fr` at line 106. `two-column` (173-178) is the sole template with an explicit `grid-template-columns: 1fr 1fr`. The fix's `[data-layout="flow"]` selector matches all of them (render.go:96-97 normalizes any non-canvas layout to `"flow"`), so one rule covers the whole set rather than a two-column-only patch — matches the brief's audit requirement. - Go text-assertion test with explanatory comment: ADDRESSED — `internal/slides/render/render_test.go:861-957` adds `TestPhoneBreakpointCollapsesEveryTemplateToOneColumn`, with a doc comment (`render_test.go:861-878`) explaining why a Go test must be a text/structural assertion against the stylesheet rather than a rendered check (no CSS grid layout or browser available), and noting its limits (confirms the reset exists, not the rendered result). Ran the focused test directly: `PASS` (`internal/slides/render/render_test.go`, `go test ./internal/slides/render/ -run TestPhoneBreakpoint -v` → PASS, 0.00s). - Minor: comment on unused `rows` table prop: ADDRESSED — `internal/slides/render/render.go:725-726` adds a one-line comment above the `cols :=` read in `tableBlock` explaining that `rows` is never read because row count is derived from `len(cells)` and `cols`. ### New Breakage in Fix Diff (Critical/Important only) None found. The fix is additive and scoped to the phone container block; it doesn't touch the desktop-width template rules, the `[data-block="columns"]` reset, or the `image-full`/`title`/`quote` phone overrides, all of which still apply after it in source order without conflict. ### Out-of-Scope Observations - The fix report's `make ci` run shows pre-existing failures in `internal/slidestest` (untracked `viewer_test.go`), explicitly noted as belonging to the other agent concurrently editing that package — not in scope for this CSS/render fix and outside this review's remit per the task brief. ### Verdict All findings addressed.</result> <usage><subagent_tokens>57059</subagent_tokens><tool_uses>10</tool_uses><duration_ms>53178</duration_ms></usage> </task-notification>

Attributed to this commit: 13.6 g CO₂e (1540428 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/internal/slides/app.go b/internal/slides/app.go
index c86a7db..e0d2037 100644
--- a/internal/slides/app.go
+++ b/internal/slides/app.go
@@ -60,6 +60,14 @@ func New(d *db.DB, cfg Config) (*http.ServeMux, *app, error) {
r.Get("/auth/verify", authPlugin.Verify)
r.Post("/signout", authPlugin.Signout)
+ // The share link, and the one screen this app serves to somebody
+ // with no session at all (spec §5). Outside the group below on
+ // purpose: the token IS the capability, so requiring a session
+ // would defeat the whole feature. It is still inside the chi
+ // router, so csrf.Protect and every other app-wide middleware
+ // covers it.
+ r.Get("/s/{token}", a.sharedDeck)
+
// 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) {
@@ -72,6 +80,15 @@ func New(d *db.DB, cfg Config) (*http.ServeMux, *app, error) {
// /d/{sqid} is the deck's own short URL — short because it is
// what a person pastes into a chat window.
r.Get("/d/{sqid}", a.showDeck)
+ // 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)
+ // 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).
+ r.Get("/d/{sqid}/share", a.deckShare)
+ r.Post("/d/{sqid}/share", a.mintShareLink)
+ r.Post("/d/{sqid}/share/{id}/revoke", a.revokeShareLink)
r.Get("/d/{sqid}/settings", a.deckSettings)
r.Post("/d/{sqid}/settings", a.saveDeckSettings)
// Deleting gets its own URL and its own page, never a modal
diff --git a/internal/slides/decks.go b/internal/slides/decks.go
index 392f36e..014fc15 100644
--- a/internal/slides/decks.go
+++ b/internal/slides/decks.go
@@ -758,86 +758,6 @@ func openingOps(title, theme string) ([]byte, error) {
})
}
-// --- GET /d/{sqid} ---
-
-type blockView struct {
- Type string
- Text string
-}
-
-type slideView struct {
- Heading string
- Layout string
- Template string
- Notes string
- Blocks []blockView
-}
-
-type deckPage struct {
- shell
- Title string
- RungLabel string
- RungTone string
- SettingsHref string
- SettingsLabel string
- NotesLabel string
- BackHref string
- BackLabel string
- Slides []slideView
-}
-
-// showDeck serves GET /d/{sqid}. It is deliberately the plainest thing
-// that shows the document is real: the title, the rung, and one section
-// per slide naming each block's type and its words. Task 5 replaces
-// this whole handler with the renderer and the editor — nothing here is
-// meant to survive it, so nothing here is styled beyond the shell.
-func (a *app) showDeck(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
- }
-
- 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 := deckPage{
- shell: shellFor(r, u),
- Title: view.Meta.Title,
- RungLabel: rungLabel(r, d.Rung),
- RungTone: rungTone(d.Rung),
- NotesLabel: rastrillo.T(r, "slides.deck.notes.label"),
- BackHref: "/",
- BackLabel: rastrillo.T(r, "slides.decks.title"),
- SettingsLabel: rastrillo.T(r, "slides.deck.settings"),
- Slides: make([]slideView, 0, len(view.Slides)),
- }
- if d.OwnerID == u.ID {
- page.SettingsHref = "/d/" + d.Sqid + "/settings"
- }
- for i, s := range view.Slides {
- sv := slideView{
- Heading: rastrillo.Tf(r, "slides.deck.slide", "n", i+1),
- Layout: s.Layout,
- Template: s.Template,
- Notes: s.Notes.Plain(),
- Blocks: make([]blockView, 0, len(s.Blocks)),
- }
- for _, b := range s.Blocks {
- sv.Blocks = append(sv.Blocks, blockView{Type: b.Type, Text: b.Text.Plain()})
- }
- page.Slides = append(page.Slides, sv)
- }
- render(w, "deck", page)
-}
-
// --- GET/POST /d/{sqid}/settings ---
type deckSettingsPage struct {
@@ -1010,6 +930,13 @@ func (a *app) deleteDeck(w http.ResponseWriter, r *http.Request) {
if err := tx.Where("deck_id = ?", d.ID).Delete(&DeckGrant{}).Error; err != nil {
return err
}
+ // The share links go too. A link outliving its deck is a live
+ // URL that can only ever answer 404, and leaving one behind
+ // would mean the one row in this database that grants
+ // unauthenticated read is also the one nothing cleans up.
+ if err := tx.Where("deck_id = ?", d.ID).Delete(&ShareLink{}).Error; err != nil {
+ return err
+ }
return tx.Where("id = ? AND owner_id = ?", d.ID, u.ID).Delete(&Deck{}).Error
})
if err != nil {
diff --git a/internal/slides/locales/en.toml b/internal/slides/locales/en.toml
index 8bbb2ce..67d03b3 100644
--- a/internal/slides/locales/en.toml
+++ b/internal/slides/locales/en.toml
@@ -63,11 +63,14 @@ slides.when.days = "{n} days ago"
slides.when.on = "on {date}"
# slides.deck.* — one deck: its own page, its settings, its deletion.
+#
+# slide is the fallback name for a slide with no heading of its own —
+# what the filmstrip and the notes page call it when there is nothing
+# better to call it (viewer.go's slideLabel).
slides.deck.settings = "Deck settings"
slides.deck.settings.submit = "Save settings"
slides.deck.back = "Back to the deck"
slides.deck.slide = "Slide {n}"
-slides.deck.notes.label = "Notes:"
slides.deck.title.label = "Title"
slides.deck.title.required = "Give the deck a title."
slides.deck.theme.label = "Theme"
@@ -116,3 +119,59 @@ slides.account.signout = "Sign out"
# failure with nowhere styled to land. Still a key, never a literal,
# even though it is plain text today.
slides.error.generic = "Something went wrong."
+
+# slides.viewer.* — GET /d/{sqid} and GET /s/{token}, the paged viewer.
+# One slide per page, and every control on it named in words: the
+# counter is text rather than a position in a scrollbar, and the pager's
+# ends are text rather than a greyed-out shape.
+slides.viewer.counter = "{n} of {total}"
+slides.viewer.prev = "Previous slide"
+slides.viewer.next = "Next slide"
+slides.viewer.pager = "Slides"
+slides.viewer.film = "All slides"
+slides.viewer.tools = "This deck"
+slides.viewer.notes = "Speaker notes"
+slides.viewer.notes.page = "All notes"
+slides.viewer.share = "Share"
+slides.viewer.present = "Present"
+# A deck whose document has nothing in it. Reachable today only after a
+# document was emptied by something other than a screen, which is
+# exactly when a page saying "not found" would read as data loss.
+slides.viewer.empty.title = "No slides yet"
+slides.viewer.empty.body = "This deck has no slides in it. Add one from the editor and it will show up here."
+
+# slides.notes.* — GET /d/{sqid}/notes, every slide's notes on one page.
+slides.notes.title = "Speaker notes"
+slides.notes.body = "Every slide's notes, in order. Print this, or keep it open on a second screen while you present."
+slides.notes.none = "No notes on this slide."
+
+# slides.share.* — GET/POST /d/{sqid}/share, the deck's read-only links.
+#
+# The warning is stated before the button rather than in a help page: a
+# share link is genuinely useful and genuinely means anyone who has it
+# can read the deck, and the person deciding should see both.
+slides.share.title = "Share links"
+slides.share.lead = "A share link opens this deck read-only, with no sign-in. The slides are what it shows — never the speaker notes, and never the settings."
+slides.share.warning.title = "What a share link does"
+slides.share.warning = "Anyone who has the link can open this deck, including anyone it gets forwarded to. It shows the deck as it is now, so later edits are visible through it too. Revoke it and the URL stops working."
+slides.share.create = "Create a share link"
+slides.share.revoke = "Revoke"
+slides.share.active = "Active"
+slides.share.revoked = "Revoked"
+slides.share.empty.title = "No share links"
+slides.share.empty.body = "A link lets somebody read this deck without signing in. Nothing is shared until you make one."
+
+# Shown once, at the moment of minting, because only the link's
+# fingerprint is stored and there is nothing to show a second time.
+slides.share.new.title = "Copy this now — it is not shown again"
+slides.share.new.body = "Only a fingerprint of this link is stored, so it cannot be shown again. If you lose it, revoke it and make another."
+slides.share.new.label = "Share link"
+slides.share.new.copy = "Select the whole field and copy it."
+
+# A link's row: when it was made, and how much it has been used. Three
+# keys for the count because English has three shapes here and the
+# catalog has no plural machinery — the handler picks (share.go).
+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"
diff --git a/internal/slides/migrations/0003_create_share_links.sql b/internal/slides/migrations/0003_create_share_links.sql
new file mode 100644
index 0000000..9826209
--- /dev/null
+++ b/internal/slides/migrations/0003_create_share_links.sql
@@ -0,0 +1,6 @@
+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 UNIQUE INDEX `idx_share_links_token_hash` ON `share_links`(`token_hash`);
+
+CREATE INDEX `idx_share_links_deck_id` ON `share_links`(`deck_id`);
+
diff --git a/internal/slides/migrations/schema.sql b/internal/slides/migrations/schema.sql
index 3796f04..2db2a17 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 `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);
CREATE INDEX `idx_deck_grants_deck_id` ON `deck_grants`(`deck_id`);
@@ -19,5 +21,9 @@ CREATE INDEX `idx_decks_owner_id` ON `decks`(`owner_id`);
CREATE UNIQUE INDEX `idx_decks_sqid` ON `decks`(`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`);
+
CREATE UNIQUE INDEX `idx_users_email` ON `users`(`email`);
diff --git a/internal/slides/models.go b/internal/slides/models.go
index 2f143fe..8e3bb11 100644
--- a/internal/slides/models.go
+++ b/internal/slides/models.go
@@ -12,6 +12,7 @@ var Models = []any{
&Deck{},
&DeckUpdate{},
&DeckGrant{},
+ &ShareLink{},
}
// User is a person who has signed in here — spec §6.1's
@@ -126,3 +127,38 @@ type DeckGrant struct {
Actor string
CreatedAt time.Time
}
+
+// ShareLink is one read-only capability for one deck — spec §5's share
+// link, calendar's feed pattern (its calendar design §6).
+//
+// Only the hash is stored. A plaintext token in this table would be a
+// live, unauthenticated read capability for somebody's whole deck,
+// sitting in a file the platform replicates to object storage. The
+// consequence is that the URL cannot be shown again, so the share page
+// shows it at the moment of minting and says so.
+//
+// TokenHash carries the unique index because it is what a request is
+// resolved by: a single indexed read on /s/{token}, and no two rows
+// can ever claim one URL.
+//
+// Views is the honest part, calendar's reason: a link that has been
+// opened four hundred times is a different thing to hold than one
+// nobody has ever used, and the share page can only say so if the
+// number is kept.
+//
+// RevokedAt rather than a delete, also calendar's: the share page keeps
+// showing that the link existed and that it is off, and a URL still
+// pasted in somebody's chat window stops working rather than silently
+// matching a later link that happened to reuse the row.
+type ShareLink struct {
+ ID int64
+ DeckID int64 `gorm:"index"`
+ // TokenHash is the SHA-256 hex of the token (share.go's
+ // hashShareToken). The token itself is never written anywhere.
+ TokenHash string `gorm:"uniqueIndex"`
+ // Actor is who minted it, in DeckUpdate.Actor's form.
+ Actor string
+ Views int64
+ CreatedAt time.Time
+ RevokedAt *time.Time
+}
diff --git a/internal/slides/render.go b/internal/slides/render.go
index 3c0974e..a5d6f82 100644
--- a/internal/slides/render.go
+++ b/internal/slides/render.go
@@ -55,7 +55,19 @@ var publicPages = []string{"signin", "errors"}
// with templates/account.html parsed after the layout, so the shell's
// account menu is defined once rather than in each page (see that
// file's own comment).
-var signedInPages = []string{"decks", "deck", "deck_new", "deck_settings", "deck_delete"}
+var signedInPages = []string{"decks", "viewer", "deck_notes", "deck_new", "deck_settings", "deck_delete", "share"}
+
+// linkPages are screens rendered from a signed-in page's own template
+// for somebody who is not signed in — today, exactly one: the viewer,
+// served over a share link (GET /s/{token}).
+//
+// The key is what a handler names; the value is the template file. That
+// is the whole point of the map: the viewer is ONE file serving both
+// audiences (viewer.go says why), built twice so the shell around it
+// can differ. templates/anon.html replaces account.html in the second
+// build, because a stranger holding a link has no account to show and
+// an empty account menu is worse than none.
+var linkPages = map[string]string{"viewer_link": "viewer"}
func init() {
for _, name := range publicPages {
@@ -64,6 +76,9 @@ func init() {
for _, name := range signedInPages {
pages[name] = buildPage(name, "templates/account.html")
}
+ for name, file := range linkPages {
+ pages[name] = buildPage(file, "templates/anon.html")
+ }
}
// buildPage parses one page: ui's partials, then the layout, then any
diff --git a/internal/slides/share.go b/internal/slides/share.go
new file mode 100644
index 0000000..72ed19f
--- /dev/null
+++ b/internal/slides/share.go
@@ -0,0 +1,352 @@
+// share.go is the read-only share link: minting one, listing them,
+// revoking one, and the unauthenticated screen a token opens.
+//
+// It is calendar's feed pattern (its calendar design §6, spec §5 here),
+// and the two rules that make it safe are worth stating where they are
+// implemented rather than only in the model's comment:
+//
+// - Only the SHA-256 of the token is stored. The token is 32 bytes of
+// randomness, so there is no dictionary to defend against and no
+// salt to add; what an unsalted hash buys is that resolving a URL
+// is one indexed read.
+// - Because only the hash is stored, the URL can be shown exactly
+// once — at the moment of minting. The page says so there, rather
+// than letting somebody find out by coming back for it.
+//
+// A missing token and a revoked one get the same 404. Anything else
+// would let a stranger with a guess learn that a link had once been
+// real.
+package slides
+
+import (
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "net/http"
+ "strconv"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+ "gorm.io/gorm"
+
+ "amadan.net/rastrillo/rastrillo"
+)
+
+// shareTokenBytes is how much randomness a share link carries. Thirty
+// two bytes is 256 bits, which is not guessable by anything, and
+// base64url makes it 43 characters of URL — long enough that nobody
+// will type it and short enough to paste into a chat window.
+const shareTokenBytes = 32
+
+// newShareToken mints one. base64url without padding, so the token is
+// URL-safe with nothing to escape and nothing to trim.
+func newShareToken() (string, error) {
+ raw := make([]byte, shareTokenBytes)
+ if _, err := rand.Read(raw); err != nil {
+ return "", fmt.Errorf("slides: generate share token: %w", err)
+ }
+ return base64.RawURLEncoding.EncodeToString(raw), nil
+}
+
+// hashShareToken is the stored form: SHA-256 hex, unsalted, for the
+// reason in this file's own comment.
+func hashShareToken(token string) string {
+ sum := sha256.Sum256([]byte(token))
+ return hex.EncodeToString(sum[:])
+}
+
+// shareURL is the whole link as a person will paste it. a.cfg.Origin,
+// not the request's Host: the origin is what the app resolved at boot
+// and refused to start without (config.go), so a link minted behind a
+// proxy that lies about Host is still the URL that works.
+func (a *app) shareURL(token string) string {
+ return a.cfg.Origin + "/s/" + token
+}
+
+// --- GET /d/{sqid}/share ---
+
+type shareRow struct {
+ Created string
+ Views string
+ StatusLabel string
+ StatusTone string
+ RevokeAction string
+ RevokeLabel string
+ Revoked bool
+}
+
+type sharePage struct {
+ shell
+ Title string
+ DeckTitle string
+ Lead string
+ WarningTitle string
+ Warning string
+ BackHref string
+ BackLabel string
+ Action string
+ CreateLabel string
+
+ // NewURL is the link this request minted, shown once and never
+ // again. Empty on every visit that did not just create one.
+ NewURL string
+ NewTitle string
+ NewHint string
+ NewLabel string
+ CopyHint string
+
+ Links []shareRow
+ EmptyTitle string
+ EmptyBody string
+}
+
+// deckShare serves GET /d/{sqid}/share: this deck's links, what each
+// one has been used for, and the one form that makes another.
+//
+// The owner's alone. A team member can read the deck, and handing them
+// the power to mint an unauthenticated read capability for it is a
+// different decision from letting them read it — one the owner made
+// when they raised the rung, and not one they made for the whole
+// internet.
+func (a *app) deckShare(w http.ResponseWriter, r *http.Request) {
+ u, ok := a.viewer(w, r)
+ if !ok {
+ return
+ }
+ d, ok := a.ownedDeckFor(w, r, u)
+ if !ok {
+ return
+ }
+ a.renderSharePage(w, r, u, d, r.URL.Query().Get("new"))
+}
+
+func (a *app) renderSharePage(w http.ResponseWriter, r *http.Request, u User, d Deck, newToken string) {
+ var links []ShareLink
+ if err := a.db.G.WithContext(r.Context()).
+ Where("deck_id = ?", d.ID).
+ Order("created_at DESC, id DESC").Find(&links).Error; err != nil {
+ a.fail(w, r, "list share links", err)
+ return
+ }
+
+ base := "/d/" + d.Sqid
+ page := sharePage{
+ shell: shellFor(r, u),
+ Title: rastrillo.T(r, "slides.share.title"),
+ DeckTitle: d.Title,
+ Lead: rastrillo.T(r, "slides.share.lead"),
+ WarningTitle: rastrillo.T(r, "slides.share.warning.title"),
+ Warning: rastrillo.T(r, "slides.share.warning"),
+ BackHref: base,
+ BackLabel: rastrillo.T(r, "slides.deck.back"),
+ Action: base + "/share",
+ CreateLabel: rastrillo.T(r, "slides.share.create"),
+ EmptyTitle: rastrillo.T(r, "slides.share.empty.title"),
+ EmptyBody: rastrillo.T(r, "slides.share.empty.body"),
+ Links: make([]shareRow, 0, len(links)),
+ }
+ if newToken != "" {
+ page.NewURL = a.shareURL(newToken)
+ page.NewTitle = rastrillo.T(r, "slides.share.new.title")
+ page.NewHint = rastrillo.T(r, "slides.share.new.body")
+ page.NewLabel = rastrillo.T(r, "slides.share.new.label")
+ page.CopyHint = rastrillo.T(r, "slides.share.new.copy")
+ }
+ for _, l := range links {
+ row := shareRow{
+ Created: rastrillo.Tf(r, "slides.share.row.created", "when", whenWords(r, l.CreatedAt)),
+ Views: viewWords(r, l.Views),
+ Revoked: l.RevokedAt != nil,
+ RevokeAction: fmt.Sprintf("%s/share/%d/revoke", base, l.ID),
+ RevokeLabel: rastrillo.T(r, "slides.share.revoke"),
+ }
+ if row.Revoked {
+ row.StatusLabel = rastrillo.T(r, "slides.share.revoked")
+ row.StatusTone = "neutral"
+ } else {
+ row.StatusLabel = rastrillo.T(r, "slides.share.active")
+ row.StatusTone = "positive"
+ }
+ page.Links = append(page.Links, row)
+ }
+ render(w, "share", page)
+}
+
+// viewWords is a link's use, as words. Two keys because English has two
+// forms and the catalog has no plural machinery — the same shape
+// decks.go's rowSub uses.
+func viewWords(r *http.Request, n int64) string {
+ switch n {
+ case 0:
+ return rastrillo.T(r, "slides.share.row.views.none")
+ case 1:
+ return rastrillo.T(r, "slides.share.row.views.one")
+ default:
+ return rastrillo.Tf(r, "slides.share.row.views", "n", n)
+ }
+}
+
+// --- POST /d/{sqid}/share ---
+
+// mintShareLink serves POST /d/{sqid}/share: one token, its hash
+// stored, and a 303 back to the share page carrying the token so it can
+// be shown once.
+//
+// The token rides in the redirect's query string, which is calendar's
+// shape too. It is a real cost — a URL with a live capability in it
+// lands in the browser's history and in any proxy log between here and
+// the person — and it is bounded: the redirect is same-origin, the
+// page it lands on is the owner's own, and there is no third party to
+// leak a Referer to. The alternative, a flash cookie, moves the same
+// secret into a different log.
+func (a *app) mintShareLink(w http.ResponseWriter, r *http.Request) {
+ u, ok := a.viewer(w, r)
+ if !ok {
+ return
+ }
+ d, ok := a.ownedDeckFor(w, r, u)
+ if !ok {
+ return
+ }
+
+ token, err := newShareToken()
+ if err != nil {
+ a.fail(w, r, "mint a share token", err)
+ return
+ }
+ link := ShareLink{
+ DeckID: d.ID,
+ TokenHash: hashShareToken(token),
+ Actor: actorFor(u),
+ CreatedAt: time.Now().UTC(),
+ }
+ if err := a.db.G.WithContext(r.Context()).Create(&link).Error; err != nil {
+ a.fail(w, r, "create share link", err)
+ return
+ }
+ http.Redirect(w, r, "/d/"+d.Sqid+"/share?new="+token, http.StatusSeeOther)
+}
+
+// --- POST /d/{sqid}/share/{id}/revoke ---
+
+// revokeShareLink serves POST /d/{sqid}/share/{id}/revoke.
+//
+// Scoped by deck as well as by id, so an id belonging to another deck
+// is a 404 rather than a revocation somebody was not entitled to make;
+// the deck itself is already the owner's (ownedDeckFor).
+//
+// Revoking twice is not an error. The URL is off either way, and a
+// second POST from a double-clicked button should not look like a
+// failure.
+func (a *app) revokeShareLink(w http.ResponseWriter, r *http.Request) {
+ u, ok := a.viewer(w, r)
+ if !ok {
+ return
+ }
+ d, ok := a.ownedDeckFor(w, r, u)
+ if !ok {
+ return
+ }
+ id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
+ if err != nil {
+ a.notFound(w, r)
+ return
+ }
+
+ now := time.Now().UTC()
+ // revoked_at IS NULL in the WHERE, so a second POST cannot move the
+ // timestamp: when a link was turned off is a fact about it, and a
+ // double-clicked button must not rewrite it.
+ res := a.db.G.WithContext(r.Context()).Model(&ShareLink{}).
+ Where("id = ? AND deck_id = ? AND revoked_at IS NULL", id, d.ID).
+ Update("revoked_at", now)
+ if res.Error != nil {
+ a.fail(w, r, "revoke share link", res.Error)
+ return
+ }
+ if res.RowsAffected == 0 {
+ // Either the link is already revoked — in which case the URL is
+ // off, the person got what they asked for, and an error page
+ // would be a lie — or the id is not this deck's, which is a 404
+ // like every other row that is not yours.
+ var n int64
+ if err := a.db.G.WithContext(r.Context()).Model(&ShareLink{}).
+ Where("id = ? AND deck_id = ?", id, d.ID).Count(&n).Error; err != nil {
+ a.fail(w, r, "check a share link", err)
+ return
+ }
+ if n == 0 {
+ a.notFound(w, r)
+ return
+ }
+ http.Redirect(w, r, "/d/"+d.Sqid+"/share", http.StatusSeeOther)
+ return
+ }
+ // The row carries the actor who minted it, not the one who revoked
+ // it — the model has one actor column and this screen is the
+ // owner's alone, so the two are the same person in every path that
+ // exists today. Logged rather than left unrecorded, so the day a
+ // second person can reach this screen there is a trail.
+ a.logger.Info("slides: share link revoked",
+ "deck", d.Sqid, "link", id, "actor", actorFor(u))
+ http.Redirect(w, r, "/d/"+d.Sqid+"/share", http.StatusSeeOther)
+}
+
+// --- GET /s/{token} ---
+
+// sharedDeck serves the share link itself, outside the session group:
+// the whole point of the capability is that it works for somebody who
+// has no account here.
+//
+// It renders the same viewer every other reader gets (viewer.go), in
+// link mode — no notes, no owner screens, no filmstrip link into
+// anything but another slide.
+func (a *app) sharedDeck(w http.ResponseWriter, r *http.Request) {
+ token := chi.URLParam(r, "token")
+ if token == "" {
+ a.notFound(w, r)
+ return
+ }
+
+ var link ShareLink
+ err := a.db.G.WithContext(r.Context()).
+ Where("token_hash = ? AND revoked_at IS NULL", hashShareToken(token)).
+ Take(&link).Error
+ switch {
+ case err == nil:
+ case errors.Is(err, gorm.ErrRecordNotFound):
+ // Missing and revoked are the same answer. Not 410: a gone
+ // tells a stranger the URL they guessed was once real.
+ a.notFound(w, r)
+ return
+ default:
+ a.fail(w, r, "resolve share link", err)
+ return
+ }
+
+ var d Deck
+ if err := a.db.G.WithContext(r.Context()).Take(&d, link.DeckID).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ a.notFound(w, r)
+ return
+ }
+ a.fail(w, r, "load a shared deck", err)
+ return
+ }
+
+ // Best effort, and deliberately outside the render path's error
+ // handling: a deck that will not load is a real problem, and a
+ // view count one short is not. Counted on resolution rather than
+ // on a successful render, so the number means "the link was
+ // opened" — which is the question the share page is asking.
+ if err := a.db.G.WithContext(r.Context()).Model(&ShareLink{}).
+ Where("id = ?", link.ID).
+ Update("views", gorm.Expr("views + 1")).Error; err != nil {
+ a.logger.Error("slides: count a share link view", "link", link.ID, "err", err)
+ }
+
+ a.renderViewer(w, r, d, modeLink, "/s/"+token, shell{})
+}
diff --git a/internal/slides/templates/account.html b/internal/slides/templates/account.html
index d9a06db..24a9cd1 100644
--- a/internal/slides/templates/account.html
+++ b/internal/slides/templates/account.html
@@ -14,7 +14,9 @@
{{/* This file owns "head" for every signed-in page, because that is
where the one rule below has to live. A page that defines its own
"head" replaces this one and loses the rule — so no signed-in page
- does; they style with the rst-* vocabulary instead. */}}
+ does. A page with something of its own to put in <head> defines
+ "page-head" instead (layout.html says why), and this definition
+ calls it, so the rule below and the page's own markup both land. */}}
{{define "head"}}<style>
/* The address the account menu is signed in as: a label above the
one control, not a control itself. Muted and small so it reads as
@@ -27,7 +29,7 @@
overflow-wrap: anywhere;
padding: 0.4rem 0.65rem;
}
-</style>{{end}}
+</style>{{template "page-head" .}}{{end}}
{{define "account"}}
<p slides-account-who>{{.Person}}</p>
<hr>
diff --git a/internal/slides/templates/anon.html b/internal/slides/templates/anon.html
new file mode 100644
index 0000000..b5ba228
--- /dev/null
+++ b/internal/slides/templates/anon.html
@@ -0,0 +1,16 @@
+{{/* anon — the shell's account menu for a screen nobody is signed in on.
+
+ There is exactly one such screen: the share-link viewer,
+ GET /s/{token}. render.go builds viewer.html twice, once with
+ account.html and once with this file, so the same viewer can be
+ served to a signed-in person and to a stranger holding a link
+ without either shell pretending to be the other.
+
+ The layout renders the account disclosure unconditionally, so the
+ choice is not whether to have one — it is what to put in it. A
+ sign-in link is the honest answer: somebody reading a deck over a
+ link may well have an account here, and the door is the only thing
+ this app can offer them. */}}
+{{define "account"}}
+<a href="{{.SignInHref}}">{{.SignInLabel}}</a>
+{{end}}
diff --git a/internal/slides/templates/deck.html b/internal/slides/templates/deck.html
deleted file mode 100644
index 14e3cf5..0000000
--- a/internal/slides/templates/deck.html
+++ /dev/null
@@ -1,41 +0,0 @@
-{{/* deck — GET /d/{sqid}, the plainest possible view of a document.
-
- It exists to prove the deck is real: the title, who can open it,
- and one <section> per slide naming each block's type and its words.
- The renderer and the editor land in task 5 and replace this whole
- screen, so nothing here is invested in — no stage, no theme, no
- layout engine.
-
- Block, layout and template ids render verbatim. They are
- identifiers, like a sqid, not copy: translating "heading" would
- make this page harder to read against the model, not easier.
-
- Each slide is a box-head SIBLING followed by its box, which is how
- tokens.css means a section card to be built — the heading is not
- inside the card. */}}
-{{define "title"}}{{.Title}}{{end}}
-{{define "content"}}
-{{template "back-nav" dict "Href" .BackHref "Label" .BackLabel}}
-{{template "page-header" dict
- "Title" .Title
- "ActionHref" .SettingsHref "ActionLabel" .SettingsLabel}}
-<div rst-box>
- {{template "status-pill" dict "Tone" .RungTone "Label" .RungLabel}}
-</div>
-{{range .Slides}}
-<div rst-box-head>
- <h2>{{.Heading}}</h2>
- <p><code class="rst-mono">{{.Layout}}</code> <code class="rst-mono">{{.Template}}</code></p>
-</div>
-<section rst-box>
- <ul>
- {{range .Blocks}}
- <li><code class="rst-mono">{{.Type}}</code> {{.Text}}</li>
- {{end}}
- </ul>
- {{with .Notes}}
- <p><strong>{{$.NotesLabel}}</strong> {{.}}</p>
- {{end}}
-</section>
-{{end}}
-{{end}}
diff --git a/internal/slides/templates/deck_notes.html b/internal/slides/templates/deck_notes.html
new file mode 100644
index 0000000..6979be3
--- /dev/null
+++ b/internal/slides/templates/deck_notes.html
@@ -0,0 +1,27 @@
+{{/* deck_notes — GET /d/{sqid}/notes, every slide's notes on one page.
+
+ The scriptless presenter's crib sheet (spec §8): the page a person
+ prints, or keeps open on a second screen, when they are presenting
+ from a laptop that is mirroring rather than extending. One heading
+ per slide, linked to that slide in the viewer, and the notes under
+ it.
+
+ A slide with no notes says so. On a page being read while
+ presenting, a heading over nothing is indistinguishable from a
+ page that failed to load. */}}
+{{define "title"}}{{.Title}}{{end}}
+{{define "content"}}
+{{template "back-nav" dict "Href" .BackHref "Label" .BackLabel}}
+{{template "page-header" dict "Title" .Title "Sub" .DeckTitle}}
+<div rst-box>
+ <p>{{.Body}}</p>
+</div>
+{{range .Slides}}
+<div rst-box-head>
+ <h2><a href="{{.Href}}">{{.Label}}</a></h2>
+</div>
+<section rst-box>
+ {{if .Notes}}{{.Notes}}{{else}}<p>{{.Empty}}</p>{{end}}
+</section>
+{{end}}
+{{end}}
diff --git a/internal/slides/templates/layout.html b/internal/slides/templates/layout.html
index 5f109e7..6b234b7 100644
--- a/internal/slides/templates/layout.html
+++ b/internal/slides/templates/layout.html
@@ -17,8 +17,17 @@
{{/* head is the app's own slot in <head>: a favicon, a meta tag, one
more stylesheet. Empty by default, last in the head so an app's
own CSS wins over the framework's, and the only block in the
- three shells that is not chrome. */}}
-{{block "head" .}}{{end}}
+ three shells that is not chrome.
+
+ page-head nested inside it is this app's own addition, and it
+ exists because head has an owner already: account.html defines it
+ for every signed-in screen, to carry the one rule the account menu
+ needs. A page defining "head" of its own would replace that and
+ silently lose the rule, so a page defines "page-head" instead and
+ both land — whichever of the two shells is in play, since a screen
+ built without account.html (templates/anon.html's share-link
+ viewer) still resolves page-head through this default. */}}
+{{block "head" .}}{{block "page-head" .}}{{end}}{{end}}
</head>
<body>
<div rst-shell-topbar>
diff --git a/internal/slides/templates/share.html b/internal/slides/templates/share.html
new file mode 100644
index 0000000..c411077
--- /dev/null
+++ b/internal/slides/templates/share.html
@@ -0,0 +1,67 @@
+{{/* share — GET /d/{sqid}/share, a deck's read-only links.
+
+ The screen is three things in the order they matter: the link that
+ was just made (once, and never again), what a link costs to hand
+ out, and the links that already exist with the one button that
+ turns each of them off.
+
+ The new link is a callout plus a read-only field rather than a line
+ of prose, because the whole job of this screen at that moment is
+ "select this and copy it", and a field is the thing a browser lets
+ you select cleanly with no script at all. */}}
+{{define "title"}}{{.Title}}{{end}}
+{{define "content"}}
+{{template "back-nav" dict "Href" .BackHref "Label" .BackLabel}}
+{{template "page-header" dict "Title" .Title "Sub" .DeckTitle}}
+
+{{if .NewURL}}
+<div rst-box>
+ {{template "callout" dict "Tone" "positive" "Title" .NewTitle "Body" .NewHint}}
+ {{/* Hand-written rather than {{template "field-text"}} for one
+ attribute: readonly, which that partial's contract does not
+ carry. The markup is field-text's own, attribute for attribute,
+ so the control still looks and reads like every other field on
+ every other screen — and the day readonly joins the partial,
+ this becomes a one-line call again. */}}
+ <div rst-field>
+ <label rst-field-label for="share-url">{{.NewLabel}}</label>
+ <input rst-input id="share-url" name="share-url" type="text" value="{{.NewURL}}"
+ readonly aria-describedby="share-url-hint">
+ <small rst-field-hint id="share-url-hint">{{.CopyHint}}</small>
+ </div>
+</div>
+{{end}}
+
+<div rst-box>
+ <p>{{.Lead}}</p>
+</div>
+
+{{template "callout" dict "Tone" "warning" "Title" .WarningTitle "Body" .Warning}}
+
+{{if .Links}}
+<div rst-list>
+ {{range .Links}}
+ <div rst-row>
+ <span rst-row-main>
+ {{.Created}}
+ <small rst-row-sub>{{.Views}}</small>
+ </span>
+ {{template "status-pill" dict "Tone" .StatusTone "Label" .StatusLabel}}
+ {{if not .Revoked}}
+ <form method="post" action="{{.RevokeAction}}">
+ <button rst-btn="ghost" class="rst-danger" type="submit">{{.RevokeLabel}}</button>
+ </form>
+ {{end}}
+ </div>
+ {{end}}
+</div>
+{{else}}
+<div rst-box>
+ {{template "empty-state" dict "Title" .EmptyTitle "Body" .EmptyBody}}
+</div>
+{{end}}
+
+<form method="post" action="{{.Action}}" rst-form>
+ <button rst-btn="primary lg" type="submit">{{.CreateLabel}}</button>
+</form>
+{{end}}
diff --git a/internal/slides/templates/viewer.html b/internal/slides/templates/viewer.html
new file mode 100644
index 0000000..f65d5b8
--- /dev/null
+++ b/internal/slides/templates/viewer.html
@@ -0,0 +1,135 @@
+{{/* viewer — GET /d/{sqid} and GET /s/{token}, the paged viewer.
+
+ One file for all three audiences (viewer.go's modeOwner,
+ modeMember, modeLink). The mode decides what the handler PUTS in
+ the view data — the notes, the owner's buttons, the filmstrip's
+ links — and this template only ever asks whether a field is there.
+ That is the whole reason it is not two templates: a second file is
+ a second place for "does this reader get the speaker notes?" to be
+ answered, and the two answers would drift.
+
+ render.go builds it twice, as "viewer" (with the account menu) and
+ as "viewer_link" (with a sign-in link instead), because nobody
+ reading a deck over a share link has an account to show.
+
+ Present is task 6's surface in its scriptless form: one slide, two
+ links, nothing else on the page. §8's floor, and the thing the
+ fullscreen presenter will be an enhancement OF rather than a
+ replacement for. */}}
+{{define "title"}}{{.Title}}{{end}}
+{{/* The slide's own stylesheet, on the screens that render a slide and
+ nowhere else: it is a document's look, not the app's chrome, and
+ every other screen would pay for it without using it. */}}
+{{define "page-head"}}<link rel="stylesheet" href="{{asset "static/slides.css"}}">
+<style>
+ /* The chrome around the slide. Everything a slide itself looks like
+ is slides.css; these five rules only place the viewer's own parts,
+ and each one is an app attribute (slides-*) so nothing here can
+ collide with the rst-* vocabulary. */
+ [slides-stage] {
+ margin-block: var(--rst-sp-4);
+ }
+ [slides-tools] {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--rst-sp-2);
+ }
+ [slides-pager] {
+ align-items: center;
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--rst-sp-3);
+ }
+ /* A pager end — the previous link on slide one — is plain text, not
+ a disabled control: there is nowhere to go, so there is nothing to
+ put in the tab order. */
+ [slides-pager-end],
+ [slides-counter] {
+ color: var(--rst-text-muted);
+ margin: 0;
+ }
+ [slides-film] {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--rst-sp-2);
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ }
+ [slides-film] a {
+ display: inline-block;
+ /* 24 by 24 is the floor a pointer target has to meet (WCAG 2.2 AA);
+ the padding is what gets a one-word slide name there. */
+ min-height: 24px;
+ padding: var(--rst-sp-1) var(--rst-sp-2);
+ }
+ [slides-film] a[aria-current="true"] {
+ background: var(--rst-accent-soft);
+ border-radius: var(--rst-radius-sm);
+ font-weight: 600;
+ }
+</style>{{end}}
+{{define "content"}}
+{{if .Present}}
+<div slides-present data-vp="{{.VP}}">
+ {{.Slide}}
+ <nav slides-pager aria-label="{{.PagerLabel}}">
+ {{if .PrevHref}}<a rst-btn href="{{.PrevHref}}" rel="prev">{{.PrevLabel}}</a>
+ {{else}}<span slides-pager-end>{{.PrevLabel}}</span>{{end}}
+ <p slides-counter>{{.Counter}}</p>
+ {{if .NextHref}}<a rst-btn href="{{.NextHref}}" rel="next">{{.NextLabel}}</a>
+ {{else}}<span slides-pager-end>{{.NextLabel}}</span>{{end}}
+ </nav>
+</div>
+{{else}}
+<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}}
+<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}}
+</nav>
+{{end}}
+
+{{if .HasSlides}}
+<div slides-stage>{{.Slide}}</div>
+
+<nav slides-pager aria-label="{{.PagerLabel}}">
+ {{if .PrevHref}}<a rst-btn href="{{.PrevHref}}" rel="prev">{{.PrevLabel}}</a>
+ {{else}}<span slides-pager-end>{{.PrevLabel}}</span>{{end}}
+ <p slides-counter>{{.Counter}}</p>
+ {{if .NextHref}}<a rst-btn href="{{.NextHref}}" rel="next">{{.NextLabel}}</a>
+ {{else}}<span slides-pager-end>{{.NextLabel}}</span>{{end}}
+</nav>
+
+{{if .HasNotes}}
+<details slides-notes>
+ <summary>{{.NotesLabel}}</summary>
+ {{.Notes}}
+</details>
+{{end}}
+
+<nav slides-film-nav aria-label="{{.FilmLabel}}">
+ <ol slides-film>
+ {{range .Film}}
+ <li><a href="{{.Href}}"{{if .Current}} aria-current="true"{{end}}>{{.Label}}</a></li>
+ {{end}}
+ </ol>
+</nav>
+{{else}}
+<div rst-box>
+ {{template "empty-state" dict "Title" .EmptyTitle "Body" .EmptyBody}}
+</div>
+{{end}}
+</div>
+{{end}}
+{{end}}
diff --git a/internal/slides/viewer.go b/internal/slides/viewer.go
new file mode 100644
index 0000000..d646bc5
--- /dev/null
+++ b/internal/slides/viewer.go
@@ -0,0 +1,406 @@
+// viewer.go is the paged viewer and the notes page: the screens that
+// show a deck to somebody who is reading it rather than writing it.
+//
+// One surface, three audiences (spec §5). The owner, a team member and
+// a stranger holding a share link all get the same page from the same
+// template — mode is a field on the view data, never a second template
+// and never a second handler, because the day the three drift is the
+// day one of them leaks the speaker notes.
+//
+// It is the scriptless floor (§8) as a whole screen: one slide per
+// page, previous and next as ordinary links, a filmstrip of ordinary
+// links, notes in a <details>. Nothing here needs JavaScript to work,
+// and task 6's live presenter is an addition to it rather than a
+// replacement for it.
+package slides
+
+import (
+ "html/template"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "amadan.net/rastrillo/rastrillo"
+
+ "amadan.net/oficina/slides/internal/slides/deck"
+ // Aliased: this package already has a render — render.go's, which
+ // executes a page template — and the two would collide on the
+ // name. slidehtml says which of the two a call means at the call
+ // site, which is the more useful thing for a reader anyway.
+ slidehtml "amadan.net/oficina/slides/internal/slides/render"
+)
+
+// The three audiences a viewer is rendered for. Owner and member are
+// both signed in and both see the notes; link is a share-link reader,
+// who sees the slides and nothing else about the deck.
+const (
+ modeOwner = "owner"
+ modeMember = "member"
+ modeLink = "link"
+)
+
+// vpPhone is the ?vp= value that asks for the phone rendering. Anything
+// else is the desktop one — an unknown viewport is not an error, it is
+// simply not the phone.
+const vpPhone = "phone"
+
+// filmItem is one slide in the filmstrip: where it is, what it is
+// called, and whether it is the one being shown.
+type filmItem struct {
+ Href string
+ Label string
+ Current bool
+}
+
+// viewerPage is the whole viewer screen. Mode is what the template
+// branches on; every href is built in Go so the query string a link
+// carries (the viewport, the presenting flag) is decided in one place.
+type viewerPage struct {
+ shell
+ Mode string
+ Present bool
+ // VP is "desktop" or "phone" — the wrapper's data-vp, so the page
+ // itself says which shape it was rendered for and not only the
+ // section inside it.
+ VP string
+
+ Title string
+ Slide template.HTML
+
+ // HasSlides is false for a deck with nothing in it. It is a real
+ // state rather than a 404: a deck you own that renders as "not
+ // found" reads as data loss.
+ HasSlides bool
+ EmptyTitle string
+ EmptyBody string
+
+ Counter string
+ PagerLabel string
+ PrevHref string
+ PrevLabel string
+ NextHref string
+ NextLabel string
+ FilmLabel string
+ Film []filmItem
+
+ HasNotes bool
+ NotesLabel string
+ Notes template.HTML
+
+ ToolsLabel string
+ SettingsHref string
+ SettingsLabel string
+ ShareHref string
+ ShareLabel string
+ NotesHref string
+ NotesPageLabel string
+ PresentHref string
+ PresentLabel string
+
+ BackHref string
+ BackLabel string
+
+ // SignInHref and SignInLabel are the share-link shell's one control
+ // (templates/anon.html): somebody reading a deck over a link has no
+ // account menu, and the honest thing to offer instead is the door.
+ SignInHref string
+ SignInLabel string
+}
+
+// --- GET /d/{sqid} ---
+
+// showDeck serves the signed-in viewer. Which of the two signed-in
+// modes it renders is the ownership question and nothing else: a
+// member reached this deck through visibleDecks, so they read the
+// notes, and only the owner is offered the screens that change it.
+func (a *app) showDeck(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
+ }
+ mode := modeMember
+ if d.OwnerID == u.ID {
+ mode = modeOwner
+ }
+ a.renderViewer(w, r, d, mode, "/d/"+d.Sqid, shellFor(r, u))
+}
+
+// renderViewer is the one viewer, for all three audiences. base is the
+// URL every link on the page hangs off — "/d/{sqid}" signed in,
+// "/s/{token}" over a share link — so the page never has to know which
+// of the two it is serving to build a next link.
+func (a *app) renderViewer(w http.ResponseWriter, r *http.Request, d Deck, mode, base string, sh shell) {
+ 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()
+ vp := q.Get("vp")
+ if vp != vpPhone {
+ vp = "desktop"
+ }
+ present := q.Get("present") == "1"
+
+ page := viewerPage{
+ shell: sh,
+ Mode: mode,
+ Present: present,
+ VP: vp,
+ Title: view.Meta.Title,
+ SignInHref: signinPath,
+ SignInLabel: rastrillo.T(r, "slides.signin.title"),
+ }
+ if mode != modeLink {
+ page.BackHref = "/"
+ page.BackLabel = rastrillo.T(r, "slides.decks.title")
+ }
+
+ // The buttons, before anything about which slide is being shown:
+ // they are about the deck, not about the slide, so an owner whose
+ // document has come back empty still has a way to its settings.
+ // modeLink's present link is the exception and waits for the index,
+ // because it is a link to a slide.
+ page.ToolsLabel = rastrillo.T(r, "slides.viewer.tools")
+ switch mode {
+ case modeOwner:
+ page.SettingsHref = base + "/settings"
+ page.SettingsLabel = rastrillo.T(r, "slides.deck.settings")
+ page.ShareHref = base + "/share"
+ page.ShareLabel = rastrillo.T(r, "slides.viewer.share")
+ page.NotesHref = base + "/notes"
+ page.NotesPageLabel = rastrillo.T(r, "slides.viewer.notes.page")
+ page.PresentLabel = rastrillo.T(r, "slides.viewer.present")
+ case modeMember:
+ page.NotesHref = base + "/notes"
+ page.NotesPageLabel = rastrillo.T(r, "slides.viewer.notes.page")
+ page.PresentLabel = rastrillo.T(r, "slides.viewer.present")
+ }
+
+ 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, viewerTemplate(mode), page)
+ return
+ }
+
+ index, ok := slideIndex(q.Get("slide"), len(view.Slides))
+ if !ok {
+ // Off the end of the deck is a page that does not exist, not a
+ // clamp to the last slide: a link to slide 40 of an 8-slide
+ // deck is wrong, and quietly showing slide 8 hides that.
+ a.notFound(w, r)
+ return
+ }
+
+ html, err := slidehtml.Slide(view, index, viewport(vp))
+ if err != nil {
+ a.fail(w, r, "render slide", err)
+ return
+ }
+ s := view.Slides[index]
+
+ page.HasSlides = true
+ page.Slide = html
+ page.Counter = rastrillo.Tf(r, "slides.viewer.counter",
+ "n", index+1, "total", len(view.Slides))
+ page.PagerLabel = rastrillo.T(r, "slides.viewer.pager")
+ page.PrevLabel = rastrillo.T(r, "slides.viewer.prev")
+ page.NextLabel = rastrillo.T(r, "slides.viewer.next")
+ if index > 0 {
+ page.PrevHref = viewerHref(base, index, vp, present)
+ }
+ if index+1 < len(view.Slides) {
+ page.NextHref = viewerHref(base, index+2, vp, present)
+ }
+
+ // The presenting surface is the slide and the two links, and
+ // nothing else: no filmstrip, no notes, no buttons. Task 6 replaces
+ // it with the real fullscreen surface; what it must not do is
+ // arrive before then as a page that leaks the notes.
+ if present {
+ render(w, viewerTemplate(mode), page)
+ return
+ }
+
+ page.FilmLabel = rastrillo.T(r, "slides.viewer.film")
+ page.Film = make([]filmItem, 0, len(view.Slides))
+ for i, sl := range view.Slides {
+ page.Film = append(page.Film, filmItem{
+ Href: viewerHref(base, i+1, vp, false),
+ Label: slideLabel(r, sl, i+1),
+ Current: i == index,
+ })
+ }
+
+ // The notes are the deck's own working notes, so they belong to
+ // the people who reached the deck rather than to whoever holds a
+ // link to it (spec §5: a share link is read-only, and read-only
+ // means the slides).
+ if mode != modeLink && len(s.Notes) > 0 {
+ page.HasNotes = true
+ page.NotesLabel = rastrillo.T(r, "slides.viewer.notes")
+ page.Notes = slidehtml.Notes(s.Notes)
+ }
+
+ // Spec §5: the present link is this same capability with
+ // ?present=1, so a link reader gets a working one rather than the
+ // placeholder the signed-in screens carry until task 6.
+ if mode == modeLink {
+ page.PresentHref = viewerHref(base, index+1, vp, true)
+ page.PresentLabel = rastrillo.T(r, "slides.viewer.present")
+ }
+
+ render(w, viewerTemplate(mode), page)
+}
+
+// viewerTemplate picks which build of viewer.html to execute. Two
+// builds, one file (render.go): the signed-in one carries the account
+// menu, and the share-link one carries a sign-in link instead, because
+// nobody reading over a link has an account to show.
+func viewerTemplate(mode string) string {
+ if mode == modeLink {
+ return "viewer_link"
+ }
+ return "viewer"
+}
+
+// viewport maps the ?vp= value onto the renderer's own two.
+func viewport(vp string) slidehtml.Viewport {
+ if vp == vpPhone {
+ return slidehtml.Phone
+ }
+ return slidehtml.Desktop
+}
+
+// slideIndex turns the ?slide= parameter into a 0-based index. Empty is
+// the first slide; anything else must be a whole number naming a slide
+// this deck actually has.
+func slideIndex(raw string, count int) (int, bool) {
+ if raw == "" {
+ return 0, count > 0
+ }
+ n, err := strconv.Atoi(raw)
+ if err != nil || n < 1 || n > count {
+ return 0, false
+ }
+ return n - 1, true
+}
+
+// viewerHref builds a link to slide n (1-based), carrying whatever the
+// current request was rendered with. The viewport travels because
+// paging out of a phone preview back into the desktop one is a trap;
+// the presenting flag travels for the same reason.
+//
+// Built by hand rather than with url.Values so the order is fixed —
+// slide, then vp, then present — which is what makes these URLs stable
+// enough to assert on and to read in a log.
+func viewerHref(base string, n int, vp string, present bool) string {
+ var b strings.Builder
+ b.WriteString(base)
+ b.WriteString("?slide=")
+ b.WriteString(strconv.Itoa(n))
+ if vp == vpPhone {
+ b.WriteString("&vp=phone")
+ }
+ if present {
+ b.WriteString("&present=1")
+ }
+ return b.String()
+}
+
+// slideLabel is what the filmstrip and the notes page call a slide: its
+// own first heading, or its number when it has none. The heading is the
+// better label by a distance — "Regions, not coordinates" is findable
+// in a list of eight and "Slide 3" is not — but a slide need not have
+// one, so the number is always there to fall back to.
+func slideLabel(r *http.Request, s deck.Slide, n int) string {
+ for _, b := range s.Blocks {
+ if b.Type != "heading" {
+ continue
+ }
+ if text := strings.TrimSpace(b.Text.Plain()); text != "" {
+ return text
+ }
+ }
+ return rastrillo.Tf(r, "slides.deck.slide", "n", n)
+}
+
+// --- GET /d/{sqid}/notes ---
+
+type notesRow struct {
+ Label string
+ Href string
+ Notes template.HTML
+ Empty string
+}
+
+type notesPage struct {
+ shell
+ Title string
+ DeckTitle string
+ Body string
+ BackHref string
+ BackLabel string
+ Slides []notesRow
+}
+
+// deckNotes serves GET /d/{sqid}/notes: every slide's label and its
+// notes, in order, on one page.
+//
+// It is spec §8's "read the presenter notes page" — the thing a person
+// prints, or keeps open on a second screen, when the live presenter
+// view is not available to them. Signed in only, and behind the same
+// visibility seam as the deck itself: notes are not part of what a
+// share link carries.
+func (a *app) deckNotes(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
+ }
+
+ h, view, err := a.openDeck(r.Context(), d)
+ if err != nil {
+ a.fail(w, r, "open deck", err)
+ return
+ }
+ defer h.Close(r.Context())
+
+ base := "/d/" + d.Sqid
+ page := notesPage{
+ shell: shellFor(r, u),
+ Title: rastrillo.T(r, "slides.notes.title"),
+ DeckTitle: view.Meta.Title,
+ Body: rastrillo.T(r, "slides.notes.body"),
+ BackHref: base,
+ BackLabel: rastrillo.T(r, "slides.deck.back"),
+ Slides: make([]notesRow, 0, len(view.Slides)),
+ }
+ empty := rastrillo.T(r, "slides.notes.none")
+ for i, s := range view.Slides {
+ row := notesRow{
+ Label: slideLabel(r, s, i+1),
+ Href: viewerHref(base, i+1, "desktop", false),
+ }
+ if len(s.Notes) > 0 {
+ row.Notes = slidehtml.Notes(s.Notes)
+ } else {
+ // A slide with no notes says so rather than rendering as a
+ // heading over nothing: on a page being read while
+ // presenting, a gap is indistinguishable from a bug.
+ row.Empty = empty
+ }
+ page.Slides = append(page.Slides, row)
+ }
+ render(w, "deck_notes", page)
+}
diff --git a/internal/slidestest/decks_test.go b/internal/slidestest/decks_test.go
index 93a9972..0854902 100644
--- a/internal/slidestest/decks_test.go
+++ b/internal/slidestest/decks_test.go
@@ -97,8 +97,16 @@ func TestSampleDeckSeededOnFirstSignin(t *testing.T) {
// Eight slides, every flow template plus two canvas slides, and
// notes on each: the sample is what teaches the model, so an empty
// one would pass every other assertion here.
- if n := strings.Count(page, "<section"); n != 8 {
- t.Errorf("the sample deck renders %d slides, want 8:\n%s", n, page)
+ //
+ // Counted off the filmstrip and the counter rather than off the
+ // rendered sections, because the viewer is paged (task 5): one
+ // slide is on the page at a time, and the filmstrip is where the
+ // whole deck is named.
+ if !strings.Contains(page, "1 of 8") {
+ t.Errorf("the sample deck does not say it has eight slides:\n%s", page)
+ }
+ if n := strings.Count(page, `?slide=`); n < 8 {
+ t.Errorf("the sample deck's filmstrip links %d slides, want at least 8:\n%s", n, page)
}
// Seeding happens once, not on every request past the gate.
diff --git a/internal/slidestest/share_test.go b/internal/slidestest/share_test.go
new file mode 100644
index 0000000..311780c
--- /dev/null
+++ b/internal/slidestest/share_test.go
@@ -0,0 +1,245 @@
+package slidestest
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "net/http"
+ "net/url"
+ "regexp"
+ "strings"
+ "testing"
+)
+
+// newToken finds the token the share page was redirected with. It is
+// shown once, in the query string of that one redirect, and nowhere
+// else ever again.
+var newToken = regexp.MustCompile(`\?new=([A-Za-z0-9_-]{43})`)
+
+// revokeAction finds a link row's revoke form on the share page.
+var revokeAction = regexp.MustCompile(`/share/(\d+)/revoke`)
+
+// mintShareLink posts the create form on a deck's share page and
+// returns the token it was shown once.
+func mintShareLink(t *testing.T, a *testApp, deckPath string, cookies []*http.Cookie) string {
+ t.Helper()
+ rec := post(t, a, deckPath+"/share", nil, cookies...)
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("POST %s/share: status %d, want 303\n%s", deckPath, rec.Code, rec.Body)
+ }
+ loc := rec.Header().Get("Location")
+ m := newToken.FindStringSubmatch(loc)
+ if m == nil {
+ t.Fatalf("POST %s/share: Location = %q, carries no new token", deckPath, loc)
+ }
+ return m[1]
+}
+
+// TestShareLinkShownOnceAndOpensViewer: minting shows the URL once,
+// coming back never shows it again, and the URL itself opens the deck
+// for somebody with no session at all.
+func TestShareLinkShownOnceAndOpensViewer(t *testing.T) {
+ a := newApp(t)
+ cookies := signedIn(t, a, allowedAddress)
+ path := createDeck(t, a, cookies, "Board update")
+
+ empty := mustGet(t, a, path+"/share", http.StatusOK, cookies).Body.String()
+ if !strings.Contains(empty, "No share links") {
+ t.Errorf("a deck with no share links does not say so:\n%s", empty)
+ }
+
+ token := mintShareLink(t, a, path, cookies)
+
+ shown := mustGet(t, a, path+"/share?new="+token, http.StatusOK, cookies).Body.String()
+ full := testOrigin + "/s/" + token
+ if !strings.Contains(shown, full) {
+ t.Errorf("the share page does not show the new link %q:\n%s", full, shown)
+ }
+ if !strings.Contains(shown, "readonly") {
+ t.Errorf("the new link is not in a read-only field:\n%s", shown)
+ }
+
+ // Come back for it and it is gone: only the hash was stored, so
+ // there is nothing left to show.
+ again := mustGet(t, a, path+"/share", http.StatusOK, cookies).Body.String()
+ if strings.Contains(again, token) {
+ t.Errorf("the share page shows the token a second time:\n%s", again)
+ }
+
+ // No cookies at all: the link is the whole capability.
+ page := mustGet(t, a, "/s/"+token, http.StatusOK, nil).Body.String()
+ if !strings.Contains(page, "Board update") {
+ t.Errorf("the share link does not open the deck:\n%s", page)
+ }
+ if !strings.Contains(page, "<section slides-slide") {
+ t.Errorf("the share link renders no slide:\n%s", page)
+ }
+ // It is the same paged viewer, and the same phone preview.
+ mustGet(t, a, "/s/"+token+"?slide=1", http.StatusOK, nil)
+ mustGet(t, a, "/s/"+token+"?slide=2", http.StatusNotFound, nil)
+ phone := mustGet(t, a, "/s/"+token+"?vp=phone", http.StatusOK, nil).Body.String()
+ if !strings.Contains(phone, `data-vp="phone"`) {
+ t.Errorf("the share link ignores ?vp=phone:\n%s", phone)
+ }
+ // ?present=1 is the same capability with the chrome taken off.
+ present := mustGet(t, a, "/s/"+token+"?present=1", http.StatusOK, nil).Body.String()
+ if !strings.Contains(present, "slides-present") {
+ t.Errorf("?present=1 does not render the presenting surface:\n%s", present)
+ }
+
+ // A token nobody ever minted is not a different answer.
+ mustGet(t, a, "/s/"+strings.Repeat("a", 43), http.StatusNotFound, nil)
+
+ // The views are counted, and the share page says how many.
+ counted := mustGet(t, a, path+"/share", http.StatusOK, cookies).Body.String()
+ if !strings.Contains(counted, "5 views") {
+ t.Errorf("the share page does not count the five views taken above:\n%s", counted)
+ }
+}
+
+// TestRevokedShareLinkIs404: revoking turns the URL off, and a revoked
+// link is not told apart from one that never existed.
+func TestRevokedShareLinkIs404(t *testing.T) {
+ a := newApp(t)
+ cookies := signedIn(t, a, allowedAddress)
+ path := createDeck(t, a, cookies, "Board update")
+
+ token := mintShareLink(t, a, path, cookies)
+ mustGet(t, a, "/s/"+token, http.StatusOK, nil)
+
+ page := mustGet(t, a, path+"/share", http.StatusOK, cookies).Body.String()
+ m := revokeAction.FindStringSubmatch(page)
+ if m == nil {
+ t.Fatalf("the share page carries no revoke form:\n%s", page)
+ }
+ if rec := post(t, a, path+"/share/"+m[1]+"/revoke", nil, cookies...); rec.Code != http.StatusSeeOther {
+ t.Fatalf("POST revoke: status %d, want 303\n%s", rec.Code, rec.Body)
+ }
+
+ mustGet(t, a, "/s/"+token, http.StatusNotFound, nil)
+
+ // The row stays, so the page can still say the link existed and
+ // that it is off.
+ after := mustGet(t, a, path+"/share", http.StatusOK, cookies).Body.String()
+ if !strings.Contains(after, "Revoked") {
+ t.Errorf("the share page does not say the link is revoked:\n%s", after)
+ }
+
+ // Revoking twice is what a double-clicked button does, and the URL
+ // is off either way: it is not an error page.
+ if rec := post(t, a, path+"/share/"+m[1]+"/revoke", nil, cookies...); rec.Code != http.StatusSeeOther {
+ t.Errorf("revoking twice: status %d, want 303", rec.Code)
+ }
+ // An id that is not this deck's link is a row that does not exist.
+ if rec := post(t, a, path+"/share/99999/revoke", nil, cookies...); rec.Code != http.StatusNotFound {
+ t.Errorf("revoking an unknown link: status %d, want 404", rec.Code)
+ }
+}
+
+// TestShareLinkStoresOnlyHash: the table holds a fingerprint, never the
+// URL. A plaintext token here would be a live, unauthenticated read
+// capability sitting in a file that gets backed up.
+func TestShareLinkStoresOnlyHash(t *testing.T) {
+ a := newApp(t)
+ cookies := signedIn(t, a, allowedAddress)
+ path := createDeck(t, a, cookies, "Board update")
+
+ token := mintShareLink(t, a, path, cookies)
+
+ // Every column of the row concatenated, so this is an assertion
+ // about the whole row rather than about the one column that was
+ // remembered to be checked.
+ var whole []string
+ err := a.db.G.Raw(`SELECT COALESCE(id,'') || COALESCE(deck_id,'') || COALESCE(token_hash,'') ||
+ COALESCE(actor,'') || COALESCE(views,'') || COALESCE(created_at,'') || COALESCE(revoked_at,'')
+ FROM share_links`).Scan(&whole).Error
+ if err != nil {
+ t.Fatalf("read share_links: %v", err)
+ }
+ if len(whole) != 1 {
+ t.Fatalf("share_links holds %d rows, want 1", len(whole))
+ }
+ if strings.Contains(whole[0], token) {
+ t.Errorf("share_links holds the plaintext token: %s", whole[0])
+ }
+
+ var stored struct {
+ TokenHash string
+ Actor string
+ }
+ if err := a.db.G.Raw("SELECT token_hash, actor FROM share_links").Scan(&stored).Error; err != nil {
+ t.Fatalf("read share_links: %v", err)
+ }
+ sum := sha256.Sum256([]byte(token))
+ if want := hex.EncodeToString(sum[:]); stored.TokenHash != want {
+ t.Errorf("share_links.token_hash = %q, want the SHA-256 hex %s", stored.TokenHash, want)
+ }
+ if stored.Actor == "" {
+ t.Errorf("share_links.actor is empty: every write records an actor")
+ }
+}
+
+// TestShareLinkHidesNotes: a share link is read-only and it is not a
+// way into the speaker notes, the settings or the share page itself.
+func TestShareLinkHidesNotes(t *testing.T) {
+ a := newApp(t)
+ cookies := signedIn(t, a, allowedAddress)
+ path := sampleDeck(t, a, cookies)
+
+ // The same deck, signed in, does show them — otherwise this test
+ // would pass over a viewer that renders no notes to anybody.
+ signedInPage := mustGet(t, a, path+"?slide=2", http.StatusOK, cookies).Body.String()
+ if !strings.Contains(signedInPage, "template. Text arrives as a small Markdown subset") {
+ t.Fatalf("the signed-in viewer shows no notes to compare against:\n%s", signedInPage)
+ }
+
+ token := mintShareLink(t, a, path, cookies)
+ page := mustGet(t, a, "/s/"+token+"?slide=2", http.StatusOK, nil).Body.String()
+
+ if strings.Contains(page, "template. Text arrives as a small Markdown subset") {
+ t.Errorf("the share link leaks the speaker notes:\n%s", page)
+ }
+ if strings.Contains(page, "Speaker notes") {
+ t.Errorf("the share link offers the notes:\n%s", page)
+ }
+ if strings.Contains(page, "/settings") || strings.Contains(page, "/share") {
+ t.Errorf("the share link offers the owner's screens:\n%s", page)
+ }
+ if strings.Contains(page, "/notes") {
+ t.Errorf("the share link offers the notes page:\n%s", page)
+ }
+ // The deck itself is still not readable without the link: a
+ // signed-out request for it is sent to sign in.
+ mustGet(t, a, path, http.StatusSeeOther, nil)
+}
+
+// TestShareIsTheOwnersAlone: a team member can read the deck and still
+// cannot mint, list or revoke a link on it.
+func TestShareIsTheOwnersAlone(t *testing.T) {
+ a := newSharedApp(t)
+ mine := signedIn(t, a, allowedAddress)
+ theirs := signedIn(t, a, otherAddress)
+
+ path := createDeck(t, a, mine, "All hands")
+ if rec := post(t, a, path+"/settings", url.Values{
+ "title": {"All hands"}, "theme": {"ink"}, "rung": {"team"},
+ }, mine...); rec.Code != http.StatusSeeOther {
+ t.Fatalf("raising the rung: status %d\n%s", rec.Code, rec.Body)
+ }
+ mustGet(t, a, path, http.StatusOK, theirs)
+
+ mustGet(t, a, path+"/share", http.StatusNotFound, theirs)
+ if rec := post(t, a, path+"/share", nil, theirs...); rec.Code != http.StatusNotFound {
+ t.Errorf("POST %s/share as a member: status %d, want 404", path, rec.Code)
+ }
+
+ token := mintShareLink(t, a, path, mine)
+ page := mustGet(t, a, path+"/share", http.StatusOK, mine).Body.String()
+ m := revokeAction.FindStringSubmatch(page)
+ if m == nil {
+ t.Fatalf("no revoke form:\n%s", page)
+ }
+ if rec := post(t, a, path+"/share/"+m[1]+"/revoke", nil, theirs...); rec.Code != http.StatusNotFound {
+ t.Errorf("revoke as a member: status %d, want 404", rec.Code)
+ }
+ mustGet(t, a, "/s/"+token, http.StatusOK, nil)
+}
diff --git a/internal/slidestest/viewer_test.go b/internal/slidestest/viewer_test.go
new file mode 100644
index 0000000..dacc0e8
--- /dev/null
+++ b/internal/slidestest/viewer_test.go
@@ -0,0 +1,219 @@
+package slidestest
+
+import (
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+)
+
+// The sample deck's own words, as the seed writes them (sample.go).
+// Slide 2 and slide 3 are named here because the paging test needs two
+// slides that are told apart by their headings alone.
+const (
+ sampleSlide2Heading = "Text and marks"
+ sampleSlide3Heading = "Regions, not coordinates"
+)
+
+// sampleDeck signs a person in and returns the path of the deck their
+// first sign-in seeded. Eight slides, notes on every one — the fixture
+// every viewer test here wants, without writing one of its own.
+func sampleDeck(t *testing.T, a *testApp, cookies []*http.Cookie) string {
+ t.Helper()
+ list := mustGet(t, a, "/", http.StatusOK, cookies).Body.String()
+ path := deckPath.FindString(list)
+ if path == "" {
+ t.Fatalf("GET / lists no deck to view:\n%s", list)
+ }
+ return path
+}
+
+// slideSection returns the one <section slides-slide> on a viewer page,
+// so an assertion about "the slide being shown" cannot be satisfied by
+// the filmstrip, which names every other slide on the same page.
+func slideSection(t *testing.T, page string) string {
+ t.Helper()
+ if n := strings.Count(page, "<section slides-slide"); n != 1 {
+ t.Fatalf("the viewer renders %d slides, want exactly 1:\n%s", n, page)
+ }
+ start := strings.Index(page, "<section slides-slide")
+ end := strings.Index(page[start:], "</section>")
+ if end < 0 {
+ t.Fatalf("the rendered slide is not closed:\n%s", page)
+ }
+ return page[start : start+end]
+}
+
+// TestViewerPagesThroughSlides: ?slide=N is the whole viewer. Slide 2
+// shows the second slide and only the second slide, the next link goes
+// to 3, the previous link goes to 1, and the counter says where you
+// are in words rather than only in a URL.
+func TestViewerPagesThroughSlides(t *testing.T) {
+ a := newApp(t)
+ cookies := signedIn(t, a, allowedAddress)
+ path := sampleDeck(t, a, cookies)
+
+ page := mustGet(t, a, path+"?slide=2", http.StatusOK, cookies).Body.String()
+
+ section := slideSection(t, page)
+ if !strings.Contains(section, sampleSlide2Heading) {
+ t.Errorf("?slide=2 does not render the second slide:\n%s", section)
+ }
+ if strings.Contains(section, sampleSlide3Heading) {
+ t.Errorf("?slide=2 rendered the third slide too:\n%s", section)
+ }
+ if !strings.Contains(page, `href="`+path+`?slide=3"`) {
+ t.Errorf("no next link to slide 3:\n%s", page)
+ }
+ if !strings.Contains(page, `href="`+path+`?slide=1"`) {
+ t.Errorf("no previous link to slide 1:\n%s", page)
+ }
+ if !strings.Contains(page, "2 of 8") {
+ t.Errorf("the counter does not say which slide this is:\n%s", page)
+ }
+
+ // The filmstrip names every slide by its own heading and links to
+ // it — that is how a person jumps rather than clicking next seven
+ // times.
+ if !strings.Contains(page, sampleSlide3Heading) {
+ t.Errorf("the filmstrip does not name the third slide:\n%s", page)
+ }
+ if !strings.Contains(page, `href="`+path+`?slide=8"`) {
+ t.Errorf("the filmstrip does not link the last slide:\n%s", page)
+ }
+
+ // The ends are ends: no link off either edge of the deck.
+ first := mustGet(t, a, path, http.StatusOK, cookies).Body.String()
+ if strings.Contains(first, `?slide=0"`) {
+ t.Errorf("the first slide offers a previous link:\n%s", first)
+ }
+ if !strings.Contains(first, "1 of 8") {
+ t.Errorf("no ?slide= does not default to the first slide:\n%s", first)
+ }
+ last := mustGet(t, a, path+"?slide=8", http.StatusOK, cookies).Body.String()
+ if strings.Contains(last, `?slide=9"`) {
+ t.Errorf("the last slide offers a next link:\n%s", last)
+ }
+
+ // Off the end is not a clamp and not a 500: it is a page that does
+ // not exist.
+ mustGet(t, a, path+"?slide=9", http.StatusNotFound, cookies)
+ mustGet(t, a, path+"?slide=0", http.StatusNotFound, cookies)
+ mustGet(t, a, path+"?slide=two", http.StatusNotFound, cookies)
+}
+
+// TestViewerOnPhoneViewportReflows: ?vp=phone is the settings screen's
+// "preview on a phone" — the same page, rendered for the other
+// viewport, and the viewport travels with every link on it.
+func TestViewerOnPhoneViewportReflows(t *testing.T) {
+ a := newApp(t)
+ cookies := signedIn(t, a, allowedAddress)
+ path := sampleDeck(t, a, cookies)
+
+ page := mustGet(t, a, path+"?slide=2&vp=phone", http.StatusOK, cookies).Body.String()
+ if !strings.Contains(page, `data-vp="phone"`) {
+ t.Errorf("?vp=phone does not render the phone viewport:\n%s", page)
+ }
+ // Paging out of the preview would be a trap: every link keeps it.
+ if !strings.Contains(page, `href="`+path+`?slide=3&amp;vp=phone"`) {
+ t.Errorf("the next link drops the phone viewport:\n%s", page)
+ }
+
+ desktop := mustGet(t, a, path+"?slide=2", http.StatusOK, cookies).Body.String()
+ if strings.Contains(desktop, `data-vp="phone"`) {
+ t.Errorf("the desktop viewer claims the phone viewport:\n%s", desktop)
+ }
+}
+
+// TestViewerShowsNotesToItsPeople: the notes are under the slide for
+// anyone who reached the deck signed in, and the notes page is all of
+// them at once — the scriptless presenter's crib sheet (spec §8).
+func TestViewerShowsNotesToItsPeople(t *testing.T) {
+ a := newApp(t)
+ cookies := signedIn(t, a, allowedAddress)
+ path := sampleDeck(t, a, cookies)
+
+ page := mustGet(t, a, path+"?slide=2", http.StatusOK, cookies).Body.String()
+ if !strings.Contains(page, "<details") {
+ t.Errorf("the viewer does not carry the notes disclosure:\n%s", page)
+ }
+ if !strings.Contains(page, "template. Text arrives as a small Markdown subset") {
+ t.Errorf("the viewer does not show slide 2's notes:\n%s", page)
+ }
+
+ notes := mustGet(t, a, path+"/notes", http.StatusOK, cookies).Body.String()
+ for _, heading := range []string{sampleSlide2Heading, sampleSlide3Heading, "Part two"} {
+ if !strings.Contains(notes, heading) {
+ t.Errorf("the notes page does not name the slide %q:\n%s", heading, notes)
+ }
+ }
+ if !strings.Contains(notes, "chapter break") {
+ t.Errorf("the notes page does not carry slide 4's notes:\n%s", notes)
+ }
+}
+
+// TestViewerOwnerButtons: settings and share are the owner's, and a
+// team member reading the same deck is offered neither.
+func TestViewerOwnerButtons(t *testing.T) {
+ a := newSharedApp(t)
+ mine := signedIn(t, a, allowedAddress)
+ theirs := signedIn(t, a, otherAddress)
+
+ path := createDeck(t, a, mine, "All hands")
+ if rec := post(t, a, path+"/settings", url.Values{
+ "title": {"All hands"}, "theme": {"ink"}, "rung": {"team"},
+ }, mine...); rec.Code != http.StatusSeeOther {
+ t.Fatalf("raising the rung: status %d\n%s", rec.Code, rec.Body)
+ }
+
+ owner := mustGet(t, a, path, http.StatusOK, mine).Body.String()
+ if !strings.Contains(owner, path+"/settings") {
+ t.Errorf("the owner's viewer offers no settings:\n%s", owner)
+ }
+ if !strings.Contains(owner, path+"/share") {
+ t.Errorf("the owner's viewer offers no share page:\n%s", owner)
+ }
+
+ member := mustGet(t, a, path, http.StatusOK, theirs).Body.String()
+ if strings.Contains(member, path+"/settings") {
+ t.Errorf("a member's viewer offers the owner's settings:\n%s", member)
+ }
+ if strings.Contains(member, path+"/share") {
+ t.Errorf("a member's viewer offers the owner's share page:\n%s", member)
+ }
+ // And the screens themselves are the owner's, not just their links.
+ mustGet(t, a, path+"/share", http.StatusNotFound, theirs)
+}
+
+// TestPresentSurfaceIsBare: ?present=1 is the scriptless presenting
+// page — one slide, two links, and none of the chrome. Task 6 replaces
+// it with the live fullscreen surface; this is the floor it has to keep
+// (spec §8), and the assertion that matters most is the notes, which
+// are for the presenter's own screen and not for the projector.
+func TestPresentSurfaceIsBare(t *testing.T) {
+ a := newApp(t)
+ cookies := signedIn(t, a, allowedAddress)
+ path := sampleDeck(t, a, cookies)
+
+ page := mustGet(t, a, path+"?slide=2&present=1", http.StatusOK, cookies).Body.String()
+ if !strings.Contains(page, "slides-present") {
+ t.Errorf("?present=1 does not mark the presenting wrapper:\n%s", page)
+ }
+ slideSection(t, page)
+ if strings.Contains(page, "Speaker notes") {
+ t.Errorf("the presenting page carries the speaker notes:\n%s", page)
+ }
+ // The markup, not the attribute: the page's own stylesheet names
+ // [slides-film] in a rule whether or not a filmstrip is rendered.
+ if strings.Contains(page, "<ol slides-film") {
+ t.Errorf("the presenting page carries the filmstrip:\n%s", page)
+ }
+ if strings.Contains(page, path+"/settings") {
+ t.Errorf("the presenting page carries the deck's buttons:\n%s", page)
+ }
+ // Paging stays in the presenting surface rather than dropping back
+ // into the shell halfway through a talk.
+ if !strings.Contains(page, `href="`+path+`?slide=3&amp;present=1"`) {
+ t.Errorf("the presenting page's next link leaves it:\n%s", page)
+ }
+}