diff --git a/admin/src/api/files.ts b/admin/src/api/files.ts index b44bab35..279a8057 100644 --- a/admin/src/api/files.ts +++ b/admin/src/api/files.ts @@ -7,8 +7,9 @@ // Uploads use XMLHttpRequest, the only browser API that reports upload // progress. The request carries the same X-Requested-With header and // same-origin cookie as the typed client, and a 401 refreshes the session once -// and sends the file again. Protected thumbnails and downloads are fetched as -// blobs through the typed client, so the SPA can show them from object URLs. +// and sends the file again. Protected downloads and image thumbnails (of a +// protected file, or of a public image whose URL failed to load) are fetched +// as blobs through the typed client, so the SPA can show them from object URLs. import { REQUESTED_WITH, api, refreshSession, reportUnauthorized } from './client' import type { AdminFileCaptionRequest, ControllerParams, ErrorBody, FileItem, FileMutationResult } from './types' import { runtime } from '../app/runtime' @@ -48,7 +49,7 @@ export interface FileRoutes { reorder(ids: number[]): Promise> /** A protected file's bytes, or null when they could not be loaded. */ download(file: number): Promise - /** A protected image's preview thumbnail, or null. */ + /** An image's preview thumbnail (protected, or public when its URL failed), or null. */ thumb(file: number): Promise } diff --git a/admin/src/components/form/fields/FileuploadField.vue b/admin/src/components/form/fields/FileuploadField.vue index b5aa5051..313f1798 100644 --- a/admin/src/components/form/fields/FileuploadField.vue +++ b/admin/src/components/form/fields/FileuploadField.vue @@ -71,10 +71,18 @@ const draggedUid = ref(null) const overUid = ref(null) const captionUid = ref(null) const captionOpen = ref(false) -/** Protected thumbnails: item uid -> object URL. */ +/** + * Thumbnails fetched through the admin thumb route: item uid -> object URL. + * Covers protected files and public images in fallback. + */ const thumbs = ref>({}) /** Items whose preview could not be shown. */ const broken = ref>(new Set()) +/** + * Public images whose public URL failed to load; they are previewed through + * the admin thumb route instead, like protected files. + */ +const fallback = ref>(new Set()) const requested = new Set() const transient: string[] = [] const routes: FileRoutes | null = session ? session.routes(props.field.name) : null @@ -657,7 +665,7 @@ function previewOf(item: Item): string | null { if (!item.file || !isImage(item) || broken.value.has(item.uid)) { return null } - if (!props.field.protected) { + if (!props.field.protected && !fallback.value.has(item.uid)) { return item.file.thumb_url || item.file.url || null } return thumbs.value[item.uid] ?? null @@ -678,6 +686,11 @@ function forgetThumb(uid: number): void { thumbs.value = next } requested.delete(uid) + if (fallback.value.has(uid)) { + const next = new Set(fallback.value) + next.delete(uid) + fallback.value = next + } } async function loadThumb(item: Item): Promise { @@ -710,7 +723,19 @@ watch( }, ) +/** + * An img failed to load. A public image falls back once to the admin thumb + * route (an application may not serve its public URL); a protected file, or + * a fallback object URL that failed too, shows the preview as unavailable. + */ function onPreviewError(item: Item): void { + if (!props.field.protected && !fallback.value.has(item.uid)) { + fallback.value = new Set([...fallback.value, item.uid]) + if (routes && typeof URL.createObjectURL === 'function') { + void loadThumb(item) + return + } + } broken.value = new Set([...broken.value, item.uid]) } diff --git a/admin/tests/form/FileuploadField.test.ts b/admin/tests/form/FileuploadField.test.ts index 9f1e75c0..a9de057e 100644 --- a/admin/tests/form/FileuploadField.test.ts +++ b/admin/tests/form/FileuploadField.test.ts @@ -355,6 +355,79 @@ describe('protected thumbnails (backstop 3)', () => { }) }) +describe('public preview fallback', () => { + const thumbPath = `${BASE}/7/files/photos/41/thumb` + + it('previews a public image through the thumb route when its URL fails and revokes the object URL on unmount', async () => { + const create = vi.fn(() => 'blob:fallback-41') + const revoke = vi.fn() + objectUrls(create, revoke) + const { wrapper, calls } = await setup({ + routes: { + [`GET ${BASE}/7/files/photos`]: listOf([item(41, 'a.png')]), + [`GET ${thumbPath}`]: () => ({ body: 'png-bytes' }), + }, + }) + expect(wrapper.find('[data-file-item] img').attributes('src')).toBe('/storage/app/uploads/public/thumb_a.png') + expect(requestsTo(calls, 'GET', thumbPath)).toHaveLength(0) + await wrapper.find('[data-file-item] img').trigger('error') + await flushPromises() + const [thumb] = requestsTo(calls, 'GET', thumbPath) + expect(thumb).toBeDefined() + expect(thumb!.headers.get(SESSION_HEADER)).toBe(KEY) + expect(new URL(thumb!.url).search).toBe('') + expect(create).toHaveBeenCalledTimes(1) + expect(wrapper.find('[data-file-item] img').attributes('src')).toBe('blob:fallback-41') + expect(wrapper.find('[data-preview-unavailable]').exists()).toBe(false) + expect(revoke).not.toHaveBeenCalled() + wrapper.unmount() + expect(revoke).toHaveBeenCalledWith('blob:fallback-41') + }) + + it('shows the preview as unavailable when the thumb route fails too', async () => { + objectUrls(vi.fn(() => 'blob:x'), vi.fn()) + const { wrapper, calls } = await setup({ + routes: { [`GET ${BASE}/7/files/photos`]: listOf([item(41, 'a.png')]) }, + }) + await wrapper.find('[data-file-item] img').trigger('error') + await flushPromises() + expect(requestsTo(calls, 'GET', thumbPath)).toHaveLength(1) + expect(wrapper.find('[data-preview-unavailable]').exists()).toBe(true) + expect(wrapper.find('[data-file-item] img').exists()).toBe(false) + }) + + it('makes no thumb request while the public URL loads', async () => { + objectUrls(vi.fn(() => 'blob:x'), vi.fn()) + const { wrapper, calls } = await setup({ + routes: { + [`GET ${BASE}/7/files/photos`]: listOf([item(41, 'a.png')]), + [`GET ${thumbPath}`]: () => ({ body: 'png-bytes' }), + }, + }) + await flushPromises() + expect(requestsTo(calls, 'GET', thumbPath)).toHaveLength(0) + expect(wrapper.find('[data-file-item] img').attributes('src')).toBe('/storage/app/uploads/public/thumb_a.png') + expect(wrapper.find('[data-preview-unavailable]').exists()).toBe(false) + }) + + it('shows the preview as unavailable when the fallback image fails as well, after one thumb request', async () => { + objectUrls(vi.fn(() => 'blob:fallback-41'), vi.fn()) + const { wrapper, calls } = await setup({ + routes: { + [`GET ${BASE}/7/files/photos`]: listOf([item(41, 'a.png')]), + [`GET ${thumbPath}`]: () => ({ body: 'not-an-image' }), + }, + }) + await wrapper.find('[data-file-item] img').trigger('error') + await flushPromises() + expect(wrapper.find('[data-file-item] img').attributes('src')).toBe('blob:fallback-41') + await wrapper.find('[data-file-item] img').trigger('error') + await flushPromises() + expect(wrapper.find('[data-preview-unavailable]').exists()).toBe(true) + expect(requestsTo(calls, 'GET', thumbPath)).toHaveLength(1) + }) +}) + describe('keyboard reorder (backstop 4)', () => { const three = [item(41, 'a.png'), item(42, 'b.png'), item(43, 'c.png')] diff --git a/docs/backend/admin-spa.md b/docs/backend/admin-spa.md index e0c5b336..287e8c94 100644 --- a/docs/backend/admin-spa.md +++ b/docs/backend/admin-spa.md @@ -25,7 +25,7 @@ A plugin route under the admin prefix also fails the start-up: the SPA and the a The SPA signs in through the admin API and keeps the token in the HttpOnly cookie described on [Users and permissions](users-and-permissions.md). For each screen it loads the controller's localized schema (`schema/list`, `schema/form`), then the records, and renders the fields and columns the schema names. Strings come from `GET /api/v1/lang`, the `backend::lang` bundle with CLDR plural forms, in the administrator's chosen language (sent as `?locale=`) or else the request locale. -Each record form makes a session key when it opens: 32 random bytes, base64url encoded. The form sends it in the `X-Session-Key` header with every file upload, file list and file removal, and with the final create or update save. Uploads and removals are deferred: the server holds them against the key and the admin, and the save that carries the same key commits them in its transaction. Until then the form counts as unsaved, so leaving it asks first, and a new record's files go to record id `0`. The form will not save while an upload is still in flight. Uploads use `XMLHttpRequest` for progress events and carry the same `X-Requested-With` header and cookie as every other call, plus an `X-Upload-Id` so a retry returns the already stored file. Files of a protected relation are fetched through the admin API with the key and shown from object URLs. The key travels only in headers, never in a URL. See [File uploads](forms.md#file-uploads) for the `fileupload` field. +Each record form makes a session key when it opens: 32 random bytes, base64url encoded. The form sends it in the `X-Session-Key` header with every file upload, file list and file removal, and with the final create or update save. Uploads and removals are deferred: the server holds them against the key and the admin, and the save that carries the same key commits them in its transaction. Until then the form counts as unsaved, so leaving it asks first, and a new record's files go to record id `0`. The form will not save while an upload is still in flight. Uploads use `XMLHttpRequest` for progress events and carry the same `X-Requested-With` header and cookie as every other call, plus an `X-Upload-Id` so a retry returns the already stored file. Files of a protected relation are fetched through the admin API with the key and shown from object URLs, and so is the preview of a public image whose public URL fails to load. The key travels only in headers, never in a URL. See [File uploads](forms.md#file-uploads) for the `fileupload` field. A list whose schema carries declared bulk actions shows a "Bulk actions" menu after the selection count. The menu lists only the actions the server offered to this administrator, and its button stays disabled until a row is selected. Choosing an action always asks for confirmation; the dialog stays open while the request runs, and the list reloads afterwards. See [Bulk actions](admin-controllers.md#bulk-actions). diff --git a/docs/backend/forms.md b/docs/backend/forms.md index 832c6f56..8e9d8fea 100644 --- a/docs/backend/forms.md +++ b/docs/backend/forms.md @@ -247,7 +247,7 @@ The field takes the generic keys plus these WinterCMS keys: Uploads are deferred until the form is saved, on the create form and the update form alike, as WinterCMS's file upload widget does. The admin SPA makes a random session key when it opens a form and sends it in the `X-Session-Key` header with every upload and with the save. The upload stores the file unattached and records a pending binding for that key and the signed-in administrator; the record's create or update save attaches every pending file of the form's fileupload fields inside its own transaction. If the save fails with a 422, the uploads stay pending for the next attempt; if the form is left without saving, the daily `deferred:purge` removes them. A session key is only ever seen by the administrator who used it. -Removing a file is deferred the same way: the file disappears from the form at once and is deleted by the save (a pending upload that is removed is deleted at once). On an attachOne relation, saving a new upload deletes the file it replaces. Captions (with `useCaption`) and the order of an attachMany field are saved at once, as in WinterCMS. After applying the session's work, the save checks `maxFiles` and, for a `required: true` fileupload field, that at least one file is attached; a failure is a 422 on the field and keeps the pending work. Files of a protected relation are shown through authenticated admin routes only; see [Protected files in the admin](../database/attachments.md#protected-files-in-the-admin). +Removing a file is deferred the same way: the file disappears from the form at once and is deleted by the save (a pending upload that is removed is deleted at once). On an attachOne relation, saving a new upload deletes the file it replaces. Captions (with `useCaption`) and the order of an attachMany field are saved at once, as in WinterCMS. After applying the session's work, the save checks `maxFiles` and, for a `required: true` fileupload field, that at least one file is attached; a failure is a 422 on the field and keeps the pending work. Files of a protected relation are shown through authenticated admin routes only; see [Protected files in the admin](../database/attachments.md#protected-files-in-the-admin). 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. The limits are enforced on the server: the upload route caps the request body at the smaller of `http.body_limits.upload_bytes` and `maxFilesize` plus 64 KiB (413 `payload_too_large` past it), and a file that is too large, of a type the field does not allow, or not a valid image in image mode is a 422 on the field. diff --git a/docs/services/storage.md b/docs/services/storage.md index 3e6518f7..237cb70b 100644 --- a/docs/services/storage.md +++ b/docs/services/storage.md @@ -115,6 +115,8 @@ The route serves originals and thumbnails and answers 404 for a missing file, an With `serve` on, `public_path_prefix` must be an absolute URL path such as `/storage/uploads`: a leading slash, no scheme or host, no query, no empty, `.` or `..` segment and no `{` or `}`. Start-up fails with an error naming the key otherwise, for example for the WinterCMS disk path `storage/app/uploads/public` or a CDN URL. With `serve` off the key only builds file URLs, so a CDN origin is fine there. +Admin fileupload previews do not depend on this route: when a public image's URL does not load, the admin falls back to its authenticated thumb route (see [File uploads](../backend/forms.md#file-uploads)). + The route looks up the bucket and the database on each request: `route:list` lists it without opening either, and it answers 404 until `serve` has published them. The route owns the whole prefix, so start-up fails when a plugin registers a route at or under it. Without the key, the application decides where files are served: mount `attach.StaticHandlerPublic` itself, or put a web server or CDN in front of the bucket directory. Either way, serve uploads from a separate origin when you can; the [Attachments](../database/attachments.md) page explains why. The built-in route is meant for development and for deployments that cannot split the origin yet.