oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

1// thumbs.go serves a slide as a picture (spec §4.2): one SVG per
2// slide, on every surface a deck can be read from.
3//
4// Four routes, one body:
5//
6// GET /d/{sqid}/slides/{id}.svg the session cookie
7// GET /s/{token}/slides/{id}.svg a share link
8// GET /p/{session}/slides/{id}.svg a talk in progress
9// GET /api/v1/decks/{sqid}/slides/{id}.svg a bearer token
10//
11// Each is the deck's own capability with the slide on the end, exactly
12// as the asset routes are (assets.go's comment on why there are four of
13// those). What differs between them is who is let in and where the
14// pictures inside come from; what does not differ is the picture, which
15// is internal/slides/layout measured and internal/slides/svg written,
16// the same two calls the PDF makes.
17//
18// # Why an SVG and not the HTML
19//
20// The editor strip used to hold nine copies of a slide's whole DOM,
21// scaled down by a CSS transform: nine sections, every block, every
22// run, live in the page and re-laid-out on every resize. A thumbnail is
23// now one <img>. It is smaller to send, cheaper to paint, and — the
24// part that matters — it is the SAME picture the export is, because
25// both come off one paint list.
26//
27// The `.html` route stays (editor.go's deckSlide): the stage swaps real
28// markup in, because the stage is what a person types into.
29//
30// # Caching
31//
32// The tag is the deck's seq, the slide's id and the requested width.
33// Seq changes on every accepted edit, so a strip that re-fetches nine
34// thumbnails after a keystroke gets nine 304s for the eight that did
35// not change. must-revalidate rather than a max-age: a thumbnail is a
36// picture of a document being edited, and one that is thirty seconds
37// stale is a bug report.
38package slides
39
40import (
41 "context"
42 "net/http"
43 "strconv"
44 "strings"
45
46 "github.com/go-chi/chi/v5"
47
48 "amadan.net/oficina/slides/internal/slides/deck"
49 "amadan.net/oficina/slides/internal/slides/layout"
50 "amadan.net/oficina/slides/internal/slides/svg"
51)
52
53// thumbMaxWidth bounds the ?w= a caller may ask for. Nothing rasters
54// today — an SVG scales, so the parameter changes nothing about the
55// bytes — but it is READ and it is in the ETag, so the day a raster
56// twin lands, every cache in between already has the width in its key
57// rather than one PNG standing in for every size.
58const thumbMaxWidth = 4096
59
60// thumbWidth reads ?w=. Anything that is not a plausible width is
61// dropped rather than refused: it is a rendering hint on a read, and a
62// caller that sent a typo should get the slide.
63func thumbWidth(raw string) string {
64 n, err := strconv.Atoi(strings.TrimSpace(raw))
65 if err != nil || n <= 0 || n > thumbMaxWidth {
66 return ""
67 }
68 return strconv.Itoa(n)
69}
70
71// --- GET /s/{token}/slides/{id}.svg ---
72
73// sharedSlideThumb serves a thumbnail to somebody holding a share link.
74// The token is the whole capability, as it is for the page and for the
75// images on it.
76func (a *app) sharedSlideThumb(w http.ResponseWriter, r *http.Request) {
77 d, _, ok := a.deckForShareLink(w, r, chi.URLParam(r, "token"))
78 if !ok {
79 return
80 }
81 a.serveThumb(w, r, d, "/s/"+chi.URLParam(r, "token"), func() { a.notFound(w, r) })
82}
83
84// --- GET /p/{session}/slides/{id}.svg ---
85
86// presentSlideThumb serves a thumbnail to a screen watching a talk —
87// the presenter's own notes window and the remote in a pocket, both of
88// which show the current slide and the next one small.
89func (a *app) presentSlideThumb(w http.ResponseWriter, r *http.Request) {
90 p, ok := a.presentationFor(w, r)
91 if !ok {
92 return
93 }
94 d, ok := a.deckOf(w, r, p)
95 if !ok {
96 return
97 }
98 a.serveThumb(w, r, d, "/p/"+p.Sqid, func() { a.notFound(w, r) })
99}
100
101// serveThumb is the whole body, once.
102//
103// base is the surface's own root — "/d/{sqid}", "/s/{token}",
104// "/p/{session}" — and the images inside the picture are served from
105// base + "/assets", so a thumbnail fetched by an unauthenticated
106// audience does not point at a route only a member can reach.
107//
108// missing is how THIS surface says "no such slide": the app's own 404
109// page on the three browser routes, the API's JSON shape on the fourth.
110// Nothing about the answer differs but its wrapper, and passing it in
111// is what keeps a bearer-token caller from being handed HTML.
112func (a *app) serveThumb(w http.ResponseWriter, r *http.Request, d Deck, base string, missing func()) {
113 slideID, ok := strings.CutSuffix(chi.URLParam(r, "file"), ".svg")
114 if !ok || slideID == "" {
115 missing()
116 return
117 }
118 width := thumbWidth(r.URL.Query().Get("w"))
119
120 // Seq, slide and width: the three things that change these bytes.
121 // The tag goes on the two responses that ARE this representation
122 // and never on the 404 — a tag on "there is no such slide" invites
123 // a cache to treat the absence as the thing (editor.go says the
124 // same about the HTML twin).
125 etag := strconv.Quote(strconv.FormatInt(d.Seq, 10) + "-" + slideID + "-" + width)
126 if matchesETag(r.Header.Get("If-None-Match"), etag) {
127 thumbHeaders(w, etag)
128 w.WriteHeader(http.StatusNotModified)
129 return
130 }
131
132 h, view, err := a.openDeck(r.Context(), d)
133 if err != nil {
134 a.fail(w, r, "open a deck to draw a thumbnail", err)
135 return
136 }
137 defer h.Close(r.Context())
138
139 index := -1
140 for i, s := range view.Slides {
141 if s.ID == slideID {
142 index = i
143 break
144 }
145 }
146 if index < 0 {
147 missing()
148 return
149 }
150
151 out, err := a.thumbnail(r, d, view, index, base)
152 if err != nil {
153 a.fail(w, r, "draw a thumbnail", err)
154 return
155 }
156 thumbHeaders(w, etag)
157 w.Header().Set("Content-Length", strconv.Itoa(len(out)))
158 w.WriteHeader(http.StatusOK)
159 w.Write(out)
160}
161
162// thumbnail is the picture itself: measure, then write.
163//
164// The label is the slide's own (viewer.go's slideLabel — its first
165// heading, or "Slide n"), and it goes in the document's <title>, where
166// role="img" makes it the name of the whole picture. The <img> that
167// shows it carries the same words in its alt, because an alt is what a
168// browser reads and it never opens the file to find a title.
169func (a *app) thumbnail(r *http.Request, d Deck, view *deck.Deck, index int, base string) ([]byte, error) {
170 paint, warnings, err := layout.Layout(view, index)
171 if err != nil {
172 return nil, err
173 }
174 // §11's degrade-loudly rule, at the volume a thumbnail deserves:
175 // the picture is drawn either way, and a person is not shown a
176 // warning about a slide they are looking at nine of. The export
177 // (task 4) is where these reach somebody.
178 for _, warn := range warnings {
179 a.logger.Debug("slides: thumbnail warning",
180 "deck", d.Sqid, "slide", view.Slides[index].ID, "kind", warn.Kind, "detail", warn.Detail)
181 }
182 assets := assetBaseFor(base)
183 return svg.Encode(paint, svg.Options{
184 Title: slideLabel(r, view.Slides[index], index+1),
185 InlineImages: a.assetBytes(r.Context(), d, view),
186 AssetURL: func(id string) string {
187 asset, ok := view.Assets[id]
188 if !ok || !assetRefPattern.MatchString(asset.Ref) {
189 return ""
190 }
191 return assets + "/" + asset.Ref
192 },
193 }), nil
194}
195
196// assetBytes reads an asset's bytes for the serialiser to inline.
197//
198// It has to inline. A thumbnail is shown in an `<img src>`, and a
199// browser renders an SVG referenced that way in a mode that fetches
200// nothing at all: an <image href="/d/…/assets/…"> inside one is simply
201// not loaded, however good the capability on it is. So the bytes go in
202// as a data URI or the picture is not in the thumbnail. (The URL is
203// still written when this cannot resolve one — that is the case where
204// somebody opened the .svg itself, where it does load.)
205//
206// The verified-asset row is checked, exactly as serveAsset checks it
207// and for exactly the same reason: registerAsset READ the bytes and
208// sanitised the SVG among them, and the row is the record that it did.
209// Without it a member could presign a PUT for an unsanitised SVG, name
210// its ref in a `register_asset` op — which nothing checks — and have it
211// spliced into a document this app serves from its own origin.
212//
213// Returns nil when this deployment has no object store, which turns
214// InlineImages off in the serialiser rather than making it ask nine
215// times.
216//
217// A context rather than the request: the PDF export uses this same
218// reader (exports.go), and a job outlives its request.
219func (a *app) assetBytes(ctx context.Context, d Deck, view *deck.Deck) func(string) ([]byte, string, bool) {
220 store := a.cfg.Blobs
221 if store == nil {
222 return nil
223 }
224 var verified []string
225 if err := a.db.G.WithContext(ctx).Model(&DeckAsset{}).
226 Where("deck_id = ?", d.ID).Pluck("ref", &verified).Error; err != nil {
227 a.logger.Error("slides: list verified assets for a thumbnail", "deck", d.Sqid, "err", err)
228 return nil
229 }
230 if len(verified) == 0 {
231 return nil
232 }
233 ok := make(map[string]bool, len(verified))
234 for _, ref := range verified {
235 ok[ref] = true
236 }
237
238 // One slide can name one asset several times (a picture in two
239 // columns), and a background is an op of its own. Read once.
240 seen := map[string][]byte{}
241 return func(id string) ([]byte, string, bool) {
242 asset, found := view.Assets[id]
243 if !found || !assetRefPattern.MatchString(asset.Ref) || !ok[asset.Ref] {
244 return nil, "", false
245 }
246 raw, read := seen[asset.Ref]
247 if !read {
248 b, err := a.readAsset(ctx, store, asset.Ref)
249 if err != nil {
250 // Not a failure of the request: the slide is drawn
251 // without the picture, which is what the browser shows
252 // for a broken <img> anyway.
253 a.logger.Error("slides: read an asset for a thumbnail",
254 "deck", d.Sqid, "ref", asset.Ref, "err", err)
255 b = nil
256 }
257 seen[asset.Ref] = b
258 raw = b
259 }
260 if len(raw) == 0 {
261 return nil, "", false
262 }
263 kind := sniffImage(raw)
264 if kind == "" {
265 return nil, "", false
266 }
267 return raw, kind, true
268 }
269}
270
271// thumbHeaders is the header set a thumbnail carries, on the 200 and on
272// the 304 alike.
273//
274// The Content-Security-Policy is the same argument assetHeaders makes:
275// an SVG comes back from this app's OWN origin, so anything executable
276// inside one would run with every cookie this app has. Nothing here
277// writes a script and every embedded picture was sanitised at
278// registration — and this says that even a document that somehow got
279// past both can load nothing and reach nowhere. img-src is 'self' and
280// data: because those are the two places a picture inside one legally
281// comes from: the surface's own asset route, and the bytes inlined into
282// the file.
283//
284// private, because the URL is the deck's capability and not the
285// object's: a shared cache must not hand this to the next person who
286// asks.
287func thumbHeaders(w http.ResponseWriter, etag string) {
288 w.Header().Set("Content-Type", "image/svg+xml; charset=utf-8")
289 w.Header().Set("X-Content-Type-Options", "nosniff")
290 w.Header().Set("Content-Security-Policy",
291 "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:")
292 w.Header().Set("ETag", etag)
293 w.Header().Set("Cache-Control", "private, max-age=0, must-revalidate")
294}
295