[Bug][Art Studio] Display images never cache across visits — backend half of #784 (presigned display URL) was never built #804

Open
opened 2026-07-22 23:02:21 +00:00 by spikerj · 1 comment
Owner

Summary

Art Studio display images (my-assets card thumbnails, gallery, viewer strip, full-size views) re-download in full on every page visit — nothing caches them in the browser, CDN, or object store. The /tools/art-studio page loads images slowly on every visit even though art assets are immutable once generated.

Root cause — #784 is half-shipped

The presigned-display-URL feature #784 landed on the frontend but its backend half was never built.

  • Frontend half MERGED (spikersoft-angular PR #518, feat/784-presigned-artifact-display): every display path calls ArtStudioService.fetchArtifactDisplayUrl, which prefers a backend-minted artifact.url (a plain, browser-cacheable <img src>) and only falls back to an authenticated blob→objectURL fetch when url is absent.
    • libraries/features/art-studio/src/lib/art-studio.service.ts:653fetchArtifactDisplayUrlartifact.url ?? fetchArtifactObjectUrl(...)
  • Backend half NEVER BUILT. On master, ArtAssetArtifactRef (SpikerSoft.Data/Mongos/ArtStudio/ArtAsset.cs:354-378) has no url field and no handler mints one. So artifact.url is always undefined in prod → every image takes the fallback path.

The fallback path is fundamentally uncacheable:

  • Bytes are fetched through the authenticated API as a blob and wrapped in URL.createObjectURL(...) — object URLs are memory-only, non-HTTP-cacheable, and the shell revokes them on page-leave (art-studio-shell.component.tsrevokeAllThumbnails in ngOnDestroy).
  • The download endpoint sets no Cache-Control (ArtStudioController.cs:342-369File(stream, contentType, fileName), which also emits Content-Disposition: attachment).
  • The stored MinIO objects carry no Cache-Control either (S3ArtAssetFileStore.UploadCoreAsync:30-55 — no CacheControl on the PutObjectRequest).

Additionally, card thumbnails resolve to the same full-resolution PNG as the detail view — there is no small derivative (ArtAssetArtifactSelection.PickThumbnail returns the full concept image ref). So grids download full-res PNGs, N at a time, every visit. (The small-derivative fix is tracked in the companion AVIF ticket.)

Proposed fix

Mirror the proven BookController cover pattern (SpikerSoft.Api/Domain/Books/BookController.cs:77-119 ToDtoWithCover + IObjectStore.GetPresignedUrl, SpikerSoft.Storage/S3ObjectStore.cs:90-97):

  1. Mint artifact.url on the Art Studio artifact DTOs (list / get / gallery list + detail) as a presigned MinIO URL, applied per-response after any cache layer. Preserves authz (server only mints for authorized callers — private my-assets stay private, approved gallery stays approved). Local dev (Storage:UseS3 off) leaves url null → the existing fallback still works.

  2. Make it cache across visits (the actual complaint). A per-request presign like BookController re-signs with a fresh Expires each response → the URL string changes each visit → browser cache miss. Since art-artifact objects are immutable and content-keyed:

    • Set Cache-Control: public, max-age=31536000, immutable on objects at PUT (S3ArtAssetFileStore), and backfill existing objects.
    • Mint the presigned URL with a stable / rounded expiry (bucket the Expires timestamp to a fixed boundary) so repeated mints for the same object yield a byte-identical URL within the window → real browser cache hits. (Alternatively use a response-cache-control override on the presign.)

Acceptance criteria

  • A second visit to /tools/art-studio serves every already-seen image from browser cache (from-cache / 304 — no full MinIO re-download).
  • artifact.url present on list/get/gallery artifact refs when S3 is on; null in local dev with the fallback intact.
  • Private assets remain authz-gated; no public-bucket exposure.
  • Cache-Control: immutable present on both new and backfilled art-artifact objects.

References

  • Frontend half already merged: spikersoft-angular PR #518 (#784)
  • Template: BookController presigned covers (#618 / #719)
  • Companion: AVIF display derivatives + real small card thumbnail (see linked ticket)
## Summary Art Studio display images (my-assets card thumbnails, gallery, viewer strip, full-size views) re-download in full on **every** page visit — nothing caches them in the browser, CDN, or object store. The `/tools/art-studio` page loads images slowly on every visit even though art assets are immutable once generated. ## Root cause — `#784` is half-shipped The presigned-display-URL feature **#784** landed on the frontend but its backend half was never built. - **Frontend half MERGED** (spikersoft-angular PR #518, `feat/784-presigned-artifact-display`): every display path calls `ArtStudioService.fetchArtifactDisplayUrl`, which prefers a backend-minted `artifact.url` (a plain, browser-cacheable `<img src>`) and only falls back to an authenticated blob→objectURL fetch when `url` is absent. - `libraries/features/art-studio/src/lib/art-studio.service.ts:653` — `fetchArtifactDisplayUrl` → `artifact.url ?? fetchArtifactObjectUrl(...)` - **Backend half NEVER BUILT.** On master, `ArtAssetArtifactRef` (`SpikerSoft.Data/Mongos/ArtStudio/ArtAsset.cs:354-378`) has no `url` field and no handler mints one. So `artifact.url` is **always undefined in prod** → every image takes the fallback path. The fallback path is fundamentally uncacheable: - Bytes are fetched through the **authenticated API** as a blob and wrapped in `URL.createObjectURL(...)` — object URLs are memory-only, non-HTTP-cacheable, and the shell **revokes them on page-leave** (`art-studio-shell.component.ts` → `revokeAllThumbnails` in `ngOnDestroy`). - The download endpoint sets **no `Cache-Control`** (`ArtStudioController.cs:342-369` — `File(stream, contentType, fileName)`, which also emits `Content-Disposition: attachment`). - The stored MinIO objects carry **no `Cache-Control`** either (`S3ArtAssetFileStore.UploadCoreAsync:30-55` — no `CacheControl` on the `PutObjectRequest`). Additionally, card thumbnails resolve to the **same full-resolution PNG** as the detail view — there is no small derivative (`ArtAssetArtifactSelection.PickThumbnail` returns the full concept image ref). So grids download full-res PNGs, N at a time, every visit. *(The small-derivative fix is tracked in the companion AVIF ticket.)* ## Proposed fix Mirror the proven BookController cover pattern (`SpikerSoft.Api/Domain/Books/BookController.cs:77-119` `ToDtoWithCover` + `IObjectStore.GetPresignedUrl`, `SpikerSoft.Storage/S3ObjectStore.cs:90-97`): 1. **Mint `artifact.url`** on the Art Studio artifact DTOs (list / get / gallery list + detail) as a presigned MinIO URL, applied per-response *after* any cache layer. Preserves authz (server only mints for authorized callers — private my-assets stay private, approved gallery stays approved). Local dev (`Storage:UseS3` off) leaves `url` null → the existing fallback still works. 2. **Make it cache across visits** (the actual complaint). A per-request presign like BookController re-signs with a fresh `Expires` each response → the URL string changes each visit → browser cache miss. Since art-artifact objects are immutable and content-keyed: - Set `Cache-Control: public, max-age=31536000, immutable` on objects at PUT (`S3ArtAssetFileStore`), and backfill existing objects. - Mint the presigned URL with a **stable / rounded expiry** (bucket the `Expires` timestamp to a fixed boundary) so repeated mints for the same object yield a byte-identical URL within the window → real browser cache hits. (Alternatively use a `response-cache-control` override on the presign.) ## Acceptance criteria - A second visit to `/tools/art-studio` serves every already-seen image from browser cache (from-cache / 304 — no full MinIO re-download). - `artifact.url` present on list/get/gallery artifact refs when S3 is on; null in local dev with the fallback intact. - Private assets remain authz-gated; no public-bucket exposure. - `Cache-Control: immutable` present on both new and backfilled art-artifact objects. ## References - Frontend half already merged: spikersoft-angular PR #518 (`#784`) - Template: BookController presigned covers (`#618` / `#719`) - Companion: AVIF display derivatives + real small card thumbnail (see linked ticket)
Author
Owner

Audited against origin/master3 of 4 acceptance criteria met; AC4 shipped as the ticket's own stated alternative. Leaving open as a judgement call for you rather than closing it unilaterally.

AC1 — stable URL: met. ArtStudioController.cs:113-114 (ArtifactUrlBucket = 1h, ArtifactUrlTtl = 2h) plus :167-178 StablePresignedUrl, which memoizes per (objectKey, bucket) in IMemoryCache and expires on the bucket boundary. Within the window the URL is byte-identical, which is exactly the bucketed-Expires approach the ticket prescribed.

AC2 — url on list/get/gallery refs, null in local dev: met. :120-146 MintArtifactUrls, guarded so it only runs when the keyed store exists (Storage:UseS3).

AC3 — authorization preserved: met. URLs are minted after the handler's authorization decision and never persisted (:94-99).

AC4 — Cache-Control: immutable on art-artifact objects: not met as worded.

  • No object-level header at PUT: S3ArtAssetFileStore.cs:36-55 builds PutObjectRequest with no CacheControl. git grep -n "CacheControl" origin/master -- SpikerSoft.Data SpikerSoft.EventHandlers.ArtPipeProcessorno matches.
  • No backfill of existing objects — no tool, no commit.

What shipped instead is the alternative this ticket itself names: a signed response-cache-control override — ArtStudioController.cs:115 PresignedCacheControl = "private, max-age=3600", applied at S3ObjectStore.cs:90-101.

The call is yours. That delivers the user-visible outcome (images cache across visits), so closing this is defensible. The practical ceiling worth knowing: caching holds for one hour, then the URL rotates with the bucket and the browser entry expires — so it's a 1-hour cache, not immutable. If that's fine, close it; if you want true immutability and the benefit for already-stored objects, the remaining work is the PUT-time header plus a backfill.

Audited against `origin/master` — **3 of 4 acceptance criteria met; AC4 shipped as the ticket's own stated alternative.** Leaving open as a judgement call for you rather than closing it unilaterally. **AC1 — stable URL: met.** `ArtStudioController.cs:113-114` (`ArtifactUrlBucket = 1h`, `ArtifactUrlTtl = 2h`) plus `:167-178` `StablePresignedUrl`, which memoizes per `(objectKey, bucket)` in `IMemoryCache` and expires on the bucket boundary. Within the window the URL is byte-identical, which is exactly the bucketed-`Expires` approach the ticket prescribed. **AC2 — `url` on list/get/gallery refs, null in local dev: met.** `:120-146` `MintArtifactUrls`, guarded so it only runs when the keyed store exists (`Storage:UseS3`). **AC3 — authorization preserved: met.** URLs are minted after the handler's authorization decision and never persisted (`:94-99`). **AC4 — `Cache-Control: immutable` on art-artifact objects: not met as worded.** - No object-level header at PUT: `S3ArtAssetFileStore.cs:36-55` builds `PutObjectRequest` with no `CacheControl`. `git grep -n "CacheControl" origin/master -- SpikerSoft.Data SpikerSoft.EventHandlers.ArtPipeProcessor` → **no matches**. - No backfill of existing objects — no tool, no commit. **What shipped instead** is the alternative this ticket itself names: a signed `response-cache-control` override — `ArtStudioController.cs:115` `PresignedCacheControl = "private, max-age=3600"`, applied at `S3ObjectStore.cs:90-101`. **The call is yours.** That delivers the user-visible outcome (images cache across visits), so closing this is defensible. The practical ceiling worth knowing: caching holds for one hour, then the URL rotates with the bucket and the browser entry expires — so it's a 1-hour cache, not `immutable`. If that's fine, close it; if you want true immutability and the benefit for already-stored objects, the remaining work is the PUT-time header plus a backfill.
Sign in to join this conversation.