docs(quick-261020-13e): admin image preview fallback

This commit is contained in:
2026-10-20 01:07:07 +02:00
parent 1ab7db428a
commit 2eecfb8197
3 changed files with 341 additions and 4 deletions

View File

@@ -5,10 +5,10 @@ current_phase: "19.3"
current_phase_name: Framework transport and infrastructure for WavePath
status: Ready for verification — 6/6 plans executed, UAT in progress (19.3-UAT.md)
stopped_at: Phase 19.4 complete, ready to plan Phase 19.3
last_updated: "2026-10-19T22:53:29.668Z"
last_updated: "2026-10-19T23:07:03.019Z"
last_activity: 2026-10-20
last_activity_desc: Phase 19.4 complete, transitioned to Phase 19.3
state_head: 76c43432995fb33f3a173ab25e57dda4d7be4907
last_activity_desc: "Completed quick task 261020-13e: admin image preview fallback"
state_head: 1ab7db428ace109f25c859e3f8a15dc0bcb9b00f
progress:
total_phases: 42
completed_phases: 35
@@ -32,7 +32,7 @@ See: .planning/PROJECT.md (updated 2026-10-12)
Phase: 19.3 — Framework transport and infrastructure for WavePath
Plan: 6 of 6
Status: Ready for verification — 6/6 plans executed, UAT in progress (19.3-UAT.md)
Last activity: 2026-10-20 — Phase 19.4 complete, transitioned to Phase 19.3
Last activity: 2026-10-20 - Completed quick task 261020-13e: admin image preview fallback
Progress: [████████░░] 83%
@@ -976,6 +976,7 @@ Recent decisions affecting current work:
| 261018-qbs | Fix 19.1 review WR-04 (chatbots dispatch checks IsAvailable) and WR-05 (chatvideo user.Deleting heir + LiveKit cleanup); extending.md optional-dependency go.mod note | 2026-10-18 | 97daec5 | [261018-qbs-fix-19-1-review-findings-wr-04-chatbots-](./quick/261018-qbs-fix-19-1-review-findings-wr-04-chatbots-/) |
| 261019-05g | Phase 17 verify-work unblock (COVERAGE.md reason, goal as user story, sm-sitemap-plugin requires v0.2.0) and console docs example of the application binary's --help | 2026-10-19 | 86c5a80 | [261019-05g-phase-17-verify-work-unblock-coverage-md](./quick/261019-05g-phase-17-verify-work-unblock-coverage-md/) |
| 261019-hgw | Translate hook ValidationError phrase keys in the request locale | 2026-10-19 | ce8d7ef | [261019-hgw-translate-hook-validationerror-phrase-ke](./quick/261019-hgw-translate-hook-validationerror-phrase-ke/) |
| 261020-13e | Admin image previews of public files fall back to the authenticated thumb route | 2026-10-19 | 1ab7db4 | [261020-13e-admin-file-upload-widget-image-preview-a](./quick/261020-13e-admin-file-upload-widget-image-preview-a/) |
## Deferred Items

View File

