Files
summercms/admin/src/api/files.ts
Jakub Zych 1806e5a36d 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.
2026-10-20 01:05:42 +02:00

321 lines
11 KiB
TypeScript

// File routes of a fileupload field (D-02, D-09, D-10). FileuploadField talks
// to one FileRoutes object and never knows whether it edits a record (the
// record file routes, keyed by X-Session-Key) or a related record inside a
// relation child modal (the child file routes, which also carry
// X-Child-Session-Key). Session keys travel in headers only, never in a URL.
//
// 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 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'
import { CHILD_SESSION_HEADER, SESSION_HEADER } from '../app/sessionKey'
/** The outcome of one JSON file call: data on success, else the error body. */
export interface FileCallResult<T> {
data: T | null
status: number
error: ErrorBody | null
}
/** Why an upload did not store a file. */
export type UploadFailure = 'invalid' | 'too_large' | 'network' | 'failed' | 'aborted'
export type UploadResult =
| { ok: true; item: FileItem }
| { ok: false; reason: UploadFailure; status: number; message: string }
/** A running upload: its result and a way to cancel it. */
export interface UploadHandle {
promise: Promise<UploadResult>
abort(): void
}
export type UploadProgress = (loaded: number, total: number) => void
/** Everything a fileupload control does with its files. */
/** Client upload id sent as X-Upload-Id so a retry returns the stored file. */
export const UPLOAD_ID_HEADER = 'X-Upload-Id'
export interface FileRoutes {
list(): Promise<FileCallResult<FileItem[]>>
upload(file: File, onProgress?: UploadProgress, uploadId?: string): UploadHandle
update(file: number, body: AdminFileCaptionRequest): Promise<FileCallResult<FileItem>>
remove(file: number): Promise<FileCallResult<FileMutationResult>>
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>
/** An image's preview thumbnail (protected, or public when its URL failed), or null. */
thumb(file: number): Promise<Blob | null>
}
interface TypedResult<T> {
data?: { data: T }
error?: { error: ErrorBody }
response: Response
}
async function settle<T>(call: () => Promise<TypedResult<T>>): Promise<FileCallResult<T>> {
try {
const result = await call()
return {
data: result.data ? result.data.data : null,
status: result.response.status,
error: result.error?.error ?? null,
}
} catch {
return { data: null, status: 0, error: null }
}
}
async function blob(call: () => Promise<{ data?: Blob; response: Response }>): Promise<Blob | null> {
try {
const result = await call()
return result.response.ok && result.data instanceof Blob ? result.data : null
} catch {
return null
}
}
function segment(value: string | number): string {
return encodeURIComponent(String(value))
}
function controllerUrl(source: ControllerParams): string {
return `${runtime.api}/${segment(source.vendor)}/${segment(source.plugin)}/${segment(source.controller)}`
}
function parse(text: string): unknown {
try {
return text === '' ? null : JSON.parse(text)
} catch {
return null
}
}
/** The first message of a 422: the field's detail, else the error message. */
export function errorMessage(error: ErrorBody | null | undefined): string {
if (!error) {
return ''
}
for (const value of Object.values(error.details ?? {})) {
const first = Array.isArray(value) ? value.find((item) => typeof item === 'string' && item !== '') : value
if (typeof first === 'string' && first !== '') {
return first
}
}
return error.message ?? ''
}
function classify(status: number, body: unknown): UploadResult {
const envelope = body as { data?: FileItem; error?: ErrorBody } | null
if ((status === 200 || status === 201) && envelope?.data) {
return { ok: true, item: envelope.data }
}
const message = errorMessage(envelope?.error)
if (status === 413) {
return { ok: false, reason: 'too_large', status, message }
}
if (status === 422) {
return { ok: false, reason: 'invalid', status, message }
}
if (status === 0) {
return { ok: false, reason: 'network', status, message }
}
return { ok: false, reason: 'failed', status, message }
}
type Sent = { status: number; body: unknown } | 'network' | 'aborted'
/**
* Posts one file as the multipart part `file_data` with progress events.
* The XMLHttpRequest sends X-Requested-With and the given session headers
* with the same-origin cookie; a 401 refreshes the session once and retries.
*/
export function uploadWithProgress(
url: string,
file: File,
headers: Record<string, string>,
onProgress?: UploadProgress,
uploadId?: string,
): UploadHandle {
let current: XMLHttpRequest | null = null
let aborted = false
const send = () =>
new Promise<Sent>((resolve) => {
const request = new XMLHttpRequest()
current = request
request.open('POST', url)
request.setRequestHeader('X-Requested-With', REQUESTED_WITH)
request.setRequestHeader('Accept', 'application/json')
for (const [name, value] of Object.entries(headers)) {
request.setRequestHeader(name, value)
}
if (uploadId) {
request.setRequestHeader(UPLOAD_ID_HEADER, uploadId)
}
request.upload.onprogress = (event: ProgressEvent) => {
if (event.lengthComputable) {
onProgress?.(event.loaded, event.total)
}
}
request.onload = () => resolve({ status: request.status, body: parse(request.responseText) })
request.onerror = () => resolve('network')
request.ontimeout = () => resolve('network')
request.onabort = () => resolve('aborted')
const form = new FormData()
form.append('file_data', file, file.name)
request.send(form)
})
const promise = (async (): Promise<UploadResult> => {
let sent = await send()
if (typeof sent === 'object' && sent.status === 401 && !aborted) {
if (await refreshSession()) {
sent = aborted ? 'aborted' : await send()
}
if (typeof sent === 'object' && sent.status === 401) {
reportUnauthorized()
}
}
if (sent === 'aborted' || aborted) {
return { ok: false, reason: 'aborted', status: 0, message: '' }
}
if (sent === 'network') {
return { ok: false, reason: 'network', status: 0, message: '' }
}
return classify(sent.status, sent.body)
})()
return {
promise,
abort() {
aborted = true
current?.abort()
},
}
}
/**
* The record file routes of one field. recordId is 0 for the record being
* created; the server then holds every file against sessionKey.
*/
export function parentFileRoutes(
source: ControllerParams,
recordId: number,
field: string,
sessionKey: string,
): FileRoutes {
const path = { ...source, id: recordId, field }
const header = { [SESSION_HEADER]: sessionKey } as { 'X-Session-Key': string }
const url = `${controllerUrl(source)}/${segment(recordId)}/files/${segment(field)}`
return {
list: () => settle(() => api.GET('/{vendor}/{plugin}/{controller}/{id}/files/{field}', { params: { path, header } })),
upload: (file, onProgress, uploadId) => uploadWithProgress(url, file, { ...header }, onProgress, uploadId),
update: (file, body) =>
settle(() =>
api.PUT('/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}', {
params: { path: { ...path, file }, header },
body,
}),
),
remove: (file) =>
settle(() =>
api.DELETE('/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}', {
params: { path: { ...path, file }, header },
}),
),
reorder: (ids) =>
settle(() =>
api.POST('/{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder', {
params: { path, header },
body: { ids },
}),
),
download: (file) =>
blob(() =>
api.GET('/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/download', {
params: { path: { ...path, file }, header },
parseAs: 'blob',
}),
),
thumb: (file) =>
blob(() =>
api.GET('/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/thumb', {
params: { path: { ...path, file }, header },
parseAs: 'blob',
}),
),
}
}
/**
* The file routes of a related record's field inside a relation child modal.
* childId is 0 for the child being created. Both keys travel: the record
* form's key scopes an unsaved parent, the child modal's key holds the files.
*/
export function childFileRoutes(
source: ControllerParams,
recordId: number,
relation: string,
childId: number,
field: string,
sessionKey: string,
childKey: string,
): FileRoutes {
const path = { ...source, id: recordId, name: relation, child: childId, field }
const header = { [SESSION_HEADER]: sessionKey, [CHILD_SESSION_HEADER]: childKey } as {
'X-Session-Key': string
'X-Child-Session-Key': string
}
const url = `${controllerUrl(source)}/${segment(recordId)}/relations/${segment(relation)}/records/${segment(childId)}/files/${segment(field)}`
return {
list: () =>
settle(() =>
api.GET('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}', {
params: { path, header },
}),
),
upload: (file, onProgress, uploadId) => uploadWithProgress(url, file, { ...header }, onProgress, uploadId),
update: (file, body) =>
settle(() =>
api.PUT('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}', {
params: { path: { ...path, file }, header },
body,
}),
),
remove: (file) =>
settle(() =>
api.DELETE('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}', {
params: { path: { ...path, file }, header },
}),
),
reorder: (ids) =>
settle(() =>
api.POST('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/reorder', {
params: { path, header },
body: { ids },
}),
),
download: (file) =>
blob(() =>
api.GET('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}/download', {
params: { path: { ...path, file }, header },
parseAs: 'blob',
}),
),
thumb: (file) =>
blob(() =>
api.GET('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}/thumb', {
params: { path: { ...path, file }, header },
parseAs: 'blob',
}),
),
}
}