oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

1// assets.go is how a picture gets into a deck and back out of it
2// (spec §2.7).
3//
4// Three endpoints and one rule. The rule is that bytes go in the object
5// store and nowhere else — never a column, never this instance's disk,
6// which is ephemeral and hibernates (rastrillo §2) — and that they
7// travel browser-to-bucket rather than through this process:
8//
9// POST /d/{sqid}/uploads sign a PUT the browser makes itself
10// POST /d/{sqid}/assets verify what landed, hand back the record
11// GET /d/{sqid}/assets/{ref} the bytes, by the deck's own capability
12//
13// STAGE, THEN PROMOTE (codex 1, codex 2). The browser never names the
14// address its bytes will live at. The first endpoint mints a staging
15// key of this app's own — models.go's AssetUpload says how it is built
16// and why — writes the receipt for it, and signs a PUT for that key and
17// nothing else. The second endpoint will only look at bytes a receipt
18// says THIS principal staged for THIS deck; it reads them, hashes what
19// it actually read, and puts them back as a final object keyed by that
20// hash. So no browser is ever handed a PUT for an address anything is
21// already served from, and knowing a ref is never a way to get one
22// registered. Both of those were true the other way round until the
23// codex review, and both were reachable by any member of the team.
24//
25// The middle one is the shape worth reading twice. It does NOT write
26// the document. It answers with a validated record and the browser then
27// applies `register_asset` with it, so an asset arrives in the deck
28// through the same op path, the same update log and the same undo stack
29// as every other change. A server that wrote the Y.Doc behind the
30// editor's back would be a second writer with no seq, no actor and
31// nothing for undo to take back. What it DOES write is one row saying
32// the ref was verified for this deck (models.go's DeckAsset), because
33// the op is one any member can apply with any ref, and the GET requires
34// that row as well as the deck's map.
35//
36// The GET is keyed by the deck rather than global, and it is three
37// routes rather than one, because a slide is rendered on surfaces
38// authenticated four different ways: a session, a share token, a
39// presentation session. Each renders its images under its own
40// capability (render.Options.AssetBase) and each checks the ref against
41// THAT deck's `assets` map before serving a byte — so a share link to
42// one deck is not a way to read another deck's pictures.
43//
44// With no object store bound — which is this deployment today — the
45// sign endpoint answers 503 and the inspector says so in words. That is
46// the whole "no store" story: nothing half-works, and nothing falls
47// back to a place the bytes would be lost from.
48package slides
49
50import (
51 "bytes"
52 "context"
53 "crypto/rand"
54 "crypto/sha256"
55 "encoding/hex"
56 "encoding/json"
57 "errors"
58 "fmt"
59 "io"
60 "net/http"
61 "regexp"
62 "slices"
63 "strings"
64 "time"
65 "unicode/utf8"
66
67 "github.com/go-chi/chi/v5"
68 "gorm.io/gorm"
69 "gorm.io/gorm/clause"
70
71 "amadan.net/rastrillo/rastrillo"
72 "amadan.net/rastrillo/rastrillo/blobs"
73)
74
75// maxAssetBytes is spec §2.7's 20 MiB, enforced in three places that
76// each have to hold on their own: the sign endpoint refuses a size
77// bigger than this before minting a URL, the development PUT caps the
78// body it will read, and registration refuses an object that turns out
79// bigger than it was told. The first is a courtesy, the second and
80// third are the actual limits — a presigned PUT goes straight to the
81// bucket and this process never sees it.
82const maxAssetBytes = 20 << 20
83
84// assetTypes is §2.7's four, mapped to the extension a person's file
85// picker will have offered. Values are unused today and the map is the
86// allowlist; spelled as a map so adding a fifth is one line.
87var assetTypes = map[string]string{
88 "image/png": ".png",
89 "image/jpeg": ".jpg",
90 "image/webp": ".webp",
91 "image/svg+xml": ".svg",
92}
93
94// acceptTypes is assetTypes as a file input's accept attribute, sorted
95// so the markup is the same on every render. The picker offers exactly
96// what signUpload will admit, from the one map.
97func acceptTypes() string {
98 out := make([]string, 0, len(assetTypes))
99 for kind := range assetTypes {
100 out = append(out, kind)
101 }
102 slices.Sort(out)
103 return strings.Join(out, ",")
104}
105
106// maxAssetName bounds the filename a client sends. It is only ever
107// shown back to the person who uploaded it, but it is held in memory
108// and written into the document, so it is bounded like every other
109// string that arrives from outside.
110const maxAssetName = 200
111
112// maxAssetAlt bounds the alternative text. Long enough for the sentence
113// or two a description actually is; short enough that the field is not
114// a way to put a novel in the CRDT.
115const maxAssetAlt = 2000
116
117// maxAssetDimension bounds the width and height a client reports. They
118// come from the browser's own decode of the image, so they are
119// advisory — the renderer writes them onto the <img> so the layout does
120// not jump — and a number outside this is dropped rather than refused.
121const maxAssetDimension = 30000
122
123// assetRefPattern is what may be a ref in this app: one URL path
124// segment of unreserved characters. The object store mints a hex
125// SHA-256 (rastrillo/blobs is content-addressed), which passes. Kept in
126// step with render.safeRef's own pattern — the renderer writes these
127// into a src and this file reads them out of a path, and a value only
128// one of the two accepts is a broken image either way.
129var assetRefPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{1,128}$`)
130
131// presigner is the part of an object store that can hand a browser a
132// URL of its own. blobs.Store does not require it — blobs.Dir has
133// nothing to presign, being files in a directory — so this is a type
134// assertion at the point of use rather than a wider interface.
135//
136// Declared here rather than imported because rastrillo/blobs exports
137// the methods on *blobs.S3 concretely; naming the shape keeps this file
138// testable against a fake and keeps a second backend from needing a
139// change here.
140type presigner interface {
141 PresignPut(hash string, ttl time.Duration) (string, error)
142 PresignGet(hash string, ttl time.Duration) (string, error)
143}
144
145// presignTTL is how long a minted URL lives. An hour for a GET, because
146// that is the life of one person reading a deck; the same for a PUT,
147// because a 20 MiB file on a bad connection is not a five-minute job.
148const presignTTL = time.Hour
149
150// --- the staging key ---
151
152// stagingLife is how long a receipt is registrable for. Twice the
153// presigned PUT's own hour, because the PUT may be minted at the start
154// of that hour and land at the end of it, and registration happens
155// after the bytes are up. Long enough that a 20 MiB file on hotel wifi
156// still registers; short enough that a staged object nobody registered
157// stops being a thing this app will read.
158const stagingLife = 2 * presignTTL
159
160// newStagingKey mints the address one upload may be written to.
161//
162// It is a SHA-256 because that is the only shape rastrillo/blobs will
163// take as a key (blobs.checkHash), and it is a hash of the deck, the
164// actor and 32 fresh random bytes so the address is literally a
165// function of the principal and the deck it was signed for — codex 1's
166// "never a caller-chosen final address". The randomness is what makes
167// it unguessable; the domain prefix is what keeps it from ever
168// colliding with a content address, which is a hash of bytes and not of
169// this string.
170func newStagingKey(deckID int64, actor string) (string, error) {
171 nonce := make([]byte, 32)
172 if _, err := rand.Read(nonce); err != nil {
173 return "", fmt.Errorf("slides: generate a staging key: %w", err)
174 }
175 sum := sha256.Sum256([]byte(fmt.Sprintf("slides/asset-staging\x00%d\x00%s\x00%x", deckID, actor, nonce)))
176 return hex.EncodeToString(sum[:]), nil
177}
178
179// stagedUpload resolves one receipt: this staging key, staged by this
180// actor for this deck, not yet registered and not yet stale.
181//
182// All four conditions are the codex 2 fix in one query. The key alone
183// is not enough (that was the hole: a ref was), the deck alone is not
184// enough, and a receipt cannot be replayed into a second registration.
185func (a *app) stagedUpload(ctx context.Context, d Deck, actor, key string) (AssetUpload, bool, error) {
186 var up AssetUpload
187 err := a.db.G.WithContext(ctx).
188 Where("staging_key = ? AND deck_id = ? AND actor = ? AND consumed_at IS NULL AND created_at > ?",
189 key, d.ID, actor, time.Now().UTC().Add(-stagingLife)).
190 Take(&up).Error
191 switch {
192 case err == nil:
193 return up, true, nil
194 case errors.Is(err, gorm.ErrRecordNotFound):
195 return AssetUpload{}, false, nil
196 default:
197 return AssetUpload{}, false, err
198 }
199}
200
201// --- POST /d/{sqid}/uploads ---
202
203// signUpload answers "where do I put these bytes".
204//
205// The answer is a staging key of this app's own and never the hash the
206// client sent (codex 1). The hash is still read, because the browser
207// computes it anyway and the development PUT can cheaply check what
208// landed against it, but nothing is addressed by it: registration
209// hashes the bytes it actually reads and that hash is what the deck
210// ends up referring to.
211//
212// Why it matters that the client cannot choose: rastrillo's store signs
213// an unconditional PUT (blobs/s3.go's PresignPut), so an address a
214// browser can name is an address a browser can overwrite. Naming a ref
215// another deck's picture is already served from would have changed that
216// picture, in that deck, with no document update anywhere.
217func (a *app) signUpload(w http.ResponseWriter, r *http.Request) {
218 u, ok := a.viewer(w, r)
219 if !ok {
220 return
221 }
222 d, ok := a.deckFor(w, r, u)
223 if !ok {
224 return
225 }
226 store := a.cfg.Blobs
227 if store == nil {
228 assetFail(w, r, http.StatusServiceUnavailable, "no_store", "nostore")
229 return
230 }
231
232 r.Body = http.MaxBytesReader(w, r.Body, 4<<10)
233 var body struct {
234 Name string `json:"name"`
235 Size int64 `json:"size"`
236 Type string `json:"type"`
237 Hash string `json:"hash"`
238 }
239 if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
240 assetFail(w, r, http.StatusUnprocessableEntity, "invalid", "invalid")
241 return
242 }
243 if _, allowed := assetTypes[strings.ToLower(strings.TrimSpace(body.Type))]; !allowed {
244 assetFail(w, r, http.StatusUnprocessableEntity, "unsupported_type", "type")
245 return
246 }
247 if body.Size <= 0 || body.Size > maxAssetBytes {
248 assetFail(w, r, http.StatusRequestEntityTooLarge, "too_large", "toolarge")
249 return
250 }
251 // The declared hash is still held to the shape a ref has, because a
252 // malformed one is a client bug worth reporting rather than a
253 // number to record. It addresses nothing either way.
254 if !assetRefPattern.MatchString(body.Hash) {
255 assetFail(w, r, http.StatusUnprocessableEntity, "invalid", "invalid")
256 return
257 }
258
259 kind := strings.ToLower(strings.TrimSpace(body.Type))
260 key, err := newStagingKey(d.ID, actorFor(u))
261 if err != nil {
262 a.fail(w, r, "mint a staging key", err)
263 return
264 }
265 // Belt to the staging key's braces. A fresh random key cannot be an
266 // address this deck already serves from, so this can only fire if
267 // somebody reintroduces a caller-chosen key — which is the mistake
268 // codex 1 found, and this is the line that would catch it coming
269 // back. Checked across every deck, not just this one: an address
270 // verified anywhere is an address in use.
271 var verified int64
272 if err := a.db.G.WithContext(r.Context()).Model(&DeckAsset{}).
273 Where("ref = ?", key).Count(&verified).Error; err != nil {
274 a.fail(w, r, "check whether an upload address is already verified", err)
275 return
276 }
277 if verified > 0 {
278 a.logger.Error("slides: refused to sign a PUT for a verified address",
279 "deck", d.Sqid, "ref", key)
280 assetFail(w, r, http.StatusConflict, "already_verified", "failed")
281 return
282 }
283
284 // The receipt goes in BEFORE the URL goes out. A receipt with no
285 // upload against it is a row that expires unread; a URL with no
286 // receipt is bytes nothing will ever register, which is worse
287 // because somebody waits for it.
288 up := AssetUpload{
289 DeckID: d.ID,
290 Actor: actorFor(u),
291 StagingKey: key,
292 DeclaredHash: body.Hash,
293 ContentType: kind,
294 Size: body.Size,
295 CreatedAt: time.Now().UTC(),
296 }
297 out := map[string]any{
298 "ref": key,
299 "headers": map[string]string{"Content-Type": body.Type},
300 }
301 if p, canPresign := store.(presigner); canPresign {
302 // The browser writes the staging key itself, so that is where
303 // the bytes will be.
304 up.ObjectKey = key
305 url, err := p.PresignPut(key, presignTTL)
306 if err != nil {
307 a.logger.Error("slides: presign an upload", "deck", d.Sqid, "err", err)
308 assetFail(w, r, http.StatusInternalServerError, "internal", "failed")
309 return
310 }
311 out["url"] = url
312 } else {
313 // blobs.Dir has nothing to sign, so development uploads the
314 // same way through this app. Production with a real bucket
315 // never reaches this branch and never proxies a byte — see
316 // putUpload's own refusal. ObjectKey stays empty: a
317 // content-addressed store picks the key itself, and putUpload
318 // records which one.
319 out["url"] = "/d/" + d.Sqid + "/uploads/" + key
320 }
321 if err := a.db.G.WithContext(r.Context()).Create(&up).Error; err != nil {
322 a.fail(w, r, "record a staged upload", err)
323 return
324 }
325 writeAssetJSON(w, http.StatusOK, out)
326}
327
328// --- PUT /d/{sqid}/uploads/{ref} ---
329
330// putUpload is the development-only receiving end of the URL signUpload
331// hands back when the bound store cannot presign — blobs.Dir, a
332// directory beside the database.
333//
334// It refuses outright on a store that CAN presign, rather than quietly
335// working: the whole point of the presigned path is that 20 MiB does
336// not travel through an app process that has one small instance's
337// memory, and an endpoint that silently accepted it anyway would be a
338// performance cliff nobody could see in the code.
339//
340// It takes a staging key, not a ref, and resolves the receipt for it
341// before it reads a byte (codex 1). Without that this endpoint was its
342// own copy of the same hole: any member could write any address in the
343// store, presigning or not.
344func (a *app) putUpload(w http.ResponseWriter, r *http.Request) {
345 u, ok := a.viewer(w, r)
346 if !ok {
347 return
348 }
349 d, ok := a.deckFor(w, r, u)
350 if !ok {
351 return
352 }
353 store := a.cfg.Blobs
354 if store == nil {
355 assetFail(w, r, http.StatusServiceUnavailable, "no_store", "nostore")
356 return
357 }
358 if _, canPresign := store.(presigner); canPresign {
359 assetFail(w, r, http.StatusNotFound, "not_found", "invalid")
360 return
361 }
362 key := chi.URLParam(r, "ref")
363 if !assetRefPattern.MatchString(key) {
364 assetFail(w, r, http.StatusUnprocessableEntity, "invalid", "invalid")
365 return
366 }
367 up, staged, err := a.stagedUpload(r.Context(), d, actorFor(u), key)
368 if err != nil {
369 a.fail(w, r, "resolve a staged upload", err)
370 return
371 }
372 if !staged {
373 // The same 404 an address nobody signed gets. A member learns
374 // nothing about what other people have staged.
375 assetFail(w, r, http.StatusNotFound, "not_found", "invalid")
376 return
377 }
378
379 r.Body = http.MaxBytesReader(w, r.Body, maxAssetBytes)
380 stored, err := store.Put(r.Context(), r.Body, r.Header.Get("Content-Type"))
381 if err != nil {
382 var tooBig *http.MaxBytesError
383 if errors.As(err, &tooBig) {
384 assetFail(w, r, http.StatusRequestEntityTooLarge, "too_large", "toolarge")
385 return
386 }
387 a.logger.Error("slides: store an upload", "err", err)
388 assetFail(w, r, http.StatusInternalServerError, "internal", "failed")
389 return
390 }
391 // The store hashed what it read, and the client said what it was
392 // sending. A disagreement is a client bug and is reported as one —
393 // it is no longer a security question, because what registration
394 // promotes is addressed by the bytes this app read either way.
395 if up.DeclaredHash != "" && stored.Hash != up.DeclaredHash {
396 assetFail(w, r, http.StatusUnprocessableEntity, "hash_mismatch", "invalid")
397 return
398 }
399 // Where the bytes actually landed. blobs.Dir is content-addressed
400 // and has no way to write a key of somebody else's choosing, so the
401 // receipt records the key it chose rather than pretending the
402 // staging key holds anything.
403 if err := a.db.G.WithContext(r.Context()).Model(&AssetUpload{}).
404 Where("id = ?", up.ID).
405 Updates(map[string]any{"object_key": stored.Hash, "size": stored.Size}).Error; err != nil {
406 a.fail(w, r, "record where an upload landed", err)
407 return
408 }
409 w.WriteHeader(http.StatusNoContent)
410}
411
412// --- POST /d/{sqid}/assets ---
413
414// registerAsset verifies what actually landed in the store and hands
415// back the record the client should register.
416//
417// Verifying means reading the bytes, not asking the store what it was
418// told: the content type on an object is whatever the browser put in a
419// header, and this is the one place between an upload and a deck full
420// of readers where somebody can check that a file called a PNG is one.
421//
422// What it will look at is a STAGED upload and nothing else: the `ref`
423// in the body is the staging key POST /d/{sqid}/uploads answered with,
424// and a receipt has to say this principal staged it for this deck
425// (codex 2). A ref on its own is never enough — that was the hole, and
426// it turned knowing another deck's content address into permission to
427// read it, because the row this writes plus a `register_asset` op are
428// exactly what serveAsset asks for.
429//
430// It then does what this comment used to claim and did not do (codex
431// 1): it hashes the bytes it read and PUTS THEM BACK, so the address
432// the deck ends up referring to is derived from bytes this app has
433// seen. That costs one upload of at most 20 MiB per registration, on
434// top of the download registration already did. Known, and the price
435// of a final object no browser was ever handed a PUT for.
436//
437// An SVG goes through svg.go on the way, and comes back different — a
438// stripped document has different bytes and therefore, in a
439// content-addressed store, a different address. So the ref this answers
440// with is never the ref it was asked about, and the client registers
441// what came back rather than what it uploaded.
442func (a *app) registerAsset(w http.ResponseWriter, r *http.Request) {
443 u, ok := a.viewer(w, r)
444 if !ok {
445 return
446 }
447 d, ok := a.deckFor(w, r, u)
448 if !ok {
449 return
450 }
451 store := a.cfg.Blobs
452 if store == nil {
453 assetFail(w, r, http.StatusServiceUnavailable, "no_store", "nostore")
454 return
455 }
456
457 r.Body = http.MaxBytesReader(w, r.Body, 8<<10)
458 var body struct {
459 Ref string `json:"ref"`
460 Name string `json:"name"`
461 Width int `json:"width"`
462 Height int `json:"height"`
463 Alt string `json:"alt"`
464 Decorative bool `json:"decorative"`
465 }
466 if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
467 assetFail(w, r, http.StatusUnprocessableEntity, "invalid", "invalid")
468 return
469 }
470 if !assetRefPattern.MatchString(body.Ref) {
471 assetFail(w, r, http.StatusUnprocessableEntity, "invalid", "invalid")
472 return
473 }
474 // §2.4 makes alt required unless the block is marked decorative.
475 // Checked here as well as in the inspector, because the inspector
476 // is a courtesy and this is the gate.
477 alt := strings.TrimSpace(body.Alt)
478 if alt == "" && !body.Decorative {
479 assetFail(w, r, http.StatusUnprocessableEntity, "alt_required", "altrequired")
480 return
481 }
482
483 // The receipt. Everything after this point is about bytes THIS
484 // principal staged for THIS deck; a `ref` that names anything else
485 // gets the same 404 an address holding nothing gets.
486 up, staged, err := a.stagedUpload(r.Context(), d, actorFor(u), body.Ref)
487 if err != nil {
488 a.fail(w, r, "resolve a staged upload", err)
489 return
490 }
491 if !staged {
492 assetFail(w, r, http.StatusNotFound, "not_found", "missing")
493 return
494 }
495 // Where the bytes are: the staging key on a presigning store, where
496 // the browser wrote them itself, or whatever key blobs.Dir chose
497 // when the PUT came through this app. Empty means the PUT never
498 // landed, which reads the same as an empty address.
499 object := up.ObjectKey
500 if object == "" {
501 assetFail(w, r, http.StatusNotFound, "not_found", "missing")
502 return
503 }
504
505 raw, err := a.readAsset(r.Context(), store, object)
506 if err != nil {
507 if errors.Is(err, blobs.ErrNotFound) {
508 assetFail(w, r, http.StatusNotFound, "not_found", "missing")
509 return
510 }
511 if errors.Is(err, errAssetTooLarge) {
512 assetFail(w, r, http.StatusRequestEntityTooLarge, "too_large", "toolarge")
513 return
514 }
515 a.logger.Error("slides: read an uploaded asset", "deck", d.Sqid, "err", err)
516 assetFail(w, r, http.StatusInternalServerError, "internal", "failed")
517 return
518 }
519
520 kind := sniffImage(raw)
521 if kind == "" {
522 assetFail(w, r, http.StatusUnprocessableEntity, "not_an_image", "notimage")
523 return
524 }
525 final := raw
526 if kind == "image/svg+xml" {
527 clean, err := sanitiseSVG(raw)
528 if err != nil {
529 a.logger.Info("slides: refused an SVG", "deck", d.Sqid, "err", err)
530 assetFail(w, r, http.StatusUnprocessableEntity, "unsafe_svg", "svg")
531 return
532 }
533 final = clean
534 }
535
536 // The promotion: what was read, put back. The store is
537 // content-addressed, so the key this answers with IS the SHA-256 of
538 // the bytes above — the rehash this endpoint's comment promised
539 // before codex 1 pointed out that nothing did it — and no browser
540 // has ever been handed a PUT for that address, so it holds these
541 // bytes for as long as it exists. Idempotent by construction:
542 // registering the same picture twice is one stored object.
543 stored, err := store.Put(r.Context(), bytes.NewReader(final), kind)
544 if err != nil {
545 a.logger.Error("slides: promote a staged upload", "deck", d.Sqid, "err", err)
546 assetFail(w, r, http.StatusInternalServerError, "internal", "failed")
547 return
548 }
549 ref := stored.Hash
550
551 // The verification, written down (models.go's DeckAsset). The
552 // record this answers with is what the client will name in a
553 // `register_asset` op, and that op is one any member can apply
554 // with ANY ref — so the deck's own map cannot be the whole gate,
555 // and serveAsset requires this row as well. Written before the
556 // answer, and a failure to write it fails the request: a record
557 // the browser registers without this row is a picture that will
558 // never be served.
559 if err := a.db.G.WithContext(r.Context()).
560 Clauses(clause.OnConflict{DoNothing: true}).
561 Create(&DeckAsset{DeckID: d.ID, Ref: ref, CreatedAt: time.Now().UTC()}).Error; err != nil {
562 a.fail(w, r, "record a verified asset", err)
563 return
564 }
565
566 // The receipt is spent, and the staged copy goes. Both after the
567 // verification row, so a failure here costs a stale row and a
568 // staged object rather than a picture that can never be served.
569 //
570 // The delete is skipped when the staged key IS the final one, which
571 // is the ordinary development case: blobs.Dir stored the bytes
572 // under their own hash, sanitising changed nothing, and the object
573 // the browser uploaded is the promoted object. Deleting it there
574 // would delete the picture.
575 now := time.Now().UTC()
576 if err := a.db.G.WithContext(r.Context()).Model(&AssetUpload{}).
577 Where("id = ? AND consumed_at IS NULL", up.ID).
578 Update("consumed_at", now).Error; err != nil {
579 a.logger.Error("slides: spend an upload receipt", "deck", d.Sqid, "upload", up.ID, "err", err)
580 }
581 if object != ref {
582 if err := store.Delete(r.Context(), object); err != nil {
583 a.logger.Error("slides: discard a staged upload", "deck", d.Sqid, "ref", object, "err", err)
584 }
585 }
586
587 id, err := newID()
588 if err != nil {
589 a.fail(w, r, "mint an asset id", err)
590 return
591 }
592 writeAssetJSON(w, http.StatusOK, map[string]any{
593 "id": id,
594 "ref": ref,
595 "name": trimTo(body.Name, maxAssetName),
596 "width": boundDimension(body.Width),
597 "height": boundDimension(body.Height),
598 "alt": trimTo(alt, maxAssetAlt),
599 })
600}
601
602// errAssetTooLarge is an object in the store that is bigger than §2.7
603// allows. Reachable even though signUpload refuses the size: a
604// presigned PUT goes straight to the bucket, so what actually landed is
605// only ever known here.
606var errAssetTooLarge = errors.New("slides: this object is larger than an asset may be")
607
608// readAsset reads one object out of the store, refusing at the limit
609// rather than reading whatever is there. The +1 is what tells "exactly
610// at the limit" from "over it".
611//
612// A context rather than the request it usually comes from: the export
613// job reads assets too (exports.go), and a job outlives the request
614// that started it by definition.
615func (a *app) readAsset(ctx context.Context, store blobs.Store, ref string) ([]byte, error) {
616 rc, err := store.Get(ctx, ref)
617 if err != nil {
618 return nil, err
619 }
620 defer rc.Close()
621 raw, err := io.ReadAll(io.LimitReader(rc, maxAssetBytes+1))
622 if err != nil {
623 return nil, err
624 }
625 if len(raw) > maxAssetBytes {
626 return nil, errAssetTooLarge
627 }
628 return raw, nil
629}
630
631// sniffImage reads the bytes themselves rather than any header, and
632// returns one of §2.7's four types or the empty string.
633//
634// http.DetectContentType is not used: it answers "image/svg+xml" for
635// nothing at all, and its window is 512 bytes, which is enough for the
636// three raster magics and awkward for an XML declaration followed by a
637// long comment. The magics are short and there are three of them.
638func sniffImage(raw []byte) string {
639 switch {
640 case bytes.HasPrefix(raw, []byte("\x89PNG\r\n\x1a\n")):
641 return "image/png"
642 case bytes.HasPrefix(raw, []byte{0xff, 0xd8, 0xff}):
643 return "image/jpeg"
644 case len(raw) >= 12 && bytes.HasPrefix(raw, []byte("RIFF")) && bytes.Equal(raw[8:12], []byte("WEBP")):
645 return "image/webp"
646 }
647 // An SVG is text, and may open with a byte-order mark, an XML
648 // declaration, a comment or whitespace before the element. Looking
649 // for "<svg" in the head of the file is what a browser does too;
650 // svg.go then refuses anything that is not really one.
651 head := raw
652 if len(head) > 1024 {
653 head = head[:1024]
654 }
655 head = bytes.TrimPrefix(head, []byte("\xef\xbb\xbf"))
656 if bytes.Contains(bytes.ToLower(head), []byte("<svg")) {
657 return "image/svg+xml"
658 }
659 return ""
660}
661
662// --- GET {surface}/assets/{ref} ---
663
664// deckAsset serves an asset to a signed-in reader of the deck.
665func (a *app) deckAsset(w http.ResponseWriter, r *http.Request) {
666 u, ok := a.viewer(w, r)
667 if !ok {
668 return
669 }
670 d, ok := a.deckFor(w, r, u)
671 if !ok {
672 return
673 }
674 a.serveAsset(w, r, d)
675}
676
677// sharedAsset serves an asset to somebody holding a share link. The
678// token is the whole capability, exactly as it is for the page the
679// image is on (share.go).
680func (a *app) sharedAsset(w http.ResponseWriter, r *http.Request) {
681 d, _, ok := a.deckForShareLink(w, r, chi.URLParam(r, "token"))
682 if !ok {
683 return
684 }
685 a.serveAsset(w, r, d)
686}
687
688// presentAsset serves an asset to a projector, a presenter's phone or
689// an audience following along — none of which has a session
690// (presentation.go). The session sqid is the capability.
691func (a *app) presentAsset(w http.ResponseWriter, r *http.Request) {
692 p, ok := a.presentationFor(w, r)
693 if !ok {
694 return
695 }
696 d, ok := a.deckOf(w, r, p)
697 if !ok {
698 return
699 }
700 a.serveAsset(w, r, d)
701}
702
703// serveAsset is the one body behind all three: check that this ref was
704// verified for this deck AND that the deck really refers to it, then
705// hand over the bytes.
706//
707// Both checks are the security property, and they answer different
708// questions. The deck's `assets` map says the deck refers to the ref —
709// without that, any capability for any deck would fetch any object in
710// the bucket, because a ref is a content address and knowing one is
711// knowing the sha256 of somebody's file. The deck_assets row says
712// registerAsset READ the bytes and passed them — without that, a
713// member could presign a PUT for an unsanitised SVG, name its ref in a
714// `register_asset` op (which nothing checks), and have this app serve
715// a script under its own origin. The row is checked first because it
716// is one indexed read and the map costs an openDeck per image.
717func (a *app) serveAsset(w http.ResponseWriter, r *http.Request, d Deck) {
718 ref := chi.URLParam(r, "ref")
719 if !assetRefPattern.MatchString(ref) {
720 a.notFound(w, r)
721 return
722 }
723 store := a.cfg.Blobs
724 if store == nil {
725 a.notFound(w, r)
726 return
727 }
728
729 var verified int64
730 if err := a.db.G.WithContext(r.Context()).Model(&DeckAsset{}).
731 Where("deck_id = ? AND ref = ?", d.ID, ref).Count(&verified).Error; err != nil {
732 a.fail(w, r, "look up a verified asset", err)
733 return
734 }
735 if verified == 0 {
736 a.notFound(w, r)
737 return
738 }
739
740 h, view, err := a.openDeck(r.Context(), d)
741 if err != nil {
742 a.fail(w, r, "open a deck to serve an asset", err)
743 return
744 }
745 defer h.Close(r.Context())
746 registered := false
747 for _, asset := range view.Assets {
748 if asset.Ref == ref {
749 registered = true
750 break
751 }
752 }
753 if !registered {
754 // The same answer as a ref that is not in the store at all: a
755 // reader of this deck learns nothing about what other decks
756 // hold.
757 a.notFound(w, r)
758 return
759 }
760
761 if p, canPresign := store.(presigner); canPresign {
762 url, err := p.PresignGet(ref, presignTTL)
763 if err != nil {
764 a.fail(w, r, "presign an asset", err)
765 return
766 }
767 // private, and shorter-lived than the URL it points at: the
768 // redirect is per-reader and the URL inside it expires.
769 w.Header().Set("Cache-Control", "private, max-age=300")
770 http.Redirect(w, r, url, http.StatusFound)
771 return
772 }
773
774 rc, err := store.Get(r.Context(), ref)
775 if err != nil {
776 if errors.Is(err, blobs.ErrNotFound) {
777 a.notFound(w, r)
778 return
779 }
780 a.fail(w, r, "read an asset", err)
781 return
782 }
783 defer rc.Close()
784 raw, err := io.ReadAll(io.LimitReader(rc, maxAssetBytes+1))
785 if err != nil {
786 a.fail(w, r, "read an asset", err)
787 return
788 }
789 kind := sniffImage(raw)
790 if kind == "" {
791 a.notFound(w, r)
792 return
793 }
794 assetHeaders(w, kind)
795 // Content-addressed: these bytes are these bytes forever, so a year
796 // is honest. Private rather than public — the address is the deck's
797 // capability, not the object's.
798 w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
799 http.ServeContent(w, r, ref, time.Time{}, bytes.NewReader(raw))
800}
801
802// assetHeaders is the header set every served asset carries.
803//
804// The Content-Security-Policy is the belt to svg.go's braces. An SVG
805// comes back from this app's OWN origin, so a script inside one would
806// run with every cookie this app has; svg.go strips them, and this says
807// that even a document that somehow got past it can load nothing,
808// script nothing and reach nowhere. Costless for a PNG and decisive for
809// the one format where it matters.
810func assetHeaders(w http.ResponseWriter, kind string) {
811 w.Header().Set("Content-Type", kind)
812 w.Header().Set("X-Content-Type-Options", "nosniff")
813 w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
814 // An asset is a picture on a page, never a page. Said out loud so a
815 // reader who does navigate straight to one gets a download rather
816 // than a document rendered on this origin.
817 w.Header().Set("Content-Disposition", "inline")
818}
819
820// --- answering ---
821
822// writeAssetJSON is the shape every answer on these endpoints takes,
823// buffered before the status line the way every other JSON answer in
824// this app is (api.go's writeJSON).
825func writeAssetJSON(w http.ResponseWriter, status int, body any) {
826 b, err := json.Marshal(body)
827 if err != nil {
828 http.Error(w, "something went wrong", http.StatusInternalServerError)
829 return
830 }
831 w.Header().Set("Content-Type", "application/json; charset=utf-8")
832 w.Header().Set("Cache-Control", "no-store")
833 w.WriteHeader(status)
834 _, _ = w.Write(b)
835}
836
837// assetFail is one refusal: a fixed identifier the bundle branches on,
838// and a message from the catalog for it to show. Two fields rather than
839// one for the reason api.go gives — a program reads the same word
840// whatever Accept-Language it sent, and a person reads their own.
841func assetFail(w http.ResponseWriter, r *http.Request, status int, code, messageKey string) {
842 writeAssetJSON(w, status, map[string]string{
843 "error": code,
844 "message": rastrillo.T(r, "slides.assets.error."+messageKey),
845 })
846}
847
848// trimTo trims a string and cuts it to at most n bytes, never through
849// the middle of a rune.
850func trimTo(v string, n int) string {
851 v = strings.TrimSpace(v)
852 if len(v) <= n {
853 return v
854 }
855 cut := v[:n]
856 for len(cut) > 0 && !utf8.ValidString(cut) {
857 cut = cut[:len(cut)-1]
858 }
859 return cut
860}
861
862// boundDimension keeps a client-reported pixel size inside something a
863// picture could be. Out of range becomes 0, which the renderer reads as
864// "no intrinsic size" and simply omits from the <img>.
865func boundDimension(v int) int {
866 if v <= 0 || v > maxAssetDimension {
867 return 0
868 }
869 return v
870}
871
872// assetBaseFor is the path a surface's images are served from — what
873// render.Options.AssetBase wants. One function so the three surfaces
874// cannot each spell it slightly differently from the routes app.go
875// registers.
876func assetBaseFor(base string) string { return strings.TrimSuffix(base, "/") + "/assets" }
877