fix(admin): preview public images through the admin thumb route when their URL fails

When a public image's thumb_url or url fails to load, the fileupload
field fetches its preview once through the authenticated thumb route
(session key in the header) and shows it from an object URL, revoked
when the item is forgotten and on unmount. The preview is reported as
unavailable only when that fetch or the object-URL image fails too.
Protected fields behave as before.
This commit is contained in:
2026-10-20 01:05:42 +02:00
parent e4302bf60b
commit 1806e5a36d
6 changed files with 108 additions and 7 deletions

View File

@@ -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<FileCallResult<FileItem[]>>
/** A protected file's bytes, or null when they could not be loaded. */
download(file: number): Promise<Blob | null>
/** 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<Blob | null>
}

View File

@@ -71,10 +71,18 @@ const draggedUid = ref<number | null>(null)
const overUid = ref<number | null>(null)
const captionUid = ref<number | null>(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<Record<number, string>>({})
/** Items whose preview could not be shown. */
const broken = ref<Set<number>>(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<Set<number>>(new Set())
const requested = new Set<number>()
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<void> {
@@ -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])
}

View File

@@ -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')]