// 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 thumbnails and downloads 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 { 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 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> upload(file: File, onProgress?: UploadProgress, uploadId?: string): UploadHandle update(file: number, body: AdminFileCaptionRequest): Promise> remove(file: number): Promise> 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. */ thumb(file: number): Promise } interface TypedResult { data?: { data: T } error?: { error: ErrorBody } response: Response } async function settle(call: () => Promise>): Promise> { 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 { 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, onProgress?: UploadProgress, uploadId?: string, ): UploadHandle { let current: XMLHttpRequest | null = null let aborted = false const send = () => new Promise((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 => { 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', }), ), } }