feat(12.2-04): add the fileupload field with deferred uploads on the form session key
- sessionKey.ts: one 32-byte base64url key per form mount, sent only in headers - api/files.ts: FileRoutes over the record and child file routes, XHR upload with progress, 401 refresh and retry - FileuploadField and FileCaptionModal per UI-SPEC section 3: dropzone, image grid, rows, per-item states, client pre-checks, reorder, protected previews - FormView provides FORM_SESSION, counts pending changes as dirty and sends X-Session-Key on create and update - fileupload lang keys in en and pl, admin-spa docs note, deferred smoke test, rebuilt dist
This commit is contained in:
@@ -26,6 +26,14 @@ export function onUnauthorized(handler: UnauthorizedHandler | null): void {
|
||||
unauthorizedHandler = handler
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports a session that could not be recovered, for requests that do not
|
||||
* go through the typed client (the upload XMLHttpRequest).
|
||||
*/
|
||||
export function reportUnauthorized(): void {
|
||||
unauthorizedHandler?.()
|
||||
}
|
||||
|
||||
/** Registers a listener for successful refreshes (the new access lifetime). */
|
||||
export function onRefreshed(handler: RefreshedHandler | null): void {
|
||||
refreshedHandler = handler
|
||||
|
||||
312
admin/src/api/files.ts
Normal file
312
admin/src/api/files.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
// 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<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. */
|
||||
export interface FileRoutes {
|
||||
list(): Promise<FileCallResult<FileItem[]>>
|
||||
upload(file: File, onProgress?: UploadProgress): 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>
|
||||
/** A protected image's preview thumbnail, 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,
|
||||
): 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)
|
||||
}
|
||||
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) => uploadWithProgress(url, file, { ...header }, onProgress),
|
||||
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) => uploadWithProgress(url, file, { ...header }, onProgress),
|
||||
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',
|
||||
}),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,16 @@ export type MessageForms = Schemas['cabana.MessageForms']
|
||||
/** The public backend::lang bundle: full key to CLDR forms (D-20). */
|
||||
export type LangBundle = Schemas['cabana.LangBundle']
|
||||
export type ErrorEnvelope = Schemas['cabana.ErrorEnvelope']
|
||||
/** One file of a fileupload field; `pending` while it waits for the save. */
|
||||
export type FileItem = Schemas['cabana.FileItem']
|
||||
/** Result of a file removal: how many files were removed. */
|
||||
export type FileMutationResult = Schemas['cabana.FileMutationResult']
|
||||
/** Body of a caption save: title and description, omitted keys unchanged. */
|
||||
export type AdminFileCaptionRequest = Schemas['cabana.AdminFileCaptionRequest']
|
||||
/** Body of a relation link: the ids and, for one id, its pivot values. */
|
||||
export type AdminRelationLinkRequest = Schemas['cabana.AdminRelationLinkRequest']
|
||||
/** A fileupload field's preview thumbnail mode. */
|
||||
export type ThumbOptions = Schemas['cabana.ThumbOptions']
|
||||
/** One record: a string-keyed map read through its list or form schema. */
|
||||
export type AdminRecord = Schemas['cabana.AdminRecord']
|
||||
export type ErrorBody = Schemas['cabana.ErrorBody']
|
||||
|
||||
Reference in New Issue
Block a user