| 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. |
| 38 | package slides |
| 39 | |
| 40 | import ( |
| 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. |
| 58 | const 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. |
| 63 | func 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. |
| 76 | func (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. |
| 89 | func (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. |
| 112 | func (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. |
| 169 | func (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. |
| 219 | func (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. |
| 287 | func 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 | |