@@ -0,0 +1,235 @@
---
phase: quick-261020-13e
plan: 01
quick_id: 261020-13e
slug: admin-file-upload-widget-image-preview-a
type: execute
mode: quick
wave: 1
depends_on: []
files_modified:
- modules/cabana/field_file.go
- modules/cabana/protected_file_test.go
- modules/cabana/admin_openapi.go
- modules/cabana/README.md
- admin/openapi/admin.json
- admin/src/api/schema.d.ts
- admin/src/api/files.ts
- admin/src/components/form/fields/FileuploadField.vue
- admin/tests/form/FileuploadField.test.ts
- docs/database/attachments.md
- docs/backend/forms.md
- docs/backend/admin-spa.md
- docs/services/storage.md
- modules/boardwalk/dist/index.html
- modules/boardwalk/dist/assets/
autonomous: true
requirements: [QUICK-261020-13e]
source:
- orchestrator brief (locked fix: public image preview falls back to the authenticated admin thumb route; thumb route only, download route unchanged; both parent-record and relation-child routes)
- admin/src/components/form/fields/FileuploadField.vue:74-80 (thumbs, broken, requested), :656-715 (previewOf, forgetThumb, loadThumb, protected-only watch, onPreviewError), :781-798 (revoke on unmount), :864-896, :997-1011, :1116-1121 (the three img render sites with @error), :1277 (caption modal preview)
- admin/src/api/files.ts:10-11, :51-52 (thumb comments), :246-251 and :311-316 (parent and relation-child thumb calls, X-Session-Key header, parseAs blob)
- modules/cabana/field_file.go:619-647 (fileItem emits url/thumb_url only when relation.Public and file Public), :1152-1158 (relationChildFileThumb), :1377-1469 (fileThumb, serveProtectedFile doc comment, serveProtectedFileOn with the `found.Public()` 404 at :1416-1418, image-type gate and ThumbKey at :1428-1440, D-10 headers at :1456-1465)
- modules/lagoon/attach/thumb.go:135-160 (Thumb = PublicURL(ThumbKey); public and protected files share one bucket, so ThumbKey works for a public file and reuses the thumb the list already generated)
- modules/cabana/protected_file_test.go:61-129 (TestProtectedFileScope, its "public rows" subtest asserts thumb 404 for public rows and must change), :45-54 (securityHeaders helper)
- modules/cabana/phase122_fixture_test.go:40-41, :75, :99 (gadget photos = public attachMany image field, manual = protected; part images = protected), :676 (storeFile, extensions incl. svg/txt)
- modules/cabana/relation_child_scope_test.go:24-28, :80-90 (env.gadget/env.part/env.storeFile, child file path shape)
- admin/tests/form/FileuploadField.test.ts:144-163 (objectUrls helper), :322-356 (protected thumbnails describe = pattern for the new tests)
- modules/cabana/admin_openapi.go:1300-1324 (AdminRelationChildFileThumb), :1493-1513 (AdminFileThumb)
- modules/cabana/README.md:113, :121; docs/database/attachments.md "Protected files in the admin"; docs/backend/forms.md:250; docs/backend/admin-spa.md:28; docs/services/storage.md "Serving files"
estimate:
tokens: 110000
raw_tokens: 110000
tasks: 3
confidence: low
must_haves:
truths:
- "In an admin form, a public image (field not protected) whose thumb_url/url fails to load in the img is then previewed from an object URL fetched through the authenticated admin thumb route of its field (parent record or relation child), sent with the form's session key header and no query string; the object URL is revoked when the item is forgotten and on unmount."
- "Only when that fallback fetch also fails, or the object-URL image itself fails to load, does the field show backend::lang.fileupload.preview_unavailable; a public image whose URL loads makes no thumb request at all; protected fields behave exactly as before."
- "GET .../{id}/files/{field}/{file}/thumb and GET .../{id}/relations/{name}/records/{child}/files/{field}/{file}/thumb answer 200 for a public JPEG, PNG, GIF or WebP file of a record the admin may load through FormExtendQuery, or pending in the admin's own session, with an image/* content type and the D-10 headers (X-Content-Type-Options nosniff, Cache-Control private, no-store, Content-Security-Policy default-src 'none'; sandbox)."
- "The thumb routes still answer 404 for a public file of another record, of a record excluded by FormExtendQuery (hidden gadget), for another admin's pending public upload and for a public file that is not one of those four image types (SVG and text included); the download routes still answer 404 for every public file."
- "modules/cabana/README.md, docs/database/attachments.md, docs/backend/forms.md, docs/backend/admin-spa.md, docs/services/storage.md and the AdminFileThumb / AdminRelationChildFileThumb swag annotations describe the new thumb contract and the SPA fallback; admin/openapi/admin.json and admin/src/api/schema.d.ts match a fresh generation (scripts/check-admin-openapi.sh --check); modules/boardwalk/dist matches a fresh build (scripts/check-admin-dist.sh)."
- "go vet ./..., go test -short ./..., the cabana Postgres file tests, npm --prefix admin run test and typecheck, TestDocsTree and summer docs:build --check are green; the work lands as three commits staged by explicit path with no attribution trailer; nothing pushed or tagged; sm-demo-app untouched."
artifacts:
- path: modules/cabana/field_file.go
provides: "serveProtectedFileOn refuses public files on the download route only; thumb route serves public image thumbnails under the same scope, image-type rule and headers"
- path: modules/cabana/protected_file_test.go
provides: "TestPublicImageThumb (parent + relation-child, positive and negative scope cases) and the updated TestProtectedFileScope public-rows subtest"
- path: admin/src/components/form/fields/FileuploadField.vue
provides: "fallback set + onPreviewError fallback to routes.thumb for public images, previewOf reads the object URL in fallback mode"
- path: admin/tests/form/FileuploadField.test.ts
provides: "public preview fallback describe block"
- path: modules/cabana/admin_openapi.go
provides: "AdminFileThumb and AdminRelationChildFileThumb summaries/descriptions for image files (protected or public)"
- path: modules/boardwalk/dist/index.html
provides: "rebuilt embedded admin SPA containing the fallback"
key_links:
- from: "FileuploadField.vue onPreviewError (img @error on all three render sites)"
to: "routes.thumb in admin/src/api/files.ts (parent and relation-child FileRoutes)"
via: "fallback set + loadThumb -> object URL in thumbs"
- from: "admin/src/api/files.ts thumb"
to: "cabana fileThumb / relationChildFileThumb -> serveProtectedFileOn(thumb=true)"
via: "GET .../files/{field}/{file}/thumb with X-Session-Key / X-Child-Session-Key header"
- from: "serveProtectedFileOn thumb branch"
to: "attach.File.ThumbKey + bucket.NewReader"
via: "public check skipped only when thumb is true; AllowedImageMIMEs gate still applied"
---
<objective>
Make image previews in the admin fileupload widget work however the application serves uploads. Today a public image's preview uses its public URL (thumb_url or url). An app that leaves `storage.uploads.serve` off and has no CDN therefore answers 404 for every one, and the field shows "preview is not available".
Locked fix (orchestrator brief): when a public image's URL fails to load in the admin, the SPA fetches the preview through the authenticated admin thumb route as an object URL, the same path protected files use, and only if that also fails shows preview_unavailable. On the server, the admin THUMB route (parent record and relation child, which share serveProtectedFileOn) also serves thumbnails of public image files in the same scope: a record the admin may load, or an upload pending in the admin's own session. It applies the same headers and image-type rules. The download route keeps refusing public files.
Purpose: admins see their images in the demo app and in any production layout (separate upload origin, CDN, serve off) without changing the public URL contract or opening the download route.
Output: the cabana thumb-route change and Go tests, the SPA fallback and vitest, README/docs/openapi updates with regenerated admin.json and schema.d.ts, and a rebuilt embedded dist, in three commits.
Out of scope: the sm-demo-app `storage.uploads.serve: true` config change (the orchestrator handles it in that repo); the download route for public files; the "open full size" link in image mode, which keeps pointing at the public URL (planner's discretion: it is not the preview, and the download route stays closed to public files per the locked scope).
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@CLAUDE.md
@modules/cabana/field_file.go
@modules/cabana/protected_file_test.go
@admin/src/components/form/fields/FileuploadField.vue
@admin/src/api/files.ts
@admin/tests/form/FileuploadField.test.ts
Repo rules (CLAUDE.md): framework code never names a consuming application (no "sm-demo-app" in code, README or docs; write "an application that leaves storage.uploads.serve off"). A change to a module's exported API contract updates its README and the affected docs pages in the same commit. Every identifier on a docs page must exist in the package (the docs checker enforces it), so never cite unexported helpers (serveProtectedFileOn, withFileScope, onPreviewError) in README or docs. Config keys are not checked, so review them by hand. Stage by explicit path, never `git add -A` or `git add .`. Leave other sessions' edits alone (`.planning/state.json`, `.msd/`, the 19.5 PLAN files and anything else that shows up in `git status`), and check `git status --short -- modules/cabana admin docs` before editing shared files. Planning docs and code go in separate commits; this plan's PLAN/SUMMARY are committed by the quick workflow, not by these tasks. No attribution trailer in any commit (the user's global rule overrides the session reminder). No push, no tag. Docker-backed tests skip under -short; run them without -short, and if the Docker socket is denied although the user is in the docker group, run them through `sudo -u golem`.
</context>
<tasks>
<task type="tracer" tdd="true">
<name>Task 1: Tracer: owner's public photo previews through the admin thumb route end to end</name>
<files>modules/cabana/field_file.go, modules/cabana/protected_file_test.go, admin/src/components/form/fields/FileuploadField.vue, admin/src/api/files.ts, admin/tests/form/FileuploadField.test.ts</files>
<behavior>
- Go: GET dfPath(g, "/files/photos/{id}/thumb") for a public PNG stored on the owner gadget's public photos field answers 200 with an image/* Content-Type and the D-10 headers (securityHeaders helper); GET .../download for the same file still answers 404.
- vitest: a public (default photos()) field lists item 41 with the default thumb_url; triggering the img 'error' event sends GET {BASE}/7/files/photos/41/thumb with the session header and an empty query string, the img src becomes the object URL, no data-preview-unavailable is rendered, and the object URL is revoked on unmount.
</behavior>
<action>
Pre-flight: run `git status --short -- modules/cabana admin docs`. If another session has uncommitted edits in any file of this plan, stop and report instead of editing over them.
RED first: add the Go test TestPublicImageThumb to modules/cabana/protected_file_test.go. Use newDeferredEnv, env.gadget, and env.storeFile(t, dfGadgetMorph, g, "photos", "pub.png", conformPNG(t), true). The first case is owner thumb 200 + securityHeaders + image/ content type, and owner download 404 (Task 2 adds the rest of the matrix to this same function). Add a vitest `describe('public preview fallback', ...)` to admin/tests/form/FileuploadField.test.ts following the "protected thumbnails (backstop 3)" pattern: objectUrls(create, revoke), setup with the public photos() field, list route with item(41, 'a.png'), thumb route answering a body. Trigger 'error' on `[data-file-item] img`, flushPromises, then assert the request (session header, empty search), the img src and revoke after unmount. Run both tests and see them fail.
GREEN (server, both routes at once because they share serveProtectedFileOn): in modules/cabana/field_file.go serveProtectedFileOn, return recordNotFound{} for a public file only when thumb is false. Keep everything else unchanged: the same withFileScope/findFile scope, the AllowedImageMIMEs gate on the thumb branch (a public non-image stays 404), f.ThumbKey with cf.thumbW/cf.thumbH/cf.thumbMode, and the same nosniff / private, no-store / sandbox CSP headers. Do not rename the function. Rewrite the doc comment of serveProtectedFile (and the one-line comment of fileThumb) to state the new contract: the download route streams protected files only, and a public file is 404 there. The thumb route serves the thumbnail of any JPEG, PNG, GIF or WebP file of the field, protected or public, under the same scope, so the admin SPA can preview a public image whose public URL does not load. Mention that relationChildFileThumb shares this.
GREEN (SPA) in admin/src/components/form/fields/FileuploadField.vue:
- Add a reactive `fallback` set of item uids (ref of Set, like `broken`), documented as public images whose URL failed and that are previewed through the admin thumb route.
- previewOf: for a non-protected field, return thumb_url || url only while the uid is not in fallback; once it is, return the thumbs entry (or null while loading) exactly as protected fields do.
- onPreviewError: for a non-protected field whose uid is not yet in fallback, add the uid to fallback and start loadThumb(item). If routes is null or URL.createObjectURL is unavailable, mark it broken at once instead. In every other case (protected field, or a fallback object URL that failed to load), add it to broken as today.
- forgetThumb also removes the uid from fallback.
- Update the `thumbs` comment to say it now covers protected files and public images in fallback.
The existing loadThumb is reused unchanged: it marks broken on a null blob and guards against unmount and forgotten items. Keep the protected-only watch as it is. The three img render sites and the caption modal already go through previewOf and @error, so no template change is needed. Keep the image-mode link href on item.file.url (scope decision in <objective>). In admin/src/api/files.ts update only the comments at lines 10-11 and the `thumb` member doc (line 51): the thumb is now an image's preview thumbnail (protected, or public when its URL failed). No signature change.
Do NOT commit; Task 3 commits.
</action>
<verify>
<automated>go vet ./modules/cabana && go test ./modules/cabana -run 'TestPublicImageThumb|TestProtectedFileHeaders' -count=1 && npm --prefix admin run test -- tests/form/FileuploadField.test.ts</automated>
</verify>
<done>An owner's public photo thumb answers 200 with image content and D-10 headers while its download stays 404. In the SPA an img error on a public image leads to an authenticated thumb fetch and an object-URL preview that is revoked on unmount. The existing FileuploadField and TestProtectedFileHeaders tests pass (TestProtectedFileScope's public-rows subtest is expected to fail until Task 2 updates it). Nothing committed yet.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Expand: full scope matrix for both thumb routes and the SPA failure edges</name>
<files>modules/cabana/protected_file_test.go, admin/tests/form/FileuploadField.test.ts, admin/src/components/form/fields/FileuploadField.vue</files>
<behavior>
- Go TestPublicImageThumb, parent route: a public photo of gadget G2 requested through G1 is 404; a public photo of a hidden gadget (env.gadget(..., true), excluded by FormExtendQuery) through itself is 404. A public "notes.txt" and a public SVG (with a script element) stored on photos have a thumb that is 404. A public GIF and a public WebP (dfGIF, dfWebP) have a thumb that is 200 with image content. A public row under the protected manual field ("mixed") has a thumb that is 200 and a download that is 404. Admin A uploads to dfPath(0, "/files/photos") with session key K; A's thumb with K is 200, admin B's thumb with K is 404.
- Go TestPublicImageThumb, relation child: a public PNG stored with env.storeFile(t, dfPartMorph, p, "images", ..., true) on a part of the owner gadget answers 200 + securityHeaders + image content on .../relations/parts/records/{p}/files/images/{id}/thumb, 404 on its /download and 404 through another gadget's path.
- Go TestProtectedFileScope: the "public rows" subtest still asserts 404 on the download route for the public photo and for the mixed row; its thumb assertions move to TestPublicImageThumb; the test's doc comment says that the download route, not the thumb route, refuses public rows.
- vitest: when the fallback thumb route answers 404 (no route registered), the item shows data-preview-unavailable after the img error. When the img never errors, no request goes to the thumb route. When the fallback object-URL img errors too, the item shows data-preview-unavailable and exactly one thumb request was made. A protected field still behaves as in "protected thumbnails (backstop 3)".
</behavior>
<action>
Extend TestPublicImageThumb in modules/cabana/protected_file_test.go with every Go case listed in <behavior>, as subtests. Reuse the existing helpers: want, securityHeaders, dfPath, manualPath, sk, ck, newSessionKey, dataID, env.a / env.b, env.part, env.storeFile, conformPNG, dfGIF, dfWebP. Assert 404s with env.state / env.unchanged where the existing scope tests do, so a refused request changes nothing. For the relation-child calls, mirror the headers that childRoutes' thumb entry in relation_child_scope_test.go sends (ck with a fresh child session key). If the owning saved child needs no key, still send one, as that test does. Update TestProtectedFileScope as described in <behavior>; its other subtests stay unchanged.
Add the three vitest cases to the 'public preview fallback' describe in admin/tests/form/FileuploadField.test.ts. For the double-failure case, trigger 'error' on the img once and flushPromises; the src becomes the object URL. Trigger 'error' again and assert data-preview-unavailable and requestsTo(calls, 'GET', thumb path).length === 1. If a case exposes a defect in Task 1's FileuploadField.vue logic, fix it there (for example a second fetch, a missing broken mark, or a fallback that is not cleared by forgetThumb). Do not change protected-field behavior.
Do NOT commit; Task 3 commits.
</action>
<verify>
<automated>go vet ./modules/cabana && go test ./modules/cabana -run 'TestPublicImageThumb|TestProtectedFile|TestRelationChildScope|TestFileuploadSmoke' -count=1 && go test -short ./modules/cabana -count=1 && npm --prefix admin run test -- tests/form/FileuploadField.test.ts && npm --prefix admin run typecheck</automated>
</verify>
<done>Every case in <behavior> passes on Postgres. If the Docker socket was denied, the tests ran through `sudo -u golem`, and the SUMMARY records it. TestProtectedFileScope, TestProtectedFileSmoke, TestRelationChildScope* and TestFileuploadSmoke* stay green. The FileuploadField suite (old and new cases) and typecheck pass. Nothing committed yet.</done>
</task>
<task type="auto">
<name>Task 3: Document the contract, regenerate OpenAPI, rebuild dist, run every gate, commit in three steps</name>
<files>modules/cabana/admin_openapi.go, admin/openapi/admin.json, admin/src/api/schema.d.ts, modules/cabana/README.md, docs/database/attachments.md, docs/backend/forms.md, docs/backend/admin-spa.md, docs/services/storage.md, modules/boardwalk/dist/index.html, modules/boardwalk/dist/assets/</files>
<action>
Swag annotations in modules/cabana/admin_openapi.go:
- AdminFileThumb: Go doc line, @Summary "Thumbnail of an image file", and @Description. The description says: the preview thumbnail (imageWidth by imageHeight, 240 by 240 by default, in thumbOptions.mode) of a JPEG, PNG, GIF or WebP file of the field, protected or public, scoped like the download route (a record the admin may load or an upload pending in the admin's own session), with the download route's headers. The admin SPA uses it for protected images and for a public image whose public URL does not load. Any other file type is 404.
- AdminRelationChildFileThumb: the same change ("As the record thumb route, for an image file, protected or public, of the related record or of the child session").
- Leave AdminFileDownload and AdminRelationChildFileDownload unchanged (public files stay 404 there).
Then run `scripts/check-admin-openapi.sh` (regenerates admin/openapi/admin.json and admin/src/api/schema.d.ts), then `scripts/check-admin-openapi.sh --check`.
Docs, using only exported identifiers:
- modules/cabana/README.md line 113 table row: stream a protected file, or the preview thumbnail of any image file of the field.
- modules/cabana/README.md line 121 paragraph: the download route serves only is_public false files. The thumb route serves the thumbnail of a protected or public JPEG, PNG, GIF or WebP file under the same scope and headers, so the admin previews a public image even when its public URL does not load. It answers 404 for any other type.
- docs/database/attachments.md "Protected files in the admin": in the thumb bullet and the "A file of another record, a public file ... answer 404" sentence, say that a public file is 404 on the download route. The thumb route also serves a public image's thumbnail, same scope and headers. Link to Forms#file-uploads for the SPA fallback.
- docs/backend/forms.md, file uploads paragraph at line 250: add that a public image whose URL does not load in the admin (for example when the application leaves `storage.uploads.serve` off and nothing else serves the uploads prefix) is previewed through the authenticated thumb route the same way. Only when that also fails does the field say the preview is not available.
- docs/backend/admin-spa.md line 28: extend the object-URL sentence to cover the public-image preview fallback.
- docs/services/storage.md "Serving files" section: one sentence saying that admin fileupload previews do not depend on this route, because the admin falls back to its authenticated thumb route.
Do not name any consuming application.
Gates (all must pass): `go vet ./...`, `go test -short ./...`, `go test ./cmd/summer -run TestDocsTree -count=1`, `go run ./cmd/summer docs:build --check`, `npm --prefix admin run test`, `npm --prefix admin run typecheck`. Then `npm --prefix admin run build` (writes modules/boardwalk/dist), then `scripts/check-admin-dist.sh`.
Commits. Re-run `git status --short` first. Stage only this plan's files, by explicit path (dist via the pathspec `modules/boardwalk/dist`, which in git 2.x also stages the removed hashed assets). Never use `git add -A` or `git add .`. Leave `.msd/`, `.planning/` and other sessions' files alone. Make three commits, each with a short body and no attribution trailer:
1. `fix(cabana): serve public image thumbnails on the admin thumb routes`: modules/cabana/field_file.go, modules/cabana/protected_file_test.go, modules/cabana/admin_openapi.go, modules/cabana/README.md, admin/openapi/admin.json, admin/src/api/schema.d.ts (only if changed), docs/database/attachments.md.
2. `fix(admin): preview public images through the admin thumb route when their URL fails`: admin/src/components/form/fields/FileuploadField.vue, admin/src/api/files.ts, admin/tests/form/FileuploadField.test.ts, docs/backend/forms.md, docs/backend/admin-spa.md, docs/services/storage.md.
3. `chore(quick-261020-13e): rebuild embedded admin dist`: modules/boardwalk/dist only.
No push, no tag, no change outside summercms.go (the sm-demo-app config is the orchestrator's).
</action>
<verify>
<automated>go vet ./... && go test -short ./... && go test ./cmd/summer -run TestDocsTree -count=1 && go run ./cmd/summer docs:build --check && scripts/check-admin-openapi.sh --check && npm --prefix admin run test && scripts/check-admin-dist.sh && grep -q 'Thumbnail of an image file' modules/cabana/admin_openapi.go && C1=$(git log -1 --format=%H --fixed-strings --grep='fix(cabana): serve public image thumbnails on the admin thumb routes') && C2=$(git log -1 --format=%H --fixed-strings --grep='fix(admin): preview public images through the admin thumb route when their URL fails') && C3=$(git log -1 --format=%H --fixed-strings --grep='chore(quick-261020-13e): rebuild embedded admin dist') && test -n "$C1" && test -n "$C2" && test -n "$C3" && B1=$(git log -1 --format=%B "$C1") && B2=$(git log -1 --format=%B "$C2") && B3=$(git log -1 --format=%B "$C3") && ! printf '%s\n%s\n%s\n' "$B1" "$B2" "$B3" | grep -qi 'co-authored' && F1=$(git diff --name-only "$C1^..$C1") && F2=$(git diff --name-only "$C2^..$C2") && F3=$(git diff --name-only "$C3^..$C3") && test -n "$F1" && test -n "$F2" && test -n "$F3" && test -z "$(printf '%s\n' "$F1" | grep -v -E '^(modules/cabana/|admin/openapi/|admin/src/api/schema\.d\.ts$|docs/database/)')" && test -z "$(printf '%s\n' "$F2" | grep -v -E '^(admin/src/|admin/tests/|docs/backend/|docs/services/)')" && test -z "$(printf '%s\n' "$F3" | grep -v -E '^modules/boardwalk/dist/')"</automated>
</verify>
<done>The annotations, README and five docs pages describe the thumb contract and the SPA fallback, and the generated OpenAPI files and the embedded dist match fresh generations. Every gate passes. Three commits (found by subject, not by HEAD, since other sessions may commit in between) touch only their own path groups and carry no attribution trailer. `git status --short` shows nothing left over from this plan. Nothing is pushed or tagged.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| admin SPA -> cabana admin API (thumb routes) | Authenticated backend-guard requests carrying a file id and a session key header; the id is attacker-controllable by any signed-in admin. |
| stored upload bytes -> admin origin | A public file's bytes (possibly SVG/HTML uploaded elsewhere) are now reachable through an admin-origin route as a thumbnail. |
| public upload origin -> admin page | The admin page loads public image URLs; their failure now triggers an authenticated fetch. |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-261020-13e-01 | I | cabana fileThumb / relationChildFileThumb serving public files | medium | mitigate | Same withFileScope + findFile query as protected files (FormExtendQuery record, or the admin's own pending session); TestPublicImageThumb asserts 404 for another gadget, a hidden gadget, another admin's pending upload and another parent's child file, with no state change. |
| T-261020-13e-02 | T/E | thumb route rendering a stored public SVG/HTML in the admin origin | high | mitigate | The thumb branch keeps the AllowedImageMIMEs gate (JPEG/PNG/GIF/WebP only, else 404) and serves the re-encoded thumbnail blob; every response keeps nosniff, private no-store and `default-src 'none'; sandbox`; tests cover public SVG and text thumbs as 404 and image thumbs with securityHeaders. |
| T-261020-13e-03 | I | download route | low | mitigate | Public-file refusal on the download route is unchanged (thumb=false still 404); tests assert download 404 for public photo, mixed row and relation-child public row. |
| T-261020-13e-04 | D | on-demand thumbnail generation through the admin route | low | accept | Same lazy attach.File.ThumbKey with the field's dimensions that the file list already runs for public images; the blob is cached by key; admin-authenticated and permission-gated by protect; at most one fallback fetch per item. |
| T-261020-13e-05 | I | session key in fallback request | low | mitigate | Fallback reuses routes.thumb (session key in the X-Session-Key / X-Child-Session-Key header, never the URL); vitest asserts an empty query string. |
| T-261020-13e-06 | D | object URL leak in the SPA | low | mitigate | Fallback object URLs live in the same thumbs map, revoked by forgetThumb and on unmount; vitest asserts revoke on unmount. |
| T-261020-13e-SC | Tampering | npm/go installs | low | accept | No new dependency; `npm --prefix admin ci` only from the committed lockfile if node_modules is missing. |
</threat_model>
<verification>
- `go test ./modules/cabana -run 'TestPublicImageThumb|TestProtectedFile|TestRelationChildScope|TestFileuploadSmoke' -count=1` green on Postgres (via `sudo -u golem` if the Docker socket is denied).
- `go vet ./...` and `go test -short ./...` green.
- `npm --prefix admin run test` and `npm --prefix admin run typecheck` green.
- `scripts/check-admin-openapi.sh --check` and `scripts/check-admin-dist.sh` pass.
- `go test ./cmd/summer -run TestDocsTree -count=1` and `go run ./cmd/summer docs:build --check` pass.
</verification>
<success_criteria>
- A public image whose URL 404s in the admin shows its thumbnail from the authenticated thumb route; preview_unavailable appears only when that also fails.
- Both admin thumb routes serve public image thumbnails within the existing scope and headers; download routes, non-images and out-of-scope files stay 404.
- Contract docs, OpenAPI artifacts and the embedded dist are current; three clean commits; nothing pushed or tagged.
</success_criteria>
<output>
Create `.planning/quick/261020-13e-admin-file-upload-widget-image-preview-a/261020-13e-SUMMARY.md` when done. List: the three commit hashes; whether the Postgres tests ran directly or through `sudo -u golem` (or not at all, with the reason); whether schema.d.ts changed; any defect Task 2 found in the Task 1 SPA logic. Note that the sm-demo-app `storage.uploads.serve: true` change is handled outside this plan, and that consuming apps pick the fix up with their next framework version bump (no tag here).
</output>

View File

@@ -0,0 +1,101 @@
---
phase: quick-261020-13e
plan: 01
quick_id: 261020-13e
subsystem: cabana, admin
tags: [fileupload, attachments, admin-spa, thumbnails]
status: complete
requires: []
provides:
- admin thumb routes (record and relation child) serve public JPEG/PNG/GIF/WebP thumbnails under the existing scope and D-10 headers
- FileuploadField falls back to the authenticated thumb route when a public image URL fails to load
affects: [modules/cabana, admin, modules/boardwalk/dist, docs]
tech-stack:
added: []
patterns:
- "img @error -> one authenticated thumb fetch -> object URL, revoked on forget/unmount"
key-files:
created: []
modified:
- modules/cabana/field_file.go
- modules/cabana/protected_file_test.go
- modules/cabana/admin_openapi.go
- modules/cabana/README.md
- admin/openapi/admin.json
- admin/src/api/schema.d.ts
- admin/src/api/files.ts
- admin/src/components/form/fields/FileuploadField.vue
- admin/tests/form/FileuploadField.test.ts
- docs/database/attachments.md
- docs/backend/forms.md
- docs/backend/admin-spa.md
- docs/services/storage.md
- modules/boardwalk/dist/index.html
- modules/boardwalk/dist/assets/
decisions:
- "The thumb routes skip the public-file refusal only on the thumb branch; the download routes still refuse every public file"
- "The image-mode 'open full size' link keeps pointing at the public URL (not the preview; the download route stays closed to public files)"
metrics:
duration: ~25min
completed: 2026-10-20
estimate:
tokens: 110000
tasks: 3
actuals:
tokens: 9378
tasks: 3
commits: 3
plan_head_before: 5978998ea638213a91008294eb53feceaa18ab8b
plan_head_after: 1ab7db428ace109f25c859e3f8a15dc0bcb9b00f
---
# Quick 261020-13e: Admin fileupload previews public images through the authenticated thumb route Summary
When a public image's URL fails to load in the admin, the fileupload field now gets the preview through the cabana thumb route instead (session key sent in a header, image shown from an object URL). This works for both the parent-record route and the relation-child route. On the server, the thumb route now serves public JPEG/PNG/GIF/WebP thumbnails with the same scope and headers as protected ones. The download routes still return 404 for public files.
## Commits
| Hash | Message |
|------|---------|
| e4302bf | fix(cabana): serve public image thumbnails on the admin thumb routes |
| 1806e5a | fix(admin): preview public images through the admin thumb route when their URL fails |
| 1ab7db4 | chore(quick-261020-13e): rebuild embedded admin dist |
## What changed
- **Server** (`modules/cabana/field_file.go`): `serveProtectedFileOn` returns `recordNotFound{}` for a public file only when `thumb` is false. Nothing else changed: scope (withFileScope/findFile), the AllowedImageMIMEs gate, ThumbKey with the field's dimensions, and the D-10 headers. The `serveProtectedFile` and `fileThumb` doc comments now state the new contract, which `relationChildFileThumb` shares.
- **SPA** (`FileuploadField.vue`): new reactive `fallback` set. `previewOf` returns the object URL from `thumbs` once an item is in fallback. `onPreviewError` falls back once to `loadThumb` for public fields. If there are no routes or no `URL.createObjectURL`, the item is marked broken at once. A failure in fallback, or on a protected field, also marks it broken. `forgetThumb` clears the fallback entry. `files.ts`: comments only.
- **Docs/OpenAPI**: AdminFileThumb / AdminRelationChildFileThumb annotations updated ("Thumbnail of an image file"). Also updated: the cabana README, attachments.md, forms.md, admin-spa.md and storage.md. admin.json and schema.d.ts were regenerated.
## Verification
- Postgres tests ran **directly**. The Docker socket was accessible, so `sudo -u golem` was not needed. `go test ./modules/cabana -run 'TestPublicImageThumb|TestProtectedFile|TestRelationChildScope|TestFileuploadSmoke' -count=1` passed, including the six TestPublicImageThumb subtests:
- owner
- foreign and hidden records
- image types (txt/SVG 404; GIF/WebP/JPEG 200)
- public row under the protected field
- another admin's pending upload
- relation child
- `go vet ./...` and `go test -short ./...` passed.
- `go test ./cmd/summer -run TestDocsTree` passed, and `summer docs:build --check` found no problems.
- `npm --prefix admin run test` passed (93 files, 1435 tests). `typecheck` passed.
- `scripts/check-admin-openapi.sh --check` passed, as did `scripts/check-admin-dist.sh`.
- RED was observed before GREEN for both the Go owner case (thumb 404) and the vitest fallback case (no thumb request).
- **schema.d.ts changed**, but only the regenerated summary/description strings of the two thumb operations.
## Deviations from Plan
None. The plan was executed as written. While trying things out I briefly renamed the thumbnail failure log message. I reverted it to keep the diff minimal, as the plan's "keep everything else unchanged" asks.
Task 2's vitest cases found **no defect** in the Task 1 SPA logic: a double failure makes exactly one thumb request, the field shows "preview unavailable" when the thumb route returns 404, and there is no request while the public URL loads.
## Notes
- The sm-demo-app `storage.uploads.serve: true` change is handled outside this plan, by the orchestrator in that repository.
- No tag was made and nothing was pushed. Consuming apps get the fix with their next framework version bump.
- The dist commit deletes the previous hashed assets (`editor-b0KknApp.js`, `index-DzKf4ihI.js`). These deletions are intentional because a rebuild replaces them.
## Self-Check: PASSED
- Commits e4302bf, 1806e5a, 1ab7db4 exist on master. Each touches only its own path group, and none has an attribution trailer.
- All modified files listed in key-files exist. `git status` shows no leftover files from this plan.