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.
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-119ToDtoWithCover + IObjectStore.GetPresignedUrl, SpikerSoft.Storage/S3ObjectStore.cs:90-97):
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.
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.
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)
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-178StablePresignedUrl, 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-146MintArtifactUrls, 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:115PresignedCacheControl = "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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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-studiopage loads images slowly on every visit even though art assets are immutable once generated.Root cause —
#784is half-shippedThe presigned-display-URL feature #784 landed on the frontend but its backend half was never built.
feat/784-presigned-artifact-display): every display path callsArtStudioService.fetchArtifactDisplayUrl, which prefers a backend-mintedartifact.url(a plain, browser-cacheable<img src>) and only falls back to an authenticated blob→objectURL fetch whenurlis absent.libraries/features/art-studio/src/lib/art-studio.service.ts:653—fetchArtifactDisplayUrl→artifact.url ?? fetchArtifactObjectUrl(...)ArtAssetArtifactRef(SpikerSoft.Data/Mongos/ArtStudio/ArtAsset.cs:354-378) has nourlfield and no handler mints one. Soartifact.urlis always undefined in prod → every image takes the fallback path.The fallback path is fundamentally uncacheable:
URL.createObjectURL(...)— object URLs are memory-only, non-HTTP-cacheable, and the shell revokes them on page-leave (art-studio-shell.component.ts→revokeAllThumbnailsinngOnDestroy).Cache-Control(ArtStudioController.cs:342-369—File(stream, contentType, fileName), which also emitsContent-Disposition: attachment).Cache-Controleither (S3ArtAssetFileStore.UploadCoreAsync:30-55— noCacheControlon thePutObjectRequest).Additionally, card thumbnails resolve to the same full-resolution PNG as the detail view — there is no small derivative (
ArtAssetArtifactSelection.PickThumbnailreturns 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-119ToDtoWithCover+IObjectStore.GetPresignedUrl,SpikerSoft.Storage/S3ObjectStore.cs:90-97):Mint
artifact.urlon 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:UseS3off) leavesurlnull → the existing fallback still works.Make it cache across visits (the actual complaint). A per-request presign like BookController re-signs with a fresh
Expireseach response → the URL string changes each visit → browser cache miss. Since art-artifact objects are immutable and content-keyed:Cache-Control: public, max-age=31536000, immutableon objects at PUT (S3ArtAssetFileStore), and backfill existing objects.Expirestimestamp 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 aresponse-cache-controloverride on the presign.)Acceptance criteria
/tools/art-studioserves every already-seen image from browser cache (from-cache / 304 — no full MinIO re-download).artifact.urlpresent on list/get/gallery artifact refs when S3 is on; null in local dev with the fallback intact.Cache-Control: immutablepresent on both new and backfilled art-artifact objects.References
#784)#618/#719)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-178StablePresignedUrl, which memoizes per(objectKey, bucket)inIMemoryCacheand expires on the bucket boundary. Within the window the URL is byte-identical, which is exactly the bucketed-Expiresapproach the ticket prescribed.AC2 —
urlon list/get/gallery refs, null in local dev: met.:120-146MintArtifactUrls, 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: immutableon art-artifact objects: not met as worded.S3ArtAssetFileStore.cs:36-55buildsPutObjectRequestwith noCacheControl.git grep -n "CacheControl" origin/master -- SpikerSoft.Data SpikerSoft.EventHandlers.ArtPipeProcessor→ no matches.What shipped instead is the alternative this ticket itself names: a signed
response-cache-controloverride —ArtStudioController.cs:115PresignedCacheControl = "private, max-age=3600", applied atS3ObjectStore.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.