oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

1// share.go is the read-only share link: minting one, listing them,
2// revoking one, and the unauthenticated screen a token opens.
3//
4// It is calendar's feed pattern (its calendar design §6, spec §5 here),
5// and the two rules that make it safe are worth stating where they are
6// implemented rather than only in the model's comment:
7//
8// - Only the SHA-256 of the token is stored. The token is 32 bytes of
9// randomness, so there is no dictionary to defend against and no
10// salt to add; what an unsalted hash buys is that resolving a URL
11// is one indexed read.
12// - Because only the hash is stored, the URL can be shown exactly
13// once — at the moment of minting. The page says so there, rather
14// than letting somebody find out by coming back for it.
15//
16// A missing token and a revoked one get the same 404. Anything else
17// would let a stranger with a guess learn that a link had once been
18// real.
19package slides
20
21import (
22 "crypto/rand"
23 "crypto/sha256"
24 "encoding/base64"
25 "encoding/hex"
26 "encoding/json"
27 "errors"
28 "fmt"
29 "io"
30 "net/http"
31 "strconv"
32 "time"
33
34 "github.com/go-chi/chi/v5"
35 "gorm.io/gorm"
36
37 "amadan.net/rastrillo/rastrillo"
38
39 "amadan.net/oficina/slides/internal/slides/present"
40)
41
42// shareTokenBytes is how much randomness a share link carries. Thirty
43// two bytes is 256 bits, which is not guessable by anything, and
44// base64url makes it 43 characters of URL — long enough that nobody
45// will type it and short enough to paste into a chat window.
46const shareTokenBytes = 32
47
48// newShareToken mints one. base64url without padding, so the token is
49// URL-safe with nothing to escape and nothing to trim.
50func newShareToken() (string, error) {
51 raw := make([]byte, shareTokenBytes)
52 if _, err := rand.Read(raw); err != nil {
53 return "", fmt.Errorf("slides: generate share token: %w", err)
54 }
55 return base64.RawURLEncoding.EncodeToString(raw), nil
56}
57
58// hashShareToken is the stored form: SHA-256 hex, unsalted, for the
59// reason in this file's own comment.
60func hashShareToken(token string) string {
61 sum := sha256.Sum256([]byte(token))
62 return hex.EncodeToString(sum[:])
63}
64
65// shareURL is the whole link as a person will paste it. a.cfg.Origin,
66// not the request's Host: the origin is what the app resolved at boot
67// and refused to start without (config.go), so a link minted behind a
68// proxy that lies about Host is still the URL that works.
69func (a *app) shareURL(token string) string {
70 return a.cfg.Origin + "/s/" + token
71}
72
73// --- GET /d/{sqid}/share ---
74
75type shareRow struct {
76 Created string
77 Views string
78 StatusLabel string
79 StatusTone string
80 RevokeAction string
81 RevokeLabel string
82 RevokeAria string
83 Revoked bool
84}
85
86type sharePage struct {
87 shell
88 Title string
89 DeckTitle string
90 Lead string
91 WarningTitle string
92 Warning string
93 BackHref string
94 BackLabel string
95 Action string
96 CreateLabel string
97
98 // NewURL is the link this request minted, shown once and never
99 // again. Empty on every visit that did not just create one.
100 NewURL string
101 NewTitle string
102 NewHint string
103 NewLabel string
104 CopyHint string
105
106 Links []shareRow
107 EmptyTitle string
108 EmptyBody string
109}
110
111// deckShare serves GET /d/{sqid}/share: this deck's links, what each
112// one has been used for, and the one form that makes another.
113//
114// The owner's alone. A team member can read the deck, and handing them
115// the power to mint an unauthenticated read capability for it is a
116// different decision from letting them read it — one the owner made
117// when they raised the rung, and not one they made for the whole
118// internet.
119func (a *app) deckShare(w http.ResponseWriter, r *http.Request) {
120 u, ok := a.viewer(w, r)
121 if !ok {
122 return
123 }
124 d, ok := a.ownedDeckFor(w, r, u)
125 if !ok {
126 return
127 }
128 a.renderSharePage(w, r, u, d, r.URL.Query().Get("new"))
129}
130
131func (a *app) renderSharePage(w http.ResponseWriter, r *http.Request, u User, d Deck, newToken string) {
132 var links []ShareLink
133 if err := a.db.G.WithContext(r.Context()).
134 Where("deck_id = ?", d.ID).
135 Order("created_at DESC, id DESC").Find(&links).Error; err != nil {
136 a.fail(w, r, "list share links", err)
137 return
138 }
139
140 base := "/d/" + d.Sqid
141 page := sharePage{
142 shell: a.shellFor(r, u, sharePageFrame(r, d)),
143 Title: rastrillo.T(r, "slides.share.title"),
144 DeckTitle: d.Title,
145 Lead: rastrillo.T(r, "slides.share.lead"),
146 WarningTitle: rastrillo.T(r, "slides.share.warning.title"),
147 Warning: rastrillo.T(r, "slides.share.warning"),
148 BackHref: base,
149 BackLabel: rastrillo.T(r, "slides.deck.back"),
150 Action: base + "/share",
151 CreateLabel: rastrillo.T(r, "slides.share.create"),
152 EmptyTitle: rastrillo.T(r, "slides.share.empty.title"),
153 EmptyBody: rastrillo.T(r, "slides.share.empty.body"),
154 Links: make([]shareRow, 0, len(links)),
155 }
156 if newToken != "" {
157 page.NewURL = a.shareURL(newToken)
158 page.NewTitle = rastrillo.T(r, "slides.share.new.title")
159 page.NewHint = rastrillo.T(r, "slides.share.new.body")
160 page.NewLabel = rastrillo.T(r, "slides.share.new.label")
161 page.CopyHint = rastrillo.T(r, "slides.share.new.copy")
162 }
163 for _, l := range links {
164 when := whenWords(r, l.CreatedAt)
165 row := shareRow{
166 Created: rastrillo.Tf(r, "slides.share.row.created", "when", when),
167 Views: viewWords(r, l.Views),
168 Revoked: l.RevokedAt != nil,
169 RevokeAction: fmt.Sprintf("%s/share/%d/revoke", base, l.ID),
170 RevokeLabel: rastrillo.T(r, "slides.share.revoke"),
171 // A share link has no name — the URL is shown once and
172 // never again — so the date it was made is the only thing
173 // that tells one row from another. agents.html's rule
174 // ("Revoke" repeated down a list says which nothing),
175 // applied with the only handle this list has.
176 RevokeAria: rastrillo.Tf(r, "slides.share.revoke.aria", "when", when),
177 }
178 if row.Revoked {
179 row.StatusLabel = rastrillo.T(r, "slides.share.revoked")
180 row.StatusTone = "neutral"
181 } else {
182 row.StatusLabel = rastrillo.T(r, "slides.share.active")
183 row.StatusTone = "positive"
184 }
185 page.Links = append(page.Links, row)
186 }
187 render(w, "share", page)
188}
189
190// viewWords is a link's use, as words. Two keys because English has two
191// forms and the catalog has no plural machinery — the same shape
192// decks.go's rowSub uses.
193func viewWords(r *http.Request, n int64) string {
194 switch n {
195 case 0:
196 return rastrillo.T(r, "slides.share.row.views.none")
197 case 1:
198 return rastrillo.T(r, "slides.share.row.views.one")
199 default:
200 return rastrillo.Tf(r, "slides.share.row.views", "n", n)
201 }
202}
203
204// --- POST /d/{sqid}/share ---
205
206// mintShareLink serves POST /d/{sqid}/share: one token, its hash
207// stored, and a 303 back to the share page carrying the token so it can
208// be shown once.
209//
210// The token rides in the redirect's query string, which is calendar's
211// shape too. It is a real cost — a URL with a live capability in it
212// lands in the browser's history and in any proxy log between here and
213// the person — and it is bounded: the redirect is same-origin, the
214// page it lands on is the owner's own, and there is no third party to
215// leak a Referer to. The alternative, a flash cookie, moves the same
216// secret into a different log.
217func (a *app) mintShareLink(w http.ResponseWriter, r *http.Request) {
218 u, ok := a.viewer(w, r)
219 if !ok {
220 return
221 }
222 d, ok := a.ownedDeckFor(w, r, u)
223 if !ok {
224 return
225 }
226
227 token, err := newShareToken()
228 if err != nil {
229 a.fail(w, r, "mint a share token", err)
230 return
231 }
232 link := ShareLink{
233 DeckID: d.ID,
234 TokenHash: hashShareToken(token),
235 Actor: actorFor(u),
236 CreatedAt: time.Now().UTC(),
237 }
238 if err := a.db.G.WithContext(r.Context()).Create(&link).Error; err != nil {
239 a.fail(w, r, "create share link", err)
240 return
241 }
242 http.Redirect(w, r, "/d/"+d.Sqid+"/share?new="+token, http.StatusSeeOther)
243}
244
245// --- POST /d/{sqid}/share/{id}/revoke ---
246
247// revokeShareLink serves POST /d/{sqid}/share/{id}/revoke.
248//
249// Scoped by deck as well as by id, so an id belonging to another deck
250// is a 404 rather than a revocation somebody was not entitled to make;
251// the deck itself is already the owner's (ownedDeckFor).
252//
253// Revoking twice is not an error. The URL is off either way, and a
254// second POST from a double-clicked button should not look like a
255// failure.
256func (a *app) revokeShareLink(w http.ResponseWriter, r *http.Request) {
257 u, ok := a.viewer(w, r)
258 if !ok {
259 return
260 }
261 d, ok := a.ownedDeckFor(w, r, u)
262 if !ok {
263 return
264 }
265 id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
266 if err != nil {
267 a.notFound(w, r)
268 return
269 }
270
271 now := time.Now().UTC()
272 // revoked_at IS NULL in the WHERE, so a second POST cannot move the
273 // timestamp: when a link was turned off is a fact about it, and a
274 // double-clicked button must not rewrite it.
275 res := a.db.G.WithContext(r.Context()).Model(&ShareLink{}).
276 Where("id = ? AND deck_id = ? AND revoked_at IS NULL", id, d.ID).
277 Update("revoked_at", now)
278 if res.Error != nil {
279 a.fail(w, r, "revoke share link", res.Error)
280 return
281 }
282 if res.RowsAffected == 0 {
283 // Either the link is already revoked — in which case the URL is
284 // off, the person got what they asked for, and an error page
285 // would be a lie — or the id is not this deck's, which is a 404
286 // like every other row that is not yours.
287 var n int64
288 if err := a.db.G.WithContext(r.Context()).Model(&ShareLink{}).
289 Where("id = ? AND deck_id = ?", id, d.ID).Count(&n).Error; err != nil {
290 a.fail(w, r, "check a share link", err)
291 return
292 }
293 if n == 0 {
294 a.notFound(w, r)
295 return
296 }
297 http.Redirect(w, r, "/d/"+d.Sqid+"/share", http.StatusSeeOther)
298 return
299 }
300 // The row carries the actor who minted it, not the one who revoked
301 // it — the model has one actor column and this screen is the
302 // owner's alone, so the two are the same person in every path that
303 // exists today. Logged rather than left unrecorded, so the day a
304 // second person can reach this screen there is a trail.
305 a.logger.Info("slides: share link revoked",
306 "deck", d.Sqid, "link", id, "actor", actorFor(u))
307 http.Redirect(w, r, "/d/"+d.Sqid+"/share", http.StatusSeeOther)
308}
309
310// --- GET /s/{token} ---
311
312// sharedDeck serves the share link itself, outside the session group:
313// the whole point of the capability is that it works for somebody who
314// has no account here.
315//
316// It renders the same viewer every other reader gets (viewer.go), in
317// link mode — no notes, no owner screens, no filmstrip link into
318// anything but another slide.
319func (a *app) sharedDeck(w http.ResponseWriter, r *http.Request) {
320 token := chi.URLParam(r, "token")
321 d, link, ok := a.deckForShareLink(w, r, token)
322 if !ok {
323 return
324 }
325
326 // Best effort, and deliberately outside the render path's error
327 // handling: a deck that will not load is a real problem, and a
328 // view count one short is not. Counted on resolution rather than
329 // on a successful render, so the number means "the link was
330 // opened" — which is the question the share page is asking.
331 if err := a.db.G.WithContext(r.Context()).Model(&ShareLink{}).
332 Where("id = ?", link.ID).
333 Update("views", gorm.Expr("views + 1")).Error; err != nil {
334 a.logger.Error("slides: count a share link view", "link", link.ID, "err", err)
335 }
336
337 // Following the presenter (spec §3.4). The share link is the whole
338 // capability here too — really, and not only for this one request
339 // (codex 5): the audience never learns the session's own address,
340 // never mind the remote's, so ?follow=1 is a flag on the link they
341 // already hold rather than a second URL to hand out, and every
342 // later read they make comes back through here or through
343 // /s/{token}/stream, both of which resolve the token again.
344 //
345 // Nothing to follow is a redirect back to the deck, not an error:
346 // the talk finishing while somebody was watching is the ordinary
347 // end of a session, and the honest answer is the deck they can
348 // still read.
349 if r.URL.Query().Get("follow") == "1" {
350 if p, live := a.liveSessionFor(r, d.ID); live {
351 a.followPresenter(w, r, d, p, "/s/"+token)
352 return
353 }
354 http.Redirect(w, r, "/s/"+token, http.StatusSeeOther)
355 return
356 }
357
358 // A stranger's page: the brand is plain text and "Sign in" carries
359 // no return path, because this path is the capability.
360 a.renderViewer(w, r, d, modeLink, "/s/"+token, a.publicShell(r, frame{parts: []string{d.Title}, public: true}))
361}
362
363// --- GET /s/{token}/stream ---
364
365// sharedStream is the audience's SSE feed for whatever talk is live on
366// this deck — the presentation stream, behind the share link instead of
367// behind the session sqid (codex 5).
368//
369// It exists so that a follower's stream is a thing their own link can
370// be revoked out of. The events are the session's own, unchanged; what
371// differs is who is allowed to hear them and for how long. The link is
372// re-resolved before every write, so revoking it ends the stream within
373// one event or one heartbeat rather than at the end of the talk.
374//
375// Nothing to follow is not an error. A talk that finished while
376// somebody was watching gets one `ended` event, which is exactly what
377// the script does with a talk that ends any other way: reload, and
378// land on the deck they can still read.
379func (a *app) sharedStream(w http.ResponseWriter, r *http.Request) {
380 token := chi.URLParam(r, "token")
381 d, _, ok := a.deckForShareLink(w, r, token)
382 if !ok {
383 return
384 }
385 p, live := a.liveSessionFor(r, d.ID)
386 if !live {
387 a.endedStream(w, r)
388 return
389 }
390 a.streamPresentation(w, r, p, func() bool {
391 return a.shareLinkLive(r, token)
392 })
393}
394
395// shareLinkLive re-asks the one question deckForShareLink asks: is
396// this token still a link nobody has revoked. One indexed read, and no
397// answer written — the caller is mid-stream and has nothing to say to
398// a client but "goodbye".
399//
400// A database error reads as "not live", because failing closed on a
401// capability check is the only direction that is ever safe: the cost is
402// a follower whose page reconnects, and the alternative is a revoked
403// link that keeps working through a blip.
404func (a *app) shareLinkLive(r *http.Request, token string) bool {
405 if token == "" {
406 return false
407 }
408 var n int64
409 if err := a.db.G.WithContext(r.Context()).Model(&ShareLink{}).
410 Where("token_hash = ? AND revoked_at IS NULL", hashShareToken(token)).
411 Count(&n).Error; err != nil {
412 a.logger.Error("slides: re-check a share link mid-stream", "err", err)
413 return false
414 }
415 return n > 0
416}
417
418// endedStream is one SSE event saying the talk is over, and then the
419// end of the response. The same shape presentStream's own connect
420// event has, so the script has one thing to understand rather than two.
421func (a *app) endedStream(w http.ResponseWriter, r *http.Request) {
422 fl, ok := w.(http.Flusher)
423 if !ok {
424 a.fail(w, r, "stream: the ResponseWriter does not flush", nil)
425 return
426 }
427 w.Header().Set("Content-Type", "text/event-stream")
428 w.Header().Set("Cache-Control", "no-store")
429 w.WriteHeader(http.StatusOK)
430 body, err := json.Marshal(present.Event{Ended: true})
431 if err != nil {
432 return
433 }
434 _, _ = io.WriteString(w, "event: slide\ndata: "+string(body)+"\n\n")
435 fl.Flush()
436}
437
438// deckForShareLink resolves a share token to the deck it opens and the
439// link row itself, answering 404 for a token that is missing, revoked
440// or malformed.
441//
442// Split out of sharedDeck because the asset route (assets.go) is the
443// second thing a share link has to be able to reach: the page renders
444// its images under /s/{token}/assets, so the picture is behind exactly
445// the capability the page is, and there is one lookup rather than two
446// that could come to different conclusions about a revoked link.
447func (a *app) deckForShareLink(w http.ResponseWriter, r *http.Request, token string) (Deck, ShareLink, bool) {
448 if token == "" {
449 a.notFound(w, r)
450 return Deck{}, ShareLink{}, false
451 }
452 var link ShareLink
453 err := a.db.G.WithContext(r.Context()).
454 Where("token_hash = ? AND revoked_at IS NULL", hashShareToken(token)).
455 Take(&link).Error
456 switch {
457 case err == nil:
458 case errors.Is(err, gorm.ErrRecordNotFound):
459 // Missing and revoked are the same answer. Not 410: a gone
460 // tells a stranger the URL they guessed was once real.
461 a.notFound(w, r)
462 return Deck{}, ShareLink{}, false
463 default:
464 a.fail(w, r, "resolve share link", err)
465 return Deck{}, ShareLink{}, false
466 }
467
468 var d Deck
469 if err := a.db.G.WithContext(r.Context()).Take(&d, link.DeckID).Error; err != nil {
470 if errors.Is(err, gorm.ErrRecordNotFound) {
471 a.notFound(w, r)
472 return Deck{}, ShareLink{}, false
473 }
474 a.fail(w, r, "load a shared deck", err)
475 return Deck{}, ShareLink{}, false
476 }
477 return d, link, true
478}
479
480// sharePageFrame is the share page's chrome. Appearance returns to the
481// bare path: this page's query can carry the one-time link (?new=), and
482// reflecting a query into the page would print it again.
483func sharePageFrame(r *http.Request, d Deck) frame {
484 f := onDeck(rastrillo.T(r, "slides.share.title"), d.Title)
485 f.ret = "/d/" + d.Sqid + "/share"
486 return f
487}
488