| 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. |
| 48 | package slides |
| 49 | |
| 50 | import ( |
| 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. |
| 82 | const 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. |
| 87 | var 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. |
| 97 | func 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. |
| 110 | const 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. |
| 115 | const 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. |
| 121 | const 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. |
| 129 | var 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. |
| 140 | type 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. |
| 148 | const 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. |
| 158 | const 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. |
| 170 | func 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. |
| 185 | func (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. |
| 217 | func (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. |
| 344 | func (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. |
| 442 | func (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. |
| 606 | var 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. |
| 615 | func (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. |
| 638 | func 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. |
| 665 | func (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). |
| 680 | func (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. |
| 691 | func (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. |
| 717 | func (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. |
| 810 | func 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). |
| 825 | func 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. |
| 841 | func 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. |
| 850 | func 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>. |
| 865 | func 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. |
| 876 | func assetBaseFor(base string) string { return strings.TrimSuffix(base, "/") + "/assets" } |
| 877 | |