fix(12.2): close code-review blockers on uploads, JSON caps, and pivot fill

Keep form save behind in-flight uploads, make retries idempotent via X-Upload-Id, cap remaining JSON bodies, and surface pending pivot type errors instead of zeroing them.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jakub Zych
2026-10-02 23:10:48 +02:00
parent 6bfc0faa8a
commit 516f9c9025
22 changed files with 533 additions and 96 deletions

View File

@@ -37,9 +37,12 @@ export interface UploadHandle {
export type UploadProgress = (loaded: number, total: number) => void export type UploadProgress = (loaded: number, total: number) => void
/** Everything a fileupload control does with its files. */ /** 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 { export interface FileRoutes {
list(): Promise<FileCallResult<FileItem[]>> list(): Promise<FileCallResult<FileItem[]>>
upload(file: File, onProgress?: UploadProgress): UploadHandle upload(file: File, onProgress?: UploadProgress, uploadId?: string): UploadHandle
update(file: number, body: AdminFileCaptionRequest): Promise<FileCallResult<FileItem>> update(file: number, body: AdminFileCaptionRequest): Promise<FileCallResult<FileItem>>
remove(file: number): Promise<FileCallResult<FileMutationResult>> remove(file: number): Promise<FileCallResult<FileMutationResult>>
reorder(ids: number[]): Promise<FileCallResult<FileItem[]>> reorder(ids: number[]): Promise<FileCallResult<FileItem[]>>
@@ -137,6 +140,7 @@ export function uploadWithProgress(
file: File, file: File,
headers: Record<string, string>, headers: Record<string, string>,
onProgress?: UploadProgress, onProgress?: UploadProgress,
uploadId?: string,
): UploadHandle { ): UploadHandle {
let current: XMLHttpRequest | null = null let current: XMLHttpRequest | null = null
let aborted = false let aborted = false
@@ -151,6 +155,9 @@ export function uploadWithProgress(
for (const [name, value] of Object.entries(headers)) { for (const [name, value] of Object.entries(headers)) {
request.setRequestHeader(name, value) request.setRequestHeader(name, value)
} }
if (uploadId) {
request.setRequestHeader(UPLOAD_ID_HEADER, uploadId)
}
request.upload.onprogress = (event: ProgressEvent) => { request.upload.onprogress = (event: ProgressEvent) => {
if (event.lengthComputable) { if (event.lengthComputable) {
onProgress?.(event.loaded, event.total) onProgress?.(event.loaded, event.total)
@@ -208,7 +215,7 @@ export function parentFileRoutes(
const url = `${controllerUrl(source)}/${segment(recordId)}/files/${segment(field)}` const url = `${controllerUrl(source)}/${segment(recordId)}/files/${segment(field)}`
return { return {
list: () => settle(() => api.GET('/{vendor}/{plugin}/{controller}/{id}/files/{field}', { params: { path, header } })), list: () => settle(() => api.GET('/{vendor}/{plugin}/{controller}/{id}/files/{field}', { params: { path, header } })),
upload: (file, onProgress) => uploadWithProgress(url, file, { ...header }, onProgress), upload: (file, onProgress, uploadId) => uploadWithProgress(url, file, { ...header }, onProgress, uploadId),
update: (file, body) => update: (file, body) =>
settle(() => settle(() =>
api.PUT('/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}', { api.PUT('/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}', {
@@ -273,7 +280,7 @@ export function childFileRoutes(
params: { path, header }, params: { path, header },
}), }),
), ),
upload: (file, onProgress) => uploadWithProgress(url, file, { ...header }, onProgress), upload: (file, onProgress, uploadId) => uploadWithProgress(url, file, { ...header }, onProgress, uploadId),
update: (file, body) => update: (file, body) =>
settle(() => settle(() =>
api.PUT('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}', { api.PUT('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}', {

View File

@@ -287,7 +287,13 @@ export function boundValue(
return day return day
} }
const wall = new CalendarDateTime(day.year, day.month, day.day, end ? 23 : 0, end ? 59 : 0, end ? 59 : 0) const wall = new CalendarDateTime(day.year, day.month, day.day, end ? 23 : 0, end ? 59 : 0, end ? 59 : 0)
return ignoreTimezone ? wall : toZoned(wall, getLocalTimeZone()) if (ignoreTimezone) {
return wall
}
// The server checks the saved instant's UTC calendar date. Build the
// bound as a UTC instant so a local midnight just after the UTC day
// change is refused, and a local evening still on that UTC day is allowed.
return toTimeZone(toZoned(wall, 'UTC'), getLocalTimeZone())
} }
/** /**

View File

@@ -55,6 +55,8 @@ interface Item {
file: FileItem | null file: FileItem | null
source: File | null source: File | null
handle: UploadHandle | null handle: UploadHandle | null
/** Stable client id sent on every attempt of this queued file. */
uploadId: string
} }
let nextUid = 1 let nextUid = 1
@@ -78,6 +80,9 @@ const transient: string[] = []
const routes: FileRoutes | null = session ? session.routes(props.field.name) : null const routes: FileRoutes | null = session ? session.routes(props.field.name) : null
let reorderTimer: ReturnType<typeof setTimeout> | null = null let reorderTimer: ReturnType<typeof setTimeout> | null = null
let orderBefore: number[] | null = null let orderBefore: number[] | null = null
let orderInFlight = false
let pendingOrder: number[] | null = null
let orderGen = 0
let unmounted = false let unmounted = false
const readOnly = computed(() => { const readOnly = computed(() => {
@@ -240,9 +245,14 @@ function itemFor(file: FileItem): Item {
file, file,
source: null, source: null,
handle: null, handle: null,
uploadId: '',
}) as Item }) as Item
} }
function newUploadId(): string {
return globalThis.crypto?.randomUUID?.() ?? `u${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
}
function localItem(source: File, error: string): Item { function localItem(source: File, error: string): Item {
return reactive<Item>({ return reactive<Item>({
uid: nextUid++, uid: nextUid++,
@@ -255,6 +265,7 @@ function localItem(source: File, error: string): Item {
file: null, file: null,
source: markRaw(source), source: markRaw(source),
handle: null, handle: null,
uploadId: newUploadId(),
}) as Item }) as Item
} }
@@ -380,48 +391,86 @@ async function upload(item: Item): Promise<void> {
item.progress = 0 item.progress = 0
item.error = '' item.error = ''
item.retryable = false item.retryable = false
const handle = routes.upload(item.source, (loaded, total) => { const known = new Set(items.value.filter((entry) => entry.file).map((entry) => entry.file!.id))
item.progress = total > 0 ? Math.min(100, Math.round((loaded / total) * 100)) : 0 const release = session?.beginUpload()
}) try {
item.handle = markRaw(handle) const handle = routes.upload(
const result = await handle.promise item.source,
item.handle = null (loaded, total) => {
const kept = !!find(item.uid) item.progress = total > 0 ? Math.min(100, Math.round((loaded / total) * 100)) : 0
if (result.ok) { },
if (!kept || unmounted) { item.uploadId || undefined,
// Cancelled after the server stored it: drop the pending upload. )
void routes.remove(result.item.id) item.handle = markRaw(handle)
} else { const result = await handle.promise
item.state = 'done' item.handle = null
item.progress = 100 const kept = !!find(item.uid)
item.file = result.item if (result.ok) {
item.name = result.item.file_name if (!kept || unmounted) {
item.size = result.item.file_size // Cancelled after the server stored it: drop the pending upload.
session?.markDirty() void routes.remove(result.item.id)
} } else {
} else if (kept && result.reason !== 'aborted') { item.state = 'done'
item.state = 'failed' item.progress = 100
switch (result.reason) { item.file = result.item
case 'too_large': item.name = result.item.file_name
item.error = maxSizeLabel.value item.size = result.item.file_size
? t('backend::lang.fileupload.too_large', { name: item.name, size: maxSizeLabel.value }) session?.markDirty()
: result.message || t('backend::lang.fileupload.upload_failed') }
break } else if (result.reason === 'aborted' || result.reason === 'network') {
case 'invalid': const extras = await lostUploads(known)
item.error = result.message || t('backend::lang.fileupload.upload_failed') if (result.reason === 'aborted') {
break for (const extra of extras) {
case 'network': void routes.remove(extra.id)
}
items.value = items.value.filter((entry) => entry.uid !== item.uid)
forgetThumb(item.uid)
} else if (kept && extras[0] && extras.length === 1) {
item.state = 'done'
item.progress = 100
item.file = extras[0]
item.name = extras[0].file_name
item.size = extras[0].file_size
session?.markDirty()
} else {
item.state = 'failed'
item.error = t('backend::lang.fileupload.upload_failed') item.error = t('backend::lang.fileupload.upload_failed')
item.retryable = true item.retryable = true
break }
default: } else if (kept) {
item.error = result.message || t('backend::lang.fileupload.upload_failed') item.state = 'failed'
item.retryable = result.status >= 500 switch (result.reason) {
case 'too_large':
item.error = maxSizeLabel.value
? t('backend::lang.fileupload.too_large', { name: item.name, size: maxSizeLabel.value })
: result.message || t('backend::lang.fileupload.upload_failed')
break
case 'invalid':
item.error = result.message || t('backend::lang.fileupload.upload_failed')
break
default:
item.error = result.message || t('backend::lang.fileupload.upload_failed')
item.retryable = result.status >= 500
}
}
} finally {
release?.()
if (!unmounted) {
pump()
} }
} }
if (!unmounted) { }
pump()
/** Pending files that appeared after this upload started (response lost). */
async function lostUploads(known: Set<number>): Promise<FileItem[]> {
if (!routes) {
return []
} }
const listed = await routes.list()
if (!listed.data) {
return []
}
return listed.data.filter((file) => file.pending && !known.has(file.id))
} }
function retry(item: Item): void { function retry(item: Item): void {
@@ -488,19 +537,40 @@ function scheduleReorder(): void {
}, REORDER_DEBOUNCE) }, REORDER_DEBOUNCE)
} }
function currentFileIds(): number[] {
return items.value.filter((item) => item.state === 'done' && item.file).map((item) => item.file!.id)
}
async function sendOrder(): Promise<void> { async function sendOrder(): Promise<void> {
const before = orderBefore
orderBefore = null
if (!routes) { if (!routes) {
return return
} }
const ids = items.value.filter((item) => item.state === 'done' && item.file).map((item) => item.file!.id) const ids = currentFileIds()
const result = await routes.reorder(ids) if (orderInFlight) {
if (!result.data && !unmounted) { pendingOrder = ids
if (before) { return
restore(before) }
const before = orderBefore
orderBefore = null
const gen = ++orderGen
orderInFlight = true
try {
const result = await routes.reorder(ids)
if (pendingOrder) {
return
}
if (!result.data && !unmounted && gen === orderGen) {
if (before) {
restore(before)
}
showToast(t('backend::lang.fileupload.reorder_failed'), 'danger')
}
} finally {
orderInFlight = false
if (pendingOrder) {
pendingOrder = null
void sendOrder()
} }
showToast(t('backend::lang.fileupload.reorder_failed'), 'danger')
} }
} }

View File

@@ -3,7 +3,7 @@
// writes action results back through patch, which marks the form dirty and // writes action results back through patch, which marks the form dirty and
// clears that field's errors exactly like typing into it. A form without a // clears that field's errors exactly like typing into it. A form without a
// provider (settings pages) gets the defaults of the injecting control. // provider (settings pages) gets the defaults of the injecting control.
import type { InjectionKey, Ref } from 'vue' import { ref, type InjectionKey, type Ref } from 'vue'
import type { FileRoutes } from '../../api/files' import type { FileRoutes } from '../../api/files'
import type { AdminRecord } from '../../api/types' import type { AdminRecord } from '../../api/types'
@@ -46,6 +46,35 @@ export interface FormSession {
pendingChanges: Readonly<Ref<number>> pendingChanges: Readonly<Ref<number>>
/** Goes up after every successful save, so fields can reload their files. */ /** Goes up after every successful save, so fields can reload their files. */
revision: Readonly<Ref<number>> revision: Readonly<Ref<number>>
/**
* Registers an in-flight upload. Call the returned function in `finally`
* when the request reaches a terminal state. The form will not submit
* while any upload is still open.
*/
beginUpload(): () => void
/** How many uploads have not yet reached a terminal state. */
activeUploads: Readonly<Ref<number>>
}
/** Counter and register/release pair used by record and child forms. */
export function createUploadGate(): { activeUploads: Ref<number>; beginUpload: () => () => void } {
const activeUploads = ref(0)
return {
activeUploads,
beginUpload() {
activeUploads.value++
let released = false
return () => {
if (released) {
return
}
released = true
if (activeUploads.value > 0) {
activeUploads.value--
}
}
},
}
} }
export const FORM_SESSION: InjectionKey<FormSession> = Symbol('summer.form.session') export const FORM_SESSION: InjectionKey<FormSession> = Symbol('summer.form.session')

View File

@@ -17,7 +17,7 @@ import { CHILD_SESSION_HEADER, SESSION_HEADER, newSessionKey } from '../../app/s
import FormErrorBanner from '../form/FormErrorBanner.vue' import FormErrorBanner from '../form/FormErrorBanner.vue'
import FormGrid from '../form/FormGrid.vue' import FormGrid from '../form/FormGrid.vue'
import FormTabs from '../form/FormTabs.vue' import FormTabs from '../form/FormTabs.vue'
import { FORM_PATCH, FORM_SESSION, FORM_VALUES, type FormSession } from '../form/formContext' import { FORM_PATCH, FORM_SESSION, FORM_VALUES, createUploadGate, type FormSession } from '../form/formContext'
import { import {
DEFAULT_TAB, DEFAULT_TAB,
contextAllows, contextAllows,
@@ -79,6 +79,7 @@ const activeTab = ref(DEFAULT_TAB)
const childKey = ref(newSessionKey()) const childKey = ref(newSessionKey())
const pendingChanges = ref(0) const pendingChanges = ref(0)
const revision = ref(0) const revision = ref(0)
const { activeUploads, beginUpload } = createUploadGate()
const body = ref<HTMLElement | null>(null) const body = ref<HTMLElement | null>(null)
const confirm = useConfirm() const confirm = useConfirm()
let generation = 0 let generation = 0
@@ -148,7 +149,9 @@ const dirty = computed(
!preview.value && !preview.value &&
!loading.value && !loading.value &&
!failed.value && !failed.value &&
(pendingChanges.value > 0 || snapshot(editablePayload(fields.value, values.value)) !== saved.value), (pendingChanges.value > 0 ||
activeUploads.value > 0 ||
snapshot(editablePayload(fields.value, values.value)) !== saved.value),
) )
function headers() { function headers() {
@@ -189,6 +192,8 @@ const session: FormSession = {
}, },
pendingChanges: readonly(pendingChanges), pendingChanges: readonly(pendingChanges),
revision: readonly(revision), revision: readonly(revision),
beginUpload,
activeUploads: readonly(activeUploads),
} }
provide(FORM_SESSION, session) provide(FORM_SESSION, session)
provide(FORM_VALUES, computed(() => values.value)) provide(FORM_VALUES, computed(() => values.value))
@@ -319,7 +324,7 @@ async function showErrors(details: Record<string, unknown> | undefined): Promise
} }
async function onSubmit(): Promise<void> { async function onSubmit(): Promise<void> {
if (busy.value || preview.value || loading.value || failed.value) { if (busy.value || preview.value || loading.value || failed.value || activeUploads.value > 0) {
return return
} }
busy.value = true busy.value = true
@@ -506,7 +511,7 @@ function onCloseAutoFocus(event: Event): void {
v-if="!failed" v-if="!failed"
variant="primary" variant="primary"
data-action="save-child" data-action="save-child"
:disabled="busy || loading" :disabled="busy || loading || activeUploads > 0"
@click="onSubmit" @click="onSubmit"
> >
{{ busy ? t('backend::lang.form.saving') : submitLabel }} {{ busy ? t('backend::lang.form.saving') : submitLabel }}

View File

@@ -13,7 +13,7 @@ import { mapWinterUrl } from '../app/winterUrl'
import FormErrorBanner from '../components/form/FormErrorBanner.vue' import FormErrorBanner from '../components/form/FormErrorBanner.vue'
import FormGrid from '../components/form/FormGrid.vue' import FormGrid from '../components/form/FormGrid.vue'
import FormTabs from '../components/form/FormTabs.vue' import FormTabs from '../components/form/FormTabs.vue'
import { FORM_ASSETS, FORM_LOCALE, FORM_PATCH, FORM_SESSION, FORM_VALUES } from '../components/form/formContext' import { FORM_ASSETS, FORM_LOCALE, FORM_PATCH, FORM_SESSION, FORM_VALUES, createUploadGate } from '../components/form/formContext'
import { RELATION_MANAGER, needsRecord } from '../components/form/registry' import { RELATION_MANAGER, needsRecord } from '../components/form/registry'
import { import {
DEFAULT_TAB, DEFAULT_TAB,
@@ -79,6 +79,7 @@ const sessionHeader = { [SESSION_HEADER]: sessionKey } as { 'X-Session-Key': str
const pendingChanges = ref(0) const pendingChanges = ref(0)
// Bumped after each successful save, so file fields reload their lists. // Bumped after each successful save, so file fields reload their lists.
const revision = ref(0) const revision = ref(0)
const { activeUploads, beginUpload } = createUploadGate()
// A relation manager needs a saved record: on create it is dropped with its // A relation manager needs a saved record: on create it is dropped with its
// tab even when the YAML forgets `context: update` (D-05, design screen 5). // tab even when the YAML forgets `context: update` (D-05, design screen 5).
@@ -151,7 +152,9 @@ const subtitle = computed(() =>
const dirty = computed( const dirty = computed(
() => () =>
!loading.value && !loading.value &&
(pendingChanges.value > 0 || snapshot(editablePayload(fields.value, values.value)) !== saved.value), (pendingChanges.value > 0 ||
activeUploads.value > 0 ||
snapshot(editablePayload(fields.value, values.value)) !== saved.value),
) )
function adopt(record: RecordEnvelope | undefined): void { function adopt(record: RecordEnvelope | undefined): void {
@@ -211,6 +214,8 @@ provide(FORM_SESSION, {
}, },
pendingChanges: readonly(pendingChanges), pendingChanges: readonly(pendingChanges),
revision: readonly(revision), revision: readonly(revision),
beginUpload,
activeUploads: readonly(activeUploads),
}) })
/** 422: messages under fields, the first invalid field (schema order) focused. */ /** 422: messages under fields, the first invalid field (schema order) focused. */
@@ -236,7 +241,7 @@ function redirectTarget(kind: 'redirect' | 'redirectClose', id: unknown): string
/** Saves the record; returns the saved envelope, or null after an error. */ /** Saves the record; returns the saved envelope, or null after an error. */
async function save(): Promise<RecordEnvelope | null> { async function save(): Promise<RecordEnvelope | null> {
if (busy.value || !schema.value) { if (busy.value || !schema.value || activeUploads.value > 0) {
return null return null
} }
busy.value = true busy.value = true
@@ -420,10 +425,10 @@ void load()
</Button> </Button>
<div class="ml-auto flex items-center gap-2.5"> <div class="ml-auto flex items-center gap-2.5">
<Button variant="ghost" data-action="cancel" :to="listPath">{{ t('backend::lang.form.cancel') }}</Button> <Button variant="ghost" data-action="cancel" :to="listPath">{{ t('backend::lang.form.cancel') }}</Button>
<Button variant="outline" data-action="save-close" :disabled="busy || loading || !schema" @click="onSaveAndClose"> <Button variant="outline" data-action="save-close" :disabled="busy || loading || !schema || activeUploads > 0" @click="onSaveAndClose">
{{ t('backend::lang.form.save_and_close') }} {{ t('backend::lang.form.save_and_close') }}
</Button> </Button>
<Button variant="primary" data-action="save" :disabled="busy || loading || !schema" @click="onSave"> <Button variant="primary" data-action="save" :disabled="busy || loading || !schema || activeUploads > 0" @click="onSave">
{{ busy ? t('backend::lang.form.saving') : t('backend::lang.form.save') }} {{ busy ? t('backend::lang.form.saving') : t('backend::lang.form.save') }}
</Button> </Button>
</div> </div>

View File

@@ -196,7 +196,25 @@ describe('dayBounds and boundValue', () => {
expect(boundValue(day, 'datetime', true, true)?.toString()).toBe('2026-10-02T23:59:59') expect(boundValue(day, 'datetime', true, true)?.toString()).toBe('2026-10-02T23:59:59')
const zoned = boundValue(day, 'datetime', false, false) as ZonedDateTime const zoned = boundValue(day, 'datetime', false, false) as ZonedDateTime
expect(zoned.timeZone).toBe('Europe/Warsaw') expect(zoned.timeZone).toBe('Europe/Warsaw')
expect([zoned.hour, zoned.minute]).toEqual([0, 0]) // UTC 2026-10-02 00:00 is 02:00 in Warsaw (CEST).
expect([zoned.hour, zoned.minute]).toEqual([2, 0])
const max = boundValue(day, 'datetime', false, true) as ZonedDateTime
expect([max.hour, max.minute, max.second]).toEqual([1, 59, 59])
expect(max.day).toBe(3)
})
it('uses UTC-day instants so a local midnight just after the UTC date change is out of range', () => {
const min = boundValue(new CalendarDate(2000, 1, 1), 'datetime', false, false) as ZonedDateTime
const local = parseFieldValue('datetime', '2000-01-01T00:30:00+01:00') as ZonedDateTime
expect(local.compare(min)).toBeLessThan(0)
const allowed = parseFieldValue('datetime', '2000-01-01T01:30:00+01:00') as ZonedDateTime
expect(allowed.compare(min)).toBeGreaterThanOrEqual(0)
setLocalTimeZone('America/New_York')
const max = boundValue(new CalendarDate(2000, 1, 1), 'datetime', false, true) as ZonedDateTime
const evening = parseFieldValue('datetime', '2000-01-01T20:00:00-05:00') as ZonedDateTime
expect(evening.compare(max)).toBeGreaterThan(0)
const afternoon = parseFieldValue('datetime', '2000-01-01T15:00:00-05:00') as ZonedDateTime
expect(afternoon.compare(max)).toBeLessThanOrEqual(0)
}) })
it('starts a fresh picker at today in its value type', () => { it('starts a fresh picker at today in its value type', () => {

View File

@@ -9,13 +9,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { ref } from 'vue' import { ref } from 'vue'
import FileuploadField from '../../src/components/form/fields/FileuploadField.vue' import FileuploadField from '../../src/components/form/fields/FileuploadField.vue'
import { FORM_SESSION, type FormSession } from '../../src/components/form/formContext' import { FORM_SESSION, createUploadGate, type FormSession } from '../../src/components/form/formContext'
import { UPLOAD_ID_HEADER } from '../../src/api/files'
import { parentFileRoutes } from '../../src/api/files' import { parentFileRoutes } from '../../src/api/files'
import type { FileItem, FormField } from '../../src/api/types' import type { FileItem, FormField } from '../../src/api/types'
import { SESSION_HEADER } from '../../src/app/sessionKey' import { SESSION_HEADER } from '../../src/app/sessionKey'
import { toasts } from '../../src/state/useToasts' import { toasts } from '../../src/state/useToasts'
import { deferredFilesFixture } from '../fixtures/typed' import { deferredFilesFixture } from '../fixtures/typed'
import { API, keydown, mockApi, requestsTo, resetState, wait, type Route } from '../helpers' import { API, keydown, mockApi, requestsTo, resetState, wait, type Reply, type Route } from '../helpers'
const SOURCE = { vendor: 'acme', plugin: 'demo', controller: 'gadgets' } const SOURCE = { vendor: 'acme', plugin: 'demo', controller: 'gadgets' }
const KEY = 'k'.repeat(43) const KEY = 'k'.repeat(43)
@@ -90,7 +91,7 @@ function item(id: number, name: string, extra: Partial<FileItem> = {}): FileItem
} }
} }
function listOf(files: FileItem[]): Route { function listOf(files: FileItem[]): Reply {
return { body: { data: files, meta: {} } } return { body: { data: files, meta: {} } }
} }
@@ -105,6 +106,7 @@ async function setup(options: Setup = {}) {
const field = options.field ?? photos() const field = options.field ?? photos()
const calls = mockApi(options.routes ?? { [`GET ${BASE}/${recordId}/files/${field.name}`]: listOf([]) }) const calls = mockApi(options.routes ?? { [`GET ${BASE}/${recordId}/files/${field.name}`]: listOf([]) })
const markDirty = vi.fn() const markDirty = vi.fn()
const { activeUploads, beginUpload } = createUploadGate()
const session: FormSession = { const session: FormSession = {
key: KEY, key: KEY,
recordId, recordId,
@@ -112,6 +114,8 @@ async function setup(options: Setup = {}) {
markDirty, markDirty,
pendingChanges: ref(0), pendingChanges: ref(0),
revision: ref(0), revision: ref(0),
beginUpload,
activeUploads,
} }
const wrapper = mount(FileuploadField, { const wrapper = mount(FileuploadField, {
props: { field, modelValue: undefined, controlId: 'field-photos', describedBy: 'field-photos-comment' }, props: { field, modelValue: undefined, controlId: 'field-photos', describedBy: 'field-photos-comment' },
@@ -119,7 +123,7 @@ async function setup(options: Setup = {}) {
attachTo: document.body, attachTo: document.body,
}) })
await flushPromises() await flushPromises()
return { wrapper, calls, markDirty } return { wrapper, calls, markDirty, activeUploads }
} }
async function choose(wrapper: VueWrapper, files: File[]): Promise<void> { async function choose(wrapper: VueWrapper, files: File[]): Promise<void> {
@@ -193,12 +197,14 @@ describe('dropzone and client checks', () => {
describe('upload states', () => { describe('upload states', () => {
it('goes queued, uploading with progress, done; a server failure can be retried', async () => { it('goes queued, uploading with progress, done; a server failure can be retried', async () => {
const { wrapper, markDirty } = await setup() const { wrapper, markDirty, activeUploads } = await setup()
await choose(wrapper, [png('a.png'), png('b.png')]) await choose(wrapper, [png('a.png'), png('b.png')])
expect(wrapper.find('[data-state="uploading"]').exists()).toBe(true) expect(wrapper.find('[data-state="uploading"]').exists()).toBe(true)
expect(activeUploads.value).toBe(1)
expect(wrapper.find('[data-state="queued"]').text()).toContain('backend::lang.fileupload.queued') expect(wrapper.find('[data-state="queued"]').text()).toContain('backend::lang.fileupload.queued')
const first = FakeXHR.sent[0]! const first = FakeXHR.sent[0]!
expect(first.headers[SESSION_HEADER]).toBe(KEY) expect(first.headers[SESSION_HEADER]).toBe(KEY)
expect(first.headers[UPLOAD_ID_HEADER]).toMatch(/^[A-Za-z0-9_-]{8,}$/)
first.progress(30, 60) first.progress(30, 60)
await flushPromises() await flushPromises()
expect(wrapper.find('[data-state="uploading"] [role="progressbar"]').exists()).toBe(true) expect(wrapper.find('[data-state="uploading"] [role="progressbar"]').exists()).toBe(true)
@@ -220,6 +226,60 @@ describe('upload states', () => {
await flushPromises() await flushPromises()
expect(wrapper.findAll('[data-state="done"]')).toHaveLength(2) expect(wrapper.findAll('[data-state="done"]')).toHaveLength(2)
expect(markDirty).toHaveBeenCalledTimes(2) expect(markDirty).toHaveBeenCalledTimes(2)
expect(activeUploads.value).toBe(0)
})
it('keeps the same upload id on retry and holds the form upload gate until the XHR ends', async () => {
const { wrapper, activeUploads } = await setup()
await choose(wrapper, [png('a.png')])
expect(activeUploads.value).toBe(1)
const first = FakeXHR.sent[0]!
const id = first.headers[UPLOAD_ID_HEADER]
expect(id).toBeTruthy()
first.respond(500, { error: { code: 'error', message: 'Server error', details: {} } })
await flushPromises()
expect(activeUploads.value).toBe(0)
await wrapper.find('[data-action="retry"]').trigger('click')
await flushPromises()
expect(FakeXHR.sent[1]!.headers[UPLOAD_ID_HEADER]).toBe(id)
FakeXHR.sent[1]!.respond(201, { data: item(52, 'a.png', { pending: true }), meta: {} })
await flushPromises()
expect(activeUploads.value).toBe(0)
})
it('deletes a server-side file when the upload is aborted after the store', async () => {
let listed = 0
const { wrapper, calls } = await setup({
routes: {
[`GET ${BASE}/7/files/photos`]: () => {
listed++
return listed === 1 ? listOf([]) : listOf([item(99, 'lost.png', { pending: true })])
},
[`DELETE ${BASE}/7/files/photos/99`]: { body: { data: { removed: true, pending: true }, meta: {} } },
},
})
await choose(wrapper, [png('lost.png')])
FakeXHR.sent[0]!.abort()
await flushPromises()
expect(requestsTo(calls, 'DELETE', `${BASE}/7/files/photos/99`)).toHaveLength(1)
expect(wrapper.find('[data-file-item]').exists()).toBe(false)
})
it('adopts a stored file when the response is lost and keeps the same upload id on retry', async () => {
let listed = 0
const { wrapper } = await setup({
routes: {
[`GET ${BASE}/7/files/photos`]: () => {
listed++
return listed === 1 ? listOf([]) : listOf([item(77, 'net.png', { pending: true })])
},
},
})
await choose(wrapper, [png('net.png')])
FakeXHR.sent[0]!.onerror?.()
await flushPromises()
expect(wrapper.find('[data-state="done"]').exists()).toBe(true)
expect(wrapper.find('[data-file-item]').text()).toContain('net.png')
}) })
it('shows the Unsaved chip on pending files of a saved record only', async () => { it('shows the Unsaved chip on pending files of a saved record only', async () => {
@@ -346,6 +406,42 @@ describe('keyboard reorder (backstop 4)', () => {
expect(names(wrapper)).toEqual(['a.png', 'b.png', 'c.png']) expect(names(wrapper)).toEqual(['a.png', 'b.png', 'c.png'])
expect(toasts.value.at(-1)).toMatchObject({ text: 'backend::lang.fileupload.reorder_failed', tone: 'danger' }) expect(toasts.value.at(-1)).toMatchObject({ text: 'backend::lang.fileupload.reorder_failed', tone: 'danger' })
}) })
it('sends only the latest order when a second move happens while the first request is open', async () => {
const replies: Array<(ids: number[]) => Reply> = []
const seen: number[][] = []
const { wrapper, calls } = await setup({
routes: {
[`GET ${BASE}/7/files/photos`]: listOf(three),
[`POST ${BASE}/7/files/photos/reorder`]: async (request) => {
const body = (await request.clone().json()) as { ids: number[] }
seen.push(body.ids)
return await new Promise<Reply>((resolve) => {
replies.push((ids) => {
const ordered = ids.map((id) => three.find((file) => file.id === id)!)
const reply = { body: { data: ordered, meta: {} } }
resolve(reply)
return reply
})
})
},
},
})
keydown(wrapper.findAll('[data-handle]')[0]!.element, 'ArrowDown')
await flushPromises()
await wait(450)
await flushPromises()
expect(requestsTo(calls, 'POST', `${BASE}/7/files/photos/reorder`)).toHaveLength(1)
keydown(wrapper.findAll('[data-handle]')[1]!.element, 'ArrowDown')
await flushPromises()
await wait(450)
await flushPromises()
expect(replies).toHaveLength(1)
replies[0]!([42, 41, 43])
await flushPromises()
expect(requestsTo(calls, 'POST', `${BASE}/7/files/photos/reorder`)).toHaveLength(2)
expect(seen[1]).toEqual([42, 43, 41])
})
}) })
describe('caption modal', () => { describe('caption modal', () => {

View File

@@ -259,6 +259,8 @@ describe('relation manager row actions and deferral (Phase 12.2, D-03, D-12, D-1
markDirty: vi.fn(), markDirty: vi.fn(),
pendingChanges: ref(0), pendingChanges: ref(0),
revision: ref(0), revision: ref(0),
beginUpload: () => () => {},
activeUploads: ref(0),
} }
} }

View File

@@ -156,6 +156,7 @@ describe('fileupload on a new record (tracer)', () => {
expect(new URL(upload.url, 'http://local').pathname).toBe(`${BASE}/0/files/photos`) expect(new URL(upload.url, 'http://local').pathname).toBe(`${BASE}/0/files/photos`)
expect(upload.url).not.toContain('?') expect(upload.url).not.toContain('?')
expect(upload.headers[SESSION_HEADER]).toBe(key) expect(upload.headers[SESSION_HEADER]).toBe(key)
expect(upload.headers['X-Upload-Id']).toMatch(/^[A-Za-z0-9_-]{8,}$/)
expect(upload.headers['X-Requested-With']).toBe('XMLHttpRequest') expect(upload.headers['X-Requested-With']).toBe('XMLHttpRequest')
expect((upload.body!.get('file_data') as File).name).toBe('box.png') expect((upload.body!.get('file_data') as File).name).toBe('box.png')
expect(wrapper.find('[data-state="uploading"]').exists()).toBe(true) expect(wrapper.find('[data-state="uploading"]').exists()).toBe(true)
@@ -199,6 +200,23 @@ describe('fileupload on a new record (tracer)', () => {
expect(wrapper.find('[data-file-item]').text()).toContain('box.png') expect(wrapper.find('[data-file-item]').text()).toContain('box.png')
}) })
it('does not save while an upload is still in flight', async () => {
const { wrapper, calls } = await mountApp('/acme/demo/gadgets/create', routes())
await choose(wrapper, [png('box.png')])
expect(FakeXHR.sent).toHaveLength(1)
await wrapper.find('#field-name').setValue('Box')
expect(wrapper.find('[data-action="save"]').attributes('disabled')).toBeDefined()
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
expect(requestsTo(calls, 'POST', BASE)).toHaveLength(0)
FakeXHR.sent[0]!.respond(201, { data: fileItem(41, 'box.png', true), meta: {} })
await flushPromises()
expect(wrapper.find('[data-action="save"]').attributes('disabled')).toBeUndefined()
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
expect(requestsTo(calls, 'POST', BASE)).toHaveLength(1)
})
it('refuses a wrong type and an oversized file before sending and drops files past maxFiles', async () => { it('refuses a wrong type and an oversized file before sending and drops files past maxFiles', async () => {
const { wrapper } = await mountApp('/acme/demo/gadgets/create', routes()) const { wrapper } = await mountApp('/acme/demo/gadgets/create', routes())

View File

@@ -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 <prefix>/api/v1/lang`, the `backend::lang` bundle in the request locale, with CLDR plural forms. 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 <prefix>/api/v1/lang`, the `backend::lang` bundle in the request locale, with CLDR plural forms.
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`. Uploads use `XMLHttpRequest` for progress events and carry the same `X-Requested-With` header and cookie as every other call. 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. The key travels only in headers, never in a URL. See [File uploads](forms.md#file-uploads) for the `fileupload` field.
## Types from OpenAPI ## Types from OpenAPI

View File

@@ -168,7 +168,7 @@ The field takes the generic keys plus these WinterCMS keys:
| `mode` | `image` or `file` (the default). Image mode accepts only jpg, jpeg, png, gif and webp and checks that the bytes decode as such an image. | | `mode` | `image` or `file` (the default). Image mode accepts only jpg, jpeg, png, gif and webp and checks that the bytes decode as such an image. |
| `fileTypes` | Allowed extensions, as a comma- or pipe-separated string or a list. | | `fileTypes` | Allowed extensions, as a comma- or pipe-separated string or a list. |
| `mimeTypes` | Allowed MIME types (`image/png`, `image/*`) or extensions. | | `mimeTypes` | Allowed MIME types (`image/png`, `image/*`) or extensions. |
| `maxFilesize` | The largest file in megabytes. It may not exceed `http.body_limits.upload_bytes`. | | `maxFilesize` | The largest file in megabytes. The file plus 64 KiB of multipart framing may not exceed `http.body_limits.upload_bytes`. |
| `maxFiles` | The most files an attachMany relation may hold. Refused on attachOne. | | `maxFiles` | The most files an attachMany relation may hold. Refused on attachOne. |
| `imageWidth`, `imageHeight` | Preview size, 1 to 4096 pixels (240 by 240 when not set). | | `imageWidth`, `imageHeight` | Preview size, 1 to 4096 pixels (240 by 240 when not set). |
| `thumbOptions` | A mapping with `mode`: `auto`, `exact`, `crop` (the default) or `fit`. | | `thumbOptions` | A mapping with `mode`: `auto`, `exact`, `crop` (the default) or `fit`. |

View File

@@ -19,7 +19,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
- Toolbar actions: `toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` next to names the controller registers through `pact.HasAdminActions`. Registered actions share one namespace with widget actions, `create` and `delete` are reserved, and each toolbar action needs a label. The list schema's `toolbarActions` carries only the actions the requesting administrator may run, with localized labels; an unknown name fails boot. - Toolbar actions: `toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` next to names the controller registers through `pact.HasAdminActions`. Registered actions share one namespace with widget actions, `create` and `delete` are reserved, and each toolbar action needs a label. The list schema's `toolbarActions` carries only the actions the requesting administrator may run, with localized labels; an unknown name fails boot.
- Server-rendered partials: `headerPartial: <name>` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: <name>` in `fields.yaml` render the template `{ConfigDir}/_<name>.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot. - Server-rendered partials: `headerPartial: <name>` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: <name>` in `fields.yaml` render the template `{ConfigDir}/_<name>.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot.
- Date pickers: a `type: datepicker` field in `fields.yaml` edits a date (`mode: date`, a `lagoon.Date` column), a date and time (`mode: datetime`, the default, a `time.Time` column stored in UTC) or a time of day (`mode: time`, a `lagoon.TimeOfDay` column); pointers to the three types make the value optional. It accepts WinterCMS's `mode`, `format` (a PHP `date()` format, served also as `displayFormat` in the SPA's tokens), `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour` and `ignoreTimezone`; any other key, a format letter with no equivalent, bounds on `mode: time`, `ignoreTimezone` outside `mode: datetime` or a column whose Go type does not match the mode fails boot. The save rechecks `minDate` and `maxDate` on the calendar date and answers 422 on the field. List columns take `type: date` and `type: time` for these columns, and a struct column that implements `sql.Scanner` or `driver.Valuer` is never taken for a relation. - Date pickers: a `type: datepicker` field in `fields.yaml` edits a date (`mode: date`, a `lagoon.Date` column), a date and time (`mode: datetime`, the default, a `time.Time` column stored in UTC) or a time of day (`mode: time`, a `lagoon.TimeOfDay` column); pointers to the three types make the value optional. It accepts WinterCMS's `mode`, `format` (a PHP `date()` format, served also as `displayFormat` in the SPA's tokens), `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour` and `ignoreTimezone`; any other key, a format letter with no equivalent, bounds on `mode: time`, `ignoreTimezone` outside `mode: datetime` or a column whose Go type does not match the mode fails boot. The save rechecks `minDate` and `maxDate` on the calendar date and answers 422 on the field. List columns take `type: date` and `type: time` for these columns, and a struct column that implements `sql.Scanner` or `driver.Valuer` is never taken for a relation.
- File uploads: a `type: fileupload` field in `fields.yaml` edits an attachOne or attachMany relation the record model declares through `attach.HasRelations` (its `AttachRelations` method) next to `attach.Owner`. The field accepts WinterCMS's `mode` (`image` or `file`), `fileTypes`, `mimeTypes`, `maxFilesize` (megabytes), `maxFiles` (attachMany only), `imageWidth`, `imageHeight`, `thumbOptions` (only `mode`: `auto`, `exact`, `crop` or `fit`), `useCaption` and `prompt`; any other key, an image-mode file type outside jpg, jpeg, png, gif and webp, a name that is not a declared relation or a `maxFilesize` above `http.body_limits.upload_bytes` fails boot. Uploads and removals are deferred, as in WinterCMS: the SPA sends a random form session key in the `X-Session-Key` header (`cabana.SessionKeyHeader`) with every file call and with the save, the server keeps the pending work in `deferred_bindings` against that key and the signed-in administrator, and the record's next create or update save applies it inside its transaction. A save that fails with 422 keeps the pending uploads; another administrator's key matches nothing. The upload route caps the request body at the smaller of `http.body_limits.upload_bytes` and `maxFilesize` plus 64 KiB and answers 413 `payload_too_large` past it; the size, type and image checks run on the server (through `attach.Store`) and answer 422 on the field. A file list (`cabana.FileItem`) carries `url` and `thumb_url` only for a public relation. - File uploads: a `type: fileupload` field in `fields.yaml` edits an attachOne or attachMany relation the record model declares through `attach.HasRelations` (its `AttachRelations` method) next to `attach.Owner`. The field accepts WinterCMS's `mode` (`image` or `file`), `fileTypes`, `mimeTypes`, `maxFilesize` (megabytes), `maxFiles` (attachMany only), `imageWidth`, `imageHeight`, `thumbOptions` (only `mode`: `auto`, `exact`, `crop` or `fit`), `useCaption` and `prompt`; any other key, an image-mode file type outside jpg, jpeg, png, gif and webp, a name that is not a declared relation or a `maxFilesize` whose file plus 64 KiB of multipart framing exceeds `http.body_limits.upload_bytes` fails boot. Uploads and removals are deferred, as in WinterCMS: the SPA sends a random form session key in the `X-Session-Key` header (`cabana.SessionKeyHeader`) with every file call and with the save, the server keeps the pending work in `deferred_bindings` against that key and the signed-in administrator, and the record's next create or update save applies it inside its transaction. A retry of the same upload may send `X-Upload-Id` so the server returns the already stored file. A save that fails with 422 keeps the pending uploads; another administrator's key matches nothing. The upload route caps the request body at the smaller of `http.body_limits.upload_bytes` and `maxFilesize` plus 64 KiB and answers 413 `payload_too_large` past it; the size, type and image checks run on the server (through `attach.Store`) and answer 422 on the field. A file list (`cabana.FileItem`) carries `url` and `thumb_url` only for a public relation.
- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. - Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`.
- Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. An administrator's own `backend_users.permissions` are merged over the role's as in Winter (a `-1` denies a code the role grants). `cabana.Allows` implements the permission check with Winter's `hasAnyAccess` semantics: superusers pass, a principal needs any one of the listed codes, and wildcards match on both sides (a grant ending in `.*` covers every code with that prefix, and a required code ending in `.*` is met by any grant under it). - Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. An administrator's own `backend_users.permissions` are merged over the role's as in Winter (a `-1` denies a code the role grants). `cabana.Allows` implements the permission check with Winter's `hasAnyAccess` semantics: superusers pass, a principal needs any one of the listed codes, and wildcards match on both sides (a grant ending in `.*` covers every code with that prefix, and a required code ending in `.*` is met by any grant under it).
- Admin authentication against WinterCMS's `backend_users` and `backend_user_roles` tables (`cabana.BackendUser`, `cabana.BackendUserRole`, `cabana.BackendUsers`): a JWT guard registered in [bouncer](../bouncer/README.md) as `backend` (a guard another plugin already registered under that name fails `cabana.Activate`), login throttling, token refresh and revocation, and two transports. API clients use a Bearer token; the SPA sends `X-Requested-With: XMLHttpRequest` and receives the token in the HttpOnly, SameSite=Strict cookie named by `cabana.AdminCookieName`. Cookie-authenticated requests that change state must carry that header, which blocks cross-site request forgery. - Admin authentication against WinterCMS's `backend_users` and `backend_user_roles` tables (`cabana.BackendUser`, `cabana.BackendUserRole`, `cabana.BackendUsers`): a JWT guard registered in [bouncer](../bouncer/README.md) as `backend` (a guard another plugin already registered under that name fails `cabana.Activate`), login throttling, token refresh and revocation, and two transports. API clients use a Bearer token; the SPA sends `X-Requested-With: XMLHttpRequest` and receives the token in the HttpOnly, SameSite=Strict cookie named by `cabana.AdminCookieName`. Cookie-authenticated requests that change state must carry that header, which blocks cross-site request forgery.
@@ -210,8 +210,8 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `backend.uri` | `/backend` | Admin mount path. One or more lowercase path segments; boot fails on an invalid value. | | `backend.uri` | `/backend` | Admin mount path. One or more lowercase path segments; boot fails on an invalid value. |
| `backend.cookie_secure` | `true` | Set `false` to drop the cookie's Secure attribute for plain-HTTP development. Refused in the `production` environment. | | `backend.cookie_secure` | `true` | Set `false` to drop the cookie's Secure attribute for plain-HTTP development. Refused in the `production` environment. |
| `app.url` | empty | Base URL used for the token issuer. | | `app.url` | empty | Base URL used for the token issuer. |
| `http.body_limits.upload_bytes` | none | Read from the HTTP configuration: caps the body of a file upload (together with the field's `maxFilesize` plus 64 KiB), and no fileupload field may declare a larger `maxFilesize`. Without it the cap is the field's limit, or 128 MiB. | | `http.body_limits.upload_bytes` | none | Read from the HTTP configuration: caps the body of a file upload (together with the field's `maxFilesize` plus 64 KiB), and no fileupload field may declare a `maxFilesize` whose file plus 64 KiB of multipart framing exceeds it. Without it the cap is the field's limit, or 128 MiB. |
| `http.body_limits.default_bytes` | none | Read from the HTTP configuration: caps the JSON bodies of the file caption and reorder routes and of the relation child routes (1 MiB when not set; 413 `payload_too_large` past it). | | `http.body_limits.default_bytes` | none | Read from the HTTP configuration: caps the JSON bodies of create, update, settings, relation link/unlink, bulk delete, file caption and reorder, and relation child routes (1 MiB when not set; 413 `payload_too_large` past it). |
The backend user, role and token blacklist tables (`backend_users`, `backend_user_roles`, `backend_jwt_blacklist`) are created by `lagoon.BackendAdminMigrations`, which the `migrate` command runs. The backend user, role and token blacklist tables (`backend_users`, `backend_user_roles`, `backend_jwt_blacklist`) are created by `lagoon.BackendAdminMigrations`, which the `migrate` command runs.

View File

@@ -410,6 +410,11 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
} }
func writeCRUDError(w http.ResponseWriter, err error) { func writeCRUDError(w http.ResponseWriter, err error) {
var tooBig *http.MaxBytesError
if errors.As(err, &tooBig) {
WriteError(w, http.StatusRequestEntityTooLarge, "payload_too_large", msgPayloadTooLarge)
return
}
var ve *ValidationError var ve *ValidationError
if errors.As(err, &ve) { if errors.As(err, &ve) {
WriteErrorDetails(w, http.StatusUnprocessableEntity, "validation_failed", "Validation failed", ve.Details) WriteErrorDetails(w, http.StatusUnprocessableEntity, "validation_failed", "Validation failed", ve.Details)

View File

@@ -364,20 +364,21 @@ func compileFileFields(pluginID string, cc *CompiledController) error {
return nil return nil
} }
// checkFileLimits refuses a maxFilesize above http.body_limits.upload_bytes, // checkFileLimits refuses a maxFilesize whose file plus multipart framing
// as WinterCMS refuses one above upload_max_filesize. // cannot fit in http.body_limits.upload_bytes. Equality is not enough:
// the request cap is the whole multipart body.
func checkFileLimits(reg *Registry, uploadBytes int64) error { func checkFileLimits(reg *Registry, uploadBytes int64) error {
if reg == nil || uploadBytes <= 0 { if reg == nil || uploadBytes <= 0 {
return nil return nil
} }
for _, cc := range reg.byID { for _, cc := range reg.byID {
for _, cf := range cc.files { for _, cf := range cc.files {
if cf.maxBytes > uploadBytes { if cf.maxBytes > 0 && cf.maxBytes+multipartOverhead > uploadBytes {
path := "" path := ""
if cc.Form != nil { if cc.Form != nil {
path = cc.Form.fieldsPath path = cc.Form.fieldsPath
} }
return bootErr(cc.PluginID, controllerID(cc), path, fmt.Errorf("field %s: maxFilesize exceeds http.body_limits.upload_bytes", cf.name)) return bootErr(cc.PluginID, controllerID(cc), path, fmt.Errorf("field %s: maxFilesize plus multipart overhead exceeds http.body_limits.upload_bytes", cf.name))
} }
} }
} }
@@ -510,6 +511,10 @@ func parentFileScope(ctx context.Context, tx *gorm.DB, r *http.Request, cc *Comp
// attached to the owner minus the session's pending removals, plus the // attached to the owner minus the session's pending removals, plus the
// session's pending uploads, in sort_order then id order. // session's pending uploads, in sort_order then id order.
func (sc *fileScope) visibleFiles(tx *gorm.DB) ([]attach.File, map[uint]bool, error) { func (sc *fileScope) visibleFiles(tx *gorm.DB) ([]attach.File, map[uint]bool, error) {
return sc.visibleFilesLocked(tx, false)
}
func (sc *fileScope) visibleFilesLocked(tx *gorm.DB, lock bool) ([]attach.File, map[uint]bool, error) {
q := tx.Session(&gorm.Session{NewDB: true}).Model(&attach.File{}) q := tx.Session(&gorm.Session{NewDB: true}).Model(&attach.File{})
var attached *gorm.DB var attached *gorm.DB
if sc.ownerID > 0 { if sc.ownerID > 0 {
@@ -534,6 +539,9 @@ func (sc *fileScope) visibleFiles(tx *gorm.DB) ([]attach.File, map[uint]bool, er
default: default:
return nil, nil, nil return nil, nil, nil
} }
if lock {
q = q.Clauses(clause.Locking{Strength: "UPDATE"})
}
var files []attach.File var files []attach.File
if err := q.Order("sort_order").Order("id").Find(&files).Error; err != nil { if err := q.Order("sort_order").Order("id").Find(&files).Error; err != nil {
return nil, nil, err return nil, nil, err
@@ -547,6 +555,60 @@ func (sc *fileScope) visibleFiles(tx *gorm.DB) ([]attach.File, map[uint]bool, er
return files, isPending, nil return files, isPending, nil
} }
const uploadIDHeader = "X-Upload-Id"
const maxUploadIDLen = 64
func parseUploadID(r *http.Request) string {
raw := strings.TrimSpace(r.Header.Get(uploadIDHeader))
if raw == "" || len(raw) > maxUploadIDLen {
return ""
}
for _, c := range raw {
if (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') && (c < '0' || c > '9') && c != '-' && c != '_' {
return ""
}
}
return raw
}
// fileForUploadID returns the pending file already stored for this
// session, field and client upload id, or nil when none exists.
func (sc *fileScope) fileForUploadID(ctx context.Context, tx *gorm.DB, uploadID string) (*attach.File, error) {
if uploadID == "" || !sc.hasKey {
return nil, nil
}
rows, err := lagoon.DeferredBindings(ctx, tx, sc.key, []string{sc.file.name})
if err != nil {
return nil, err
}
for _, row := range rows {
if !row.IsBind || row.SlaveType != lagoon.DeferredFileType {
continue
}
env, err := row.Envelope()
if err != nil {
return nil, err
}
if env.UploadID != uploadID {
continue
}
id, err := strconv.ParseUint(row.SlaveID, 10, 64)
if err != nil {
return nil, err
}
var f attach.File
err = tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Where("id = ?", uint(id)).Take(&f).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &f, nil
}
return nil, nil
}
// fileItem projects a stored file. Public URLs are emitted only for a // fileItem projects a stored file. Public URLs are emitted only for a
// public relation (never for a protected file, D-10). // public relation (never for a protected file, D-10).
func fileItem(ctx context.Context, bucket *blob.Bucket, cf *compiledFile, f *attach.File, pending bool) FileItem { func fileItem(ctx context.Context, bucket *blob.Bucket, cf *compiledFile, f *attach.File, pending bool) FileItem {
@@ -643,6 +705,7 @@ func (s *service) fileUploadOn(w http.ResponseWriter, r *http.Request, cc *Compi
WriteError(w, http.StatusInternalServerError, "error", msgServerError) WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return return
} }
uploadID := parseUploadID(r)
body := &bodyReader{r: http.MaxBytesReader(w, r.Body, s.uploadCap(cf))} body := &bodyReader{r: http.MaxBytesReader(w, r.Body, s.uploadCap(cf))}
r.Body = io.NopCloser(body) r.Body = io.NopCloser(body)
mr, err := r.MultipartReader() mr, err := r.MultipartReader()
@@ -667,6 +730,12 @@ func (s *service) fileUploadOn(w http.ResponseWriter, r *http.Request, cc *Compi
if err != nil { if err != nil {
return err return err
} }
if existing, err := sc.fileForUploadID(ctx, tx, uploadID); err != nil {
return lifecycleFailure(cc, err)
} else if existing != nil {
item = fileItem(ctx, bucket, cf, existing, true)
return nil
}
if cf.relation.Many && cf.maxFiles > 0 { if cf.relation.Many && cf.maxFiles > 0 {
files, _, err := sc.visibleFiles(tx) files, _, err := sc.visibleFiles(tx)
if err != nil { if err != nil {
@@ -688,7 +757,11 @@ func (s *service) fileUploadOn(w http.ResponseWriter, r *http.Request, cc *Compi
} }
return err return err
} }
if err := lagoon.DeferredBind(ctx, tx, sc.key, cf.name, lagoon.DeferredFileType, uitoa(f.ID), nil); err != nil { var env *lagoon.DeferredEnvelope
if uploadID != "" {
env = &lagoon.DeferredEnvelope{UploadID: uploadID}
}
if err := lagoon.DeferredBind(ctx, tx, sc.key, cf.name, lagoon.DeferredFileType, uitoa(f.ID), env); err != nil {
return lifecycleFailure(cc, err) return lifecycleFailure(cc, err)
} }
item = fileItem(ctx, bucket, cf, f, true) item = fileItem(ctx, bucket, cf, f, true)
@@ -1245,7 +1318,7 @@ func (s *service) fileReorderOn(w http.ResponseWriter, r *http.Request, cc *Comp
bucket := s.bucket() bucket := s.bucket()
var items []FileItem var items []FileItem
ok := s.withFileScope(w, r, cc, fr, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error { ok := s.withFileScope(w, r, cc, fr, func(ctx context.Context, tx *gorm.DB, sc *fileScope) error {
files, _, err := sc.visibleFiles(tx) files, _, err := sc.visibleFilesLocked(tx, true)
if err != nil { if err != nil {
return err return err
} }

View File

@@ -65,9 +65,19 @@ func TestFileuploadCompile(t *testing.T) {
} }
err = activateDeferred(t, dfPlugin{fsys: dfOverlay(t, map[string]string{"models/gadget/fields.yaml": dfGadgetFields(photo(" maxFilesize: 2\n"))})}, err = activateDeferred(t, dfPlugin{fsys: dfOverlay(t, map[string]string{"models/gadget/fields.yaml": dfGadgetFields(photo(" maxFilesize: 2\n"))})},
map[string]any{"http.body_limits.upload_bytes": 1048576}) map[string]any{"http.body_limits.upload_bytes": 1048576})
if err == nil || !strings.Contains(err.Error(), "field photos: maxFilesize exceeds http.body_limits.upload_bytes") { if err == nil || !strings.Contains(err.Error(), "field photos: maxFilesize plus multipart overhead exceeds http.body_limits.upload_bytes") {
t.Fatalf("maxFilesize over upload_bytes: %v", err) t.Fatalf("maxFilesize over upload_bytes: %v", err)
} }
// Equality leaves no room for multipart framing.
err = activateDeferred(t, dfPlugin{fsys: dfOverlay(t, map[string]string{"models/gadget/fields.yaml": dfGadgetFields(photo(" maxFilesize: 1\n"))})},
map[string]any{"http.body_limits.upload_bytes": 1048576})
if err == nil || !strings.Contains(err.Error(), "field photos: maxFilesize plus multipart overhead exceeds http.body_limits.upload_bytes") {
t.Fatalf("maxFilesize equal to upload_bytes: %v", err)
}
if err := activateDeferred(t, dfPlugin{fsys: dfOverlay(t, map[string]string{"models/gadget/fields.yaml": dfGadgetFields(photo(" maxFilesize: 1\n"))})},
map[string]any{"http.body_limits.upload_bytes": 1048576 + 64<<10}); err != nil {
t.Fatalf("maxFilesize with 64 KiB headroom: %v", err)
}
if err := bootGadgetFields(t, dfGadgetFields(photo(" mode: image\n fileTypes: jpg|png\n mimeTypes: image/png, png\n maxFiles: 2\n imageWidth: 120\n thumbOptions:\n mode: fit\n useCaption: true\n prompt: Drop\n"))); err != nil { if err := bootGadgetFields(t, dfGadgetFields(photo(" mode: image\n fileTypes: jpg|png\n mimeTypes: image/png, png\n maxFiles: 2\n imageWidth: 120\n thumbOptions:\n mode: fit\n useCaption: true\n prompt: Drop\n"))); err != nil {
t.Fatalf("every key: %v", err) t.Fatalf("every key: %v", err)
} }
@@ -332,3 +342,41 @@ func TestFileuploadMIME(t *testing.T) {
} }
want(t, "text", env.a.upload(t, dfPath(g, "/files/manual"), "a.txt", []byte("hello"), sk(key)), http.StatusCreated) want(t, "text", env.a.upload(t, dfPath(g, "/files/manual"), "a.txt", []byte("hello"), sk(key)), http.StatusCreated)
} }
// TestFileuploadUploadID: a repeated X-Upload-Id returns the stored file
// instead of creating a second binding (CR-02).
func TestFileuploadUploadID(t *testing.T) {
env := newDeferredEnv(t)
key := newSessionKey(t)
h := map[string]string{cabana.SessionKeyHeader: key, "X-Upload-Id": "retry-one"}
first := env.a.upload(t, photosPath(0, ""), "a.png", conformPNG(t), h)
want(t, "first upload", first, http.StatusCreated)
id := dataID(t, first.Body.Bytes())
again := env.a.upload(t, photosPath(0, ""), "b.png", conformPNG(t), h)
want(t, "same upload id", again, http.StatusCreated)
if got := dataID(t, again.Body.Bytes()); got != id {
t.Fatalf("retry created %d, want %d", got, id)
}
if got := fileItems(t, env.a, 0, "photos", key); len(got) != 1 || got[0].ID != id {
t.Fatalf("list = %+v", got)
}
}
// TestJSONBodyCaps: create, update, link, unlink and settings refuse a
// JSON body past http.body_limits.default_bytes (CR-03).
func TestJSONBodyCaps(t *testing.T) {
env := newDeferredEnv(t)
huge := strings.Repeat("x", 1100<<10)
want(t, "create", env.a.do(t, http.MethodPost, "/acme/deferred/gadgets", map[string]any{"name": huge}, nil), http.StatusRequestEntityTooLarge)
g := env.gadget(t, "g-"+env.stamp, false)
want(t, "update", env.a.do(t, http.MethodPut, dfPath(g, ""), map[string]any{"name": huge}, nil), http.StatusRequestEntityTooLarge)
want(t, "link", env.a.do(t, http.MethodPost, dfPath(g, "/relations/members/link"), map[string]any{"ids": []uint{1}, "pad": huge}, nil), http.StatusRequestEntityTooLarge)
want(t, "unlink", env.a.do(t, http.MethodPost, dfPath(g, "/relations/members/unlink"), map[string]any{"ids": []uint{1}, "pad": huge}, nil), http.StatusRequestEntityTooLarge)
settings := newConformEnv(t)
settings.send(t, http.MethodPost, "/auth/login", map[string]string{"login": settings.login, "password": adminTestPassword}, false)
rec := settings.send(t, http.MethodPut, "/settings/conform", map[string]any{"enabled": true, "pad": huge}, true)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("settings: status=%d want %d body=%s", rec.Code, http.StatusRequestEntityTooLarge, rec.Body.String())
}
}

View File

@@ -466,7 +466,7 @@ func (s *service) settingsGet(w http.ResponseWriter, r *http.Request) {
func (s *service) settingsPut(w http.ResponseWriter, r *http.Request) { func (s *service) settingsPut(w http.ResponseWriter, r *http.Request) {
s.protectSetting(w, r, func(setting *CompiledSetting) { s.protectSetting(w, r, func(setting *CompiledSetting) {
body, err := decodeObject(r) body, err := s.decodeCappedObject(w, r)
if err != nil { if err != nil {
writeCRUDError(w, err) writeCRUDError(w, err)
return return
@@ -587,7 +587,7 @@ func (s *service) relationMutation(w http.ResponseWriter, r *http.Request, link
writeCRUDError(w, err) writeCRUDError(w, err)
return return
} }
in, err := decodeRelationMutation(r) in, err := s.decodeCappedRelationMutation(w, r)
if err != nil { if err != nil {
writeCRUDError(w, err) writeCRUDError(w, err)
return return
@@ -611,11 +611,19 @@ func (s *service) relationMutation(w http.ResponseWriter, r *http.Request, link
} }
func decodeRelationMutation(r *http.Request) (RelationMutationInput, error) { func decodeRelationMutation(r *http.Request) (RelationMutationInput, error) {
dec := json.NewDecoder(r.Body) return decodeRelationMutationBody(r.Body)
}
func decodeRelationMutationBody(body io.Reader) (RelationMutationInput, error) {
dec := json.NewDecoder(body)
dec.UseNumber() dec.UseNumber()
dec.DisallowUnknownFields() dec.DisallowUnknownFields()
var in RelationMutationInput var in RelationMutationInput
if err := dec.Decode(&in); err != nil { if err := dec.Decode(&in); err != nil {
var tooBig *http.MaxBytesError
if errors.As(err, &tooBig) {
return RelationMutationInput{}, err
}
return RelationMutationInput{}, relationInvalid("body", "The request body is invalid.") return RelationMutationInput{}, relationInvalid("body", "The request body is invalid.")
} }
var trailing any var trailing any
@@ -625,6 +633,10 @@ func decodeRelationMutation(r *http.Request) (RelationMutationInput, error) {
return in, nil return in, nil
} }
func (s *service) decodeCappedRelationMutation(w http.ResponseWriter, r *http.Request) (RelationMutationInput, error) {
return decodeRelationMutationBody(http.MaxBytesReader(w, r.Body, s.jsonCap()))
}
func (s *service) relations() (RelationService, error) { func (s *service) relations() (RelationService, error) {
db, err := s.db() db, err := s.db()
if err != nil { if err != nil {
@@ -758,7 +770,7 @@ func (s *service) create(w http.ResponseWriter, r *http.Request) {
writeCRUDError(w, err) writeCRUDError(w, err)
return return
} }
body, err := decodeObject(r) body, err := s.decodeCappedObject(w, r)
if err != nil { if err != nil {
writeCRUDError(w, err) writeCRUDError(w, err)
return return
@@ -792,7 +804,7 @@ func (s *service) update(w http.ResponseWriter, r *http.Request) {
writeCRUDError(w, err) writeCRUDError(w, err)
return return
} }
body, err := decodeObject(r) body, err := s.decodeCappedObject(w, r)
if err != nil { if err != nil {
writeCRUDError(w, err) writeCRUDError(w, err)
return return
@@ -816,7 +828,7 @@ func (s *service) bulkDelete(w http.ResponseWriter, r *http.Request) {
if !s.operationDeclared(w, r, cc, "bulk-delete") { if !s.operationDeclared(w, r, cc, "bulk-delete") {
return return
} }
in, err := decodeBulk(r) in, err := s.decodeCappedBulk(w, r)
if err != nil { if err != nil {
writeCRUDError(w, err) writeCRUDError(w, err)
return return
@@ -836,15 +848,27 @@ func (s *service) bulkDelete(w http.ResponseWriter, r *http.Request) {
} }
func decodeBulk(r *http.Request) (BulkDeleteInput, error) { func decodeBulk(r *http.Request) (BulkDeleteInput, error) {
dec := json.NewDecoder(r.Body) return decodeBulkBody(r.Body)
}
func decodeBulkBody(body io.Reader) (BulkDeleteInput, error) {
dec := json.NewDecoder(body)
dec.UseNumber() dec.UseNumber()
var in BulkDeleteInput var in BulkDeleteInput
if err := dec.Decode(&in); err != nil { if err := dec.Decode(&in); err != nil {
var tooBig *http.MaxBytesError
if errors.As(err, &tooBig) {
return BulkDeleteInput{}, err
}
return BulkDeleteInput{}, &ValidationError{Details: map[string]any{"body": []string{"The request body is invalid."}}} return BulkDeleteInput{}, &ValidationError{Details: map[string]any{"body": []string{"The request body is invalid."}}}
} }
return in, nil return in, nil
} }
func (s *service) decodeCappedBulk(w http.ResponseWriter, r *http.Request) (BulkDeleteInput, error) {
return decodeBulkBody(http.MaxBytesReader(w, r.Body, s.jsonCap()))
}
func (s *service) deleteRecord(w http.ResponseWriter, r *http.Request) { func (s *service) deleteRecord(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) { s.protect(w, r, func(cc *CompiledController) {
if !s.operationDeclared(w, r, cc, "delete") { if !s.operationDeclared(w, r, cc, "delete") {

View File

@@ -469,7 +469,9 @@ func (s RelationService) ShowPivot(ctx context.Context, cc *CompiledController,
return lifecycleFailure(cc, err) return lifecycleFailure(cc, err)
} }
row := cr.Contract.NewPivot() row := cr.Contract.NewPivot()
_ = lagoon.Fill(row, pivotFillKeys(cr), ProjectWritableFields(cr.pivot, env.Pivot), false) if err := lagoon.Fill(row, pivotFillKeys(cr), ProjectWritableFields(cr.pivot, env.Pivot), false); err != nil {
return lifecycleFailure(cc, err)
}
data = pivotRecord(cr, row, childID) data = pivotRecord(cr, row, childID)
return nil return nil
} }
@@ -564,7 +566,9 @@ func (s RelationService) updatePendingPivot(ctx context.Context, tx *gorm.DB, cc
return nil, lifecycleFailure(cc, err) return nil, lifecycleFailure(cc, err)
} }
row := cr.Contract.NewPivot() row := cr.Contract.NewPivot()
_ = lagoon.Fill(row, pivotFillKeys(cr), ProjectWritableFields(cr.pivot, env.Pivot), false) if err := lagoon.Fill(row, pivotFillKeys(cr), ProjectWritableFields(cr.pivot, env.Pivot), false); err != nil {
return nil, lifecycleFailure(cc, err)
}
if err := s.fillPivot(ctx, tx, cr, row, values); err != nil { if err := s.fillPivot(ctx, tx, cr, row, values); err != nil {
return nil, err return nil, err
} }

View File

@@ -586,3 +586,25 @@ func TestRelationChildPurgeModels(t *testing.T) {
t.Fatalf("listed models failed boot: %v", err) t.Fatalf("listed models failed boot: %v", err)
} }
} }
// TestPendingPivotFillError: a deferred pivot value that no longer fits
// the column is an error, not a silent zero (WR-04).
func TestPendingPivotFillError(t *testing.T) {
env := newDeferredEnv(t)
key := newSessionKey(t)
h := sk(key)
m := env.member(t, "p-"+env.stamp+"@example.test")
want(t, "link", env.a.do(t, http.MethodPost, dfPath(0, "/relations/members/link"), map[string]any{"ids": []uint{m}, "pivot": map[string]any{"note": "ok"}}, h), http.StatusOK)
bad := `{"created":false,"pivot":{"note":true}}`
if err := env.db.Model(&lagoon.DeferredBinding{}).Where("session_key = ? AND master_field = ?", key, "members").Update("pivot_data", bad).Error; err != nil {
t.Fatal(err)
}
shown := env.a.do(t, http.MethodGet, dfPath(0, fmt.Sprintf("/relations/members/pivot/%d", m)), nil, h)
if shown.Code < 400 {
t.Fatalf("show bad pivot: status=%d body=%s", shown.Code, shown.Body.String())
}
edited := env.a.do(t, http.MethodPut, dfPath(0, fmt.Sprintf("/relations/members/pivot/%d", m)), map[string]any{"note": "x"}, h)
if edited.Code < 400 {
t.Fatalf("update bad pivot: status=%d body=%s", edited.Code, edited.Body.String())
}
}

View File

@@ -24,7 +24,7 @@ Postgres data layer: the shared GORM connection, per-plugin migrations, model he
- Date and time columns: `lagoon.Date` (a `DATE` column, JSON `"2026-10-02"`) and `lagoon.TimeOfDay` (a `TIME` column, JSON `"14:30:00"`) implement `sql.Scanner`, `driver.Valuer`, JSON and text marshalling, and store NULL for their zero value; `*lagoon.Date` and `*lagoon.TimeOfDay` are the nullable variants, next to `time.Time` and `*time.Time` for `timestamptz`. Build them with `lagoon.NewDate`, `lagoon.DateOf`, `lagoon.ParseDate`, `lagoon.NewTimeOfDay` and `lagoon.ParseTimeOfDay`. `lagoon.Fill` fills all six from JSON strings (RFC 3339 for `time.Time`) through their text unmarshalling, after every conversion it already made. Behaviour change: `required` now treats a zero `time.Time`, `lagoon.Date` or `lagoon.TimeOfDay` (or a pointer to one) as empty, so declare optional dates as pointer fields. - Date and time columns: `lagoon.Date` (a `DATE` column, JSON `"2026-10-02"`) and `lagoon.TimeOfDay` (a `TIME` column, JSON `"14:30:00"`) implement `sql.Scanner`, `driver.Valuer`, JSON and text marshalling, and store NULL for their zero value; `*lagoon.Date` and `*lagoon.TimeOfDay` are the nullable variants, next to `time.Time` and `*time.Time` for `timestamptz`. Build them with `lagoon.NewDate`, `lagoon.DateOf`, `lagoon.ParseDate`, `lagoon.NewTimeOfDay` and `lagoon.ParseTimeOfDay`. `lagoon.Fill` fills all six from JSON strings (RFC 3339 for `time.Time`) through their text unmarshalling, after every conversion it already made. Behaviour change: `required` now treats a zero `time.Time`, `lagoon.Date` or `lagoon.TimeOfDay` (or a pointer to one) as empty, so declare optional dates as pointer fields.
- Column types: `lagoon.Encrypted` stores AES-256-GCM ciphertext under a key derived from `app.key`, decrypts with previous keys during rotation, and always redacts itself in JSON and string output; `lagoon.Jsonable` stores JSON as TEXT and keeps SQL NULL distinct from an empty value. - Column types: `lagoon.Encrypted` stores AES-256-GCM ciphertext under a key derived from `app.key`, decrypts with previous keys during rotation, and always redacts itself in JSON and string output; `lagoon.Jsonable` stores JSON as TEXT and keeps SQL NULL distinct from an empty value.
- Lifecycle and relations: hook interfaces matching GORM's native method names (`lagoon.HasBeforeCreate`, `lagoon.HasBeforeSave`, `lagoon.HasBeforeDelete`, `lagoon.HasAfterDelete`) plus `lagoon.HasBeforeValidate`; `lagoon.WithSoftDeleteCascade` runs a cascade inside the parent delete; `lagoon.RegisterJoinTable` wires pivot models with business columns. - Lifecycle and relations: hook interfaces matching GORM's native method names (`lagoon.HasBeforeCreate`, `lagoon.HasBeforeSave`, `lagoon.HasBeforeDelete`, `lagoon.HasAfterDelete`) plus `lagoon.HasBeforeValidate`; `lagoon.WithSoftDeleteCascade` runs a cascade inside the parent delete; `lagoon.RegisterJoinTable` wires pivot models with business columns.
- Deferred binding: WinterCMS's `deferred_bindings` table holds the uploads and related-record changes of a form whose record is not saved yet. Every operation takes a `lagoon.DeferredKey` (the form's session key, the owning backend admin's id and the master record's morph type from `lagoon.MorphType`) and never reads or changes another admin's rows, since each row stores `backend_user_id`. `lagoon.DeferredBind` and `lagoon.DeferredUnbind` port WinterCMS's duplicate and cancel rules: a repeated bind writes nothing, and an unbind of a slave with a pending bind deletes that bind and returns it so the caller can remove what it created. `lagoon.DeferredBindings` reads and locks a session's bindings for the save that commits them, `lagoon.DeferredForget` deletes them once applied, and `lagoon.DeferredSlaves` is the subquery a list uses to include pending rows. A child created under deferral carries the `lagoon.DeferredEnvelope` (`{"created":true,"pivot":{...}}`) in `pivot_data`. `lagoon.PurgeDeferred` removes expired bindings: it deletes an unattached `system_files` row a bind points at, and its blobs only after the commit, deletes a child only when its binding carries the created envelope, keeps records that were only linked, and locks each batch with `FOR UPDATE SKIP LOCKED`. - Deferred binding: WinterCMS's `deferred_bindings` table holds the uploads and related-record changes of a form whose record is not saved yet. Every operation takes a `lagoon.DeferredKey` (the form's session key, the owning backend admin's id and the master record's morph type from `lagoon.MorphType`) and never reads or changes another admin's rows, since each row stores `backend_user_id`. `lagoon.DeferredBind` and `lagoon.DeferredUnbind` port WinterCMS's duplicate and cancel rules: a repeated bind writes nothing, and an unbind of a slave with a pending bind deletes that bind and returns it so the caller can remove what it created. `lagoon.DeferredBindings` reads and locks a session's bindings for the save that commits them, `lagoon.DeferredForget` deletes them once applied, and `lagoon.DeferredSlaves` is the subquery a list uses to include pending rows. A child created under deferral carries the `lagoon.DeferredEnvelope` (`{"created":true,"pivot":{...},"upload_id":"..."}`) in `pivot_data`. `lagoon.PurgeDeferred` removes expired bindings: it deletes an unattached `system_files` row a bind points at, and its blobs only after the commit, deletes a child only when its binding carries the created envelope, keeps records that were only linked, and locks each batch with `FOR UPDATE SKIP LOCKED`.
- Imports from Laravel: `lagoon.DecryptLaravelPayload` decrypts Laravel `encrypted` payloads with the old application key, for one-off data imports. - Imports from Laravel: `lagoon.DecryptLaravelPayload` decrypts Laravel `encrypted` payloads with the old application key, for one-off data imports.
- Attachments (`attach`): the `attach.File` model for `system_files` rows, WinterCMS-compatible partitioned storage keys (`attach.BlobKey`, `attach.PartitionDirectory`), public URLs (`attach.PublicURL` for any key, `attach.File.URL` for an original, matching WinterCMS's `File::getPath()` under the WinterCMS layout), on-demand thumbnails through `attach.File.Thumb` for JPEG, PNG, GIF and WebP originals (a WebP original's thumbnail is JPEG bytes under its `.webp` name, since WebP cannot be encoded; a missing, undecodable or oversized original gets WinterCMS's broken-image picture, `attach.BrokenImagePNG`, as its thumbnail, as `File::makeThumb` does), storing uploads through `attach.Store` (a server-generated disk name, an extension allow-list with `attach.DefaultImageExtensions` and `attach.DefaultFileExtensions` as defaults, a MIME filter, a size limit enforced while streaming and, in image mode, the `attach.IsAllowedImage` content guard), attachment relation declarations (`attach.Relation`, `attach.HasRelations`), static serving with an optional `is_public` gate (`attach.StaticHandlerPublic`), and a two-phase delete that removes blobs only after the database transaction commits (`attach.DeleteForOwner`, `attach.DeleteKeys`). - Attachments (`attach`): the `attach.File` model for `system_files` rows, WinterCMS-compatible partitioned storage keys (`attach.BlobKey`, `attach.PartitionDirectory`), public URLs (`attach.PublicURL` for any key, `attach.File.URL` for an original, matching WinterCMS's `File::getPath()` under the WinterCMS layout), on-demand thumbnails through `attach.File.Thumb` for JPEG, PNG, GIF and WebP originals (a WebP original's thumbnail is JPEG bytes under its `.webp` name, since WebP cannot be encoded; a missing, undecodable or oversized original gets WinterCMS's broken-image picture, `attach.BrokenImagePNG`, as its thumbnail, as `File::makeThumb` does), storing uploads through `attach.Store` (a server-generated disk name, an extension allow-list with `attach.DefaultImageExtensions` and `attach.DefaultFileExtensions` as defaults, a MIME filter, a size limit enforced while streaming and, in image mode, the `attach.IsAllowedImage` content guard), attachment relation declarations (`attach.Relation`, `attach.HasRelations`), static serving with an optional `is_public` gate (`attach.StaticHandlerPublic`), and a two-phase delete that removes blobs only after the database transaction commits (`attach.DeleteForOwner`, `attach.DeleteKeys`).
@@ -133,7 +133,7 @@ func (p *Plugin) Migrations() []*gormigrate.Migration {
| `lagoon.DeferredHistoryID` | History id of the deferred-binding set, `summercms.deferred`. | | `lagoon.DeferredHistoryID` | History id of the deferred-binding set, `summercms.deferred`. |
| `lagoon.DeferredBinding` | The `deferred_bindings` row model; `lagoon.DeferredBinding.Envelope` decodes its `pivot_data`. | | `lagoon.DeferredBinding` | The `deferred_bindings` row model; `lagoon.DeferredBinding.Envelope` decodes its `pivot_data`. |
| `lagoon.DeferredKey` | Session key, admin id and master type that scope every deferred-binding operation. | | `lagoon.DeferredKey` | Session key, admin id and master type that scope every deferred-binding operation. |
| `lagoon.DeferredEnvelope` | The framework's `pivot_data` shape: `Created` marks a child created under deferral, `Pivot` holds pivot values. | | `lagoon.DeferredEnvelope` | The framework's `pivot_data` shape: `Created` marks a child created under deferral, `Pivot` holds pivot values, `UploadID` is the client upload id of a deferred file. |
| `lagoon.DeferredFileType` | The `slave_type` of a binding that points at a `system_files` row. | | `lagoon.DeferredFileType` | The `slave_type` of a binding that points at a `system_files` row. |
| `lagoon.MorphType` | The `master_type` or `slave_type` string of a model: its `attach.Owner` morph name, else its table name. | | `lagoon.MorphType` | The `master_type` or `slave_type` string of a model: its `attach.Owner` morph name, else its table name. |
| `lagoon.DeferredBind` | Records a pending bind; a repeat writes nothing and a pending unbind of the same slave is cancelled. | | `lagoon.DeferredBind` | Records a pending bind; a repeat writes nothing and a pending unbind of the same slave is cancelled. |

View File

@@ -69,8 +69,9 @@ func (k DeferredKey) validate() error {
// holds the pivot values of a deferred belongsToMany link. A binding without // holds the pivot values of a deferred belongsToMany link. A binding without
// the envelope (nil pivot_data, or JSON without these keys) is a plain link. // the envelope (nil pivot_data, or JSON without these keys) is a plain link.
type DeferredEnvelope struct { type DeferredEnvelope struct {
Created bool `json:"created,omitempty"` Created bool `json:"created,omitempty"`
Pivot map[string]any `json:"pivot,omitempty"` Pivot map[string]any `json:"pivot,omitempty"`
UploadID string `json:"upload_id,omitempty"`
} }
// Envelope decodes the binding's pivot_data. Nil or empty pivot_data is the // Envelope decodes the binding's pivot_data. Nil or empty pivot_data is the
@@ -167,7 +168,7 @@ func insertBinding(ctx context.Context, tx *gorm.DB, key DeferredKey, field, sla
IsBind: bind, IsBind: bind,
BackendUserID: key.AdminID, BackendUserID: key.AdminID,
} }
if env != nil && (env.Created || len(env.Pivot) > 0) { if env != nil && (env.Created || len(env.Pivot) > 0 || env.UploadID != "") {
raw, err := json.Marshal(env) raw, err := json.Marshal(env)
if err != nil { if err != nil {
return fmt.Errorf("lagoon: deferred binding envelope: %w", err) return fmt.Errorf("lagoon: deferred binding envelope: %w", err)

View File

@@ -362,15 +362,19 @@ func TestDeferredStore(t *testing.T) {
if err := DeferredBind(ctx, tx, key, "members", "acme_members", "2", &DeferredEnvelope{Pivot: map[string]any{"note": "hi"}}); err != nil { if err := DeferredBind(ctx, tx, key, "members", "acme_members", "2", &DeferredEnvelope{Pivot: map[string]any{"note": "hi"}}); err != nil {
return err return err
} }
// An empty envelope stores no pivot_data. // An empty envelope stores no pivot_data; upload_id alone is stored.
return DeferredBind(ctx, tx, key, "members", "acme_members", "3", &DeferredEnvelope{}) if err := DeferredBind(ctx, tx, key, "members", "acme_members", "3", &DeferredEnvelope{}); err != nil {
return err
}
return DeferredBind(ctx, tx, key, "photos", "system_files", "4", &DeferredEnvelope{UploadID: "retry-one"})
}) })
rows := bindingRows(t, gdb) rows := bindingRows(t, gdb)
if len(rows) != 3 || rows[0].PivotData == nil || *rows[0].PivotData != `{"created":true}` || if len(rows) != 4 || rows[0].PivotData == nil || *rows[0].PivotData != `{"created":true}` ||
rows[1].PivotData == nil || *rows[1].PivotData != `{"pivot":{"note":"hi"}}` || rows[2].PivotData != nil { rows[1].PivotData == nil || *rows[1].PivotData != `{"pivot":{"note":"hi"}}` || rows[2].PivotData != nil ||
rows[3].PivotData == nil || *rows[3].PivotData != `{"upload_id":"retry-one"}` {
t.Fatalf("pivot_data %+v", rows) t.Fatalf("pivot_data %+v", rows)
} }
for i, want := range []DeferredEnvelope{{Created: true}, {Pivot: map[string]any{"note": "hi"}}, {}} { for i, want := range []DeferredEnvelope{{Created: true}, {Pivot: map[string]any{"note": "hi"}}, {}, {UploadID: "retry-one"}} {
env, err := rows[i].Envelope() env, err := rows[i].Envelope()
if err != nil || fmt.Sprint(env) != fmt.Sprint(want) { if err != nil || fmt.Sprint(env) != fmt.Sprint(want) {
t.Fatalf("envelope %d = %+v %v", i, env, err) t.Fatalf("envelope %d = %+v %v", i, env, err